@agent-plan/core 0.2.25 → 0.2.27
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/dist/description-freshness.d.ts +37 -0
- package/dist/description-freshness.d.ts.map +1 -0
- package/dist/description-freshness.js +84 -0
- package/dist/display-status.d.ts +3 -3
- package/dist/display-status.d.ts.map +1 -1
- package/dist/display-status.js +5 -4
- package/dist/handoff-context.d.ts +222 -1
- package/dist/handoff-context.d.ts.map +1 -1
- package/dist/handoff-context.js +461 -11
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/naming.d.ts +3 -0
- package/dist/naming.d.ts.map +1 -1
- package/dist/naming.js +7 -0
- package/dist/package-version.d.ts +2 -0
- package/dist/package-version.d.ts.map +1 -1
- package/dist/package-version.js +1 -1
- package/dist/payload-fallback.d.ts +38 -0
- package/dist/payload-fallback.d.ts.map +1 -0
- package/dist/payload-fallback.js +79 -0
- package/dist/plan-store.d.ts +192 -36
- package/dist/plan-store.d.ts.map +1 -1
- package/dist/plan-store.js +1048 -126
- package/dist/planner-rules.d.ts.map +1 -1
- package/dist/planner-rules.js +9 -3
- package/dist/planner-skill.d.ts +24 -0
- package/dist/planner-skill.d.ts.map +1 -0
- package/dist/planner-skill.js +113 -0
- package/dist/project-context-migration.d.ts +47 -0
- package/dist/project-context-migration.d.ts.map +1 -0
- package/dist/project-context-migration.js +168 -0
- package/dist/read-tracking.d.ts +47 -13
- package/dist/read-tracking.d.ts.map +1 -1
- package/dist/read-tracking.js +88 -33
- package/dist/recap.d.ts.map +1 -1
- package/dist/recap.js +34 -9
- package/dist/refs.d.ts +6 -1
- package/dist/refs.d.ts.map +1 -1
- package/dist/refs.js +25 -0
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +24 -2
- package/dist/requirement-macro-tasks.d.ts +18 -0
- package/dist/requirement-macro-tasks.d.ts.map +1 -0
- package/dist/requirement-macro-tasks.js +55 -0
- package/dist/runtime-diagnostics.d.ts +34 -0
- package/dist/runtime-diagnostics.d.ts.map +1 -0
- package/dist/runtime-diagnostics.js +39 -0
- package/dist/schema.d.ts +1575 -290
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +89 -4
- package/dist/task-context.d.ts +41 -2
- package/dist/task-context.d.ts.map +1 -1
- package/dist/task-context.js +102 -4
- package/dist/task-selection.d.ts +44 -1
- package/dist/task-selection.d.ts.map +1 -1
- package/dist/task-selection.js +158 -7
- package/dist/task-start-outcome.d.ts +1 -1
- package/dist/task-start-outcome.d.ts.map +1 -1
- package/dist/task-start-outcome.js +1 -0
- package/dist/write-coordination.d.ts +27 -0
- package/dist/write-coordination.d.ts.map +1 -0
- package/dist/write-coordination.js +223 -0
- package/package.json +3 -1
- package/planner-skill.md +226 -0
- package/skills/grill-me/SKILL.md +10 -0
package/dist/task-selection.js
CHANGED
|
@@ -1,8 +1,108 @@
|
|
|
1
1
|
const unavailable = new Set(["blocked", "waiting", "deferred", "canceled", "rejected"]);
|
|
2
2
|
const hardUnavailable = new Set(["blocked", "deferred", "canceled", "rejected"]);
|
|
3
3
|
const terminal = new Set(["done", "canceled", "rejected"]);
|
|
4
|
-
const priority = (entity) => entity.priority
|
|
4
|
+
const priority = (entity) => entity.priority != null ? entity.priority : Number.MAX_SAFE_INTEGER;
|
|
5
5
|
const compare = (a, b) => priority(a) - priority(b) || a.number - b.number;
|
|
6
|
+
const summarizeFeature = (feature) => ({ id: feature.id, number: feature.number, priority: feature.priority, title: feature.name, status: feature.status });
|
|
7
|
+
const summarizePhase = (phase) => ({ id: phase.id, number: phase.number, priority: phase.priority, title: phase.title, status: phase.status });
|
|
8
|
+
const summarizeTask = (task) => ({ id: task.id, number: task.number, priority: task.priority, title: task.title, status: task.status });
|
|
9
|
+
const RECOMMENDATION_CLAIM_LIMIT = 8;
|
|
10
|
+
const selectionClaimKind = (selectionKind) => {
|
|
11
|
+
if (selectionKind === "priority")
|
|
12
|
+
return "recommendation";
|
|
13
|
+
if (selectionKind === "conflict")
|
|
14
|
+
return "conflict";
|
|
15
|
+
if (selectionKind === "none")
|
|
16
|
+
return "insufficient";
|
|
17
|
+
return "advisory";
|
|
18
|
+
};
|
|
19
|
+
const buildRecommendationClaims = (selection, evidence = {}, dependencyReadyCandidates = []) => {
|
|
20
|
+
const claims = [];
|
|
21
|
+
if (selection.candidate) {
|
|
22
|
+
claims.push({
|
|
23
|
+
kind: selectionClaimKind(selection.kind),
|
|
24
|
+
source: selection.kind === "resume" ? "resume-deviation" : selection.kind === "active" ? "active-task" : "priority",
|
|
25
|
+
ref: selection.candidate.task.id,
|
|
26
|
+
title: selection.candidate.task.title,
|
|
27
|
+
reason: selection.reason,
|
|
28
|
+
taskId: selection.candidate.task.id,
|
|
29
|
+
phaseId: selection.candidate.phase.id,
|
|
30
|
+
...(selection.candidate.feature?.id ? { featureId: selection.candidate.feature.id } : {}),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
for (const candidate of dependencyReadyCandidates) {
|
|
34
|
+
claims.push({
|
|
35
|
+
kind: "advisory",
|
|
36
|
+
source: "dependency-ready",
|
|
37
|
+
ref: candidate.task.id,
|
|
38
|
+
title: candidate.task.title,
|
|
39
|
+
reason: "All persisted task dependencies are done; this task is ready for explicit priority review.",
|
|
40
|
+
taskId: candidate.task.id,
|
|
41
|
+
phaseId: candidate.phase.id,
|
|
42
|
+
...(candidate.feature?.id ? { featureId: candidate.feature.id } : {}),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (selection.kind === "conflict") {
|
|
46
|
+
for (const activeCandidate of selection.activeCandidates ?? []) {
|
|
47
|
+
claims.push({
|
|
48
|
+
kind: "conflict",
|
|
49
|
+
source: "active-task",
|
|
50
|
+
ref: activeCandidate.task.id,
|
|
51
|
+
title: activeCandidate.task.title,
|
|
52
|
+
reason: "Multiple in-progress tasks are persisted; resolve the active-work conflict before automatic selection.",
|
|
53
|
+
taskId: activeCandidate.task.id,
|
|
54
|
+
phaseId: activeCandidate.phase.id,
|
|
55
|
+
...(activeCandidate.feature?.id ? { featureId: activeCandidate.feature.id } : {}),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (selection.deviation) {
|
|
60
|
+
const deviationTaskId = selection.candidate?.task.id ?? selection.deviation.resumeTaskId;
|
|
61
|
+
claims.push({
|
|
62
|
+
kind: "advisory",
|
|
63
|
+
source: "resume-deviation",
|
|
64
|
+
ref: deviationTaskId,
|
|
65
|
+
title: selection.candidate?.task.title ?? "Resume-required deviation",
|
|
66
|
+
reason: selection.deviation.state === "resume-required"
|
|
67
|
+
? "Persisted deviation requires resuming the preserved task before new priority work can continue."
|
|
68
|
+
: "Persisted approved deviation competes with priority selection.",
|
|
69
|
+
taskId: deviationTaskId,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
for (const handoff of evidence.handoffs ?? []) {
|
|
73
|
+
if (!handoff.resumeReady) {
|
|
74
|
+
claims.push({
|
|
75
|
+
kind: "insufficient",
|
|
76
|
+
source: "handoff",
|
|
77
|
+
ref: handoff.compositeRef,
|
|
78
|
+
title: handoff.firstLine || handoff.compositeRef,
|
|
79
|
+
reason: "Persisted handoff read-back is incomplete; it is not an actionable resume claim.",
|
|
80
|
+
});
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
claims.push({
|
|
84
|
+
kind: "advisory",
|
|
85
|
+
source: "handoff",
|
|
86
|
+
ref: handoff.compositeRef,
|
|
87
|
+
title: handoff.firstLine || handoff.compositeRef,
|
|
88
|
+
reason: "Persisted phase handoff is resume-ready and competes with fresh priority work.",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// Archived Markdown is historical context, not authoritative next-work
|
|
92
|
+
// evidence. A future explicit structured resume target may opt it in; never
|
|
93
|
+
// recover an action by parsing archived prose.
|
|
94
|
+
void evidence.archivedHandoffs;
|
|
95
|
+
if (claims.length === 0) {
|
|
96
|
+
claims.push({
|
|
97
|
+
kind: "insufficient",
|
|
98
|
+
source: "priority",
|
|
99
|
+
ref: "",
|
|
100
|
+
title: "No competing claim evidence",
|
|
101
|
+
reason: selection.reason,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return claims.slice(0, RECOMMENDATION_CLAIM_LIMIT);
|
|
105
|
+
};
|
|
6
106
|
/**
|
|
7
107
|
* Validate an explicitly requested task start. Priority and existing active
|
|
8
108
|
* work remain advisory for explicit user choices; availability and dependency
|
|
@@ -41,14 +141,21 @@ export function checkExplicitTaskStart(features, phases, taskId, deviations = []
|
|
|
41
141
|
* may use its recommendation as a default while still allowing an explicitly
|
|
42
142
|
* approved temporary deviation.
|
|
43
143
|
*/
|
|
44
|
-
export function recommendNextTask(features, phases, deviations = [], currentPhaseId = "") {
|
|
144
|
+
export function recommendNextTask(features, phases, deviations = [], currentPhaseId = "", sessionId = "") {
|
|
45
145
|
const featureById = new Map(features.map((feature) => [feature.id, feature]));
|
|
46
146
|
const candidates = phases.flatMap((phase) => phase.tasks.map((task) => ({ feature: phase.featureId ? featureById.get(phase.featureId) : undefined, phase, task })));
|
|
47
147
|
const byTaskId = new Map(candidates.map((candidate) => [candidate.task.id, candidate]));
|
|
48
148
|
const active = candidates.filter(({ task }) => task.status === "in-progress");
|
|
49
|
-
if (
|
|
149
|
+
if (sessionId) {
|
|
150
|
+
const ownedActive = active.filter(({ task }) => task.activeOwnerSession === sessionId);
|
|
151
|
+
if (ownedActive.length > 1)
|
|
152
|
+
return { kind: "conflict", activeCandidates: ownedActive, reason: "This session owns more than one active task; resolve the active-work conflict before autonomous selection." };
|
|
153
|
+
if (ownedActive.length === 1)
|
|
154
|
+
return { kind: "active", candidate: ownedActive[0], reason: "Resume the single active task owned by this session." };
|
|
155
|
+
}
|
|
156
|
+
else if (active.length > 1)
|
|
50
157
|
return { kind: "conflict", activeCandidates: active, reason: "More than one task is in progress; resolve the active-work conflict before autonomous selection." };
|
|
51
|
-
if (active.length === 1)
|
|
158
|
+
else if (active.length === 1)
|
|
52
159
|
return { kind: "active", candidate: active[0], reason: "Resume the single active task." };
|
|
53
160
|
const newestFirst = (left, right) => right.createdAt.localeCompare(left.createdAt);
|
|
54
161
|
const resumable = (candidate) => candidate
|
|
@@ -105,11 +212,55 @@ export function recommendNextTask(features, phases, deviations = [], currentPhas
|
|
|
105
212
|
reason: "Continue the current phase before selecting new work elsewhere.",
|
|
106
213
|
};
|
|
107
214
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
215
|
+
const bestTaskInPhase = (phaseId) => ready
|
|
216
|
+
.filter((candidate) => candidate.phase.id === phaseId)
|
|
217
|
+
.sort((a, b) => compare(a.task, b.task))[0];
|
|
218
|
+
const bestPhaseInFeature = (featureId) => {
|
|
219
|
+
const phaseIds = new Set(ready.filter((candidate) => candidate.feature?.id === featureId).map((candidate) => candidate.phase.id));
|
|
220
|
+
return phases
|
|
221
|
+
.filter((phase) => phase.featureId === featureId && phaseIds.has(phase.id))
|
|
222
|
+
.sort((a, b) => compare(a, b))[0];
|
|
223
|
+
};
|
|
224
|
+
const bestFeature = features
|
|
225
|
+
.filter((feature) => ready.some((candidate) => candidate.feature?.id === feature.id))
|
|
226
|
+
.sort((a, b) => compare(a, b))[0];
|
|
227
|
+
if (!bestFeature)
|
|
228
|
+
return { kind: "none", reason: "No ready task is available." };
|
|
229
|
+
const bestPhase = bestPhaseInFeature(bestFeature.id);
|
|
230
|
+
if (!bestPhase)
|
|
231
|
+
return { kind: "none", reason: "No ready task is available." };
|
|
232
|
+
const candidate = bestTaskInPhase(bestPhase.id);
|
|
233
|
+
return candidate
|
|
234
|
+
? { kind: "priority", candidate, reason: "Select the lowest-priority ready feature, then phase, then task." }
|
|
111
235
|
: { kind: "none", reason: "No ready task is available." };
|
|
112
236
|
}
|
|
237
|
+
export function recommendNextWork(features, phases, deviations = [], currentPhaseId = "", sessionId = "", evidence = {}) {
|
|
238
|
+
const selection = recommendNextTask(features, phases, deviations, currentPhaseId, sessionId);
|
|
239
|
+
const featureById = new Map(features.map((feature) => [feature.id, feature]));
|
|
240
|
+
const taskById = new Map(phases.flatMap((phase) => phase.tasks.map((task) => [task.id, task])));
|
|
241
|
+
const dependencyReadyCandidates = phases.flatMap((phase) => phase.tasks.map((task) => ({
|
|
242
|
+
feature: phase.featureId ? featureById.get(phase.featureId) : undefined,
|
|
243
|
+
phase,
|
|
244
|
+
task,
|
|
245
|
+
})))
|
|
246
|
+
.filter(({ feature, phase, task }) => task.status === "planned"
|
|
247
|
+
&& task.dependsOn.length > 0
|
|
248
|
+
&& !hardUnavailable.has(phase.status)
|
|
249
|
+
&& !(feature && hardUnavailable.has(feature.status))
|
|
250
|
+
&& task.dependsOn.every((id) => taskById.get(id)?.status === "done"))
|
|
251
|
+
.sort((left, right) => compare(left.feature ?? { priority: 0, number: 0 }, right.feature ?? { priority: 0, number: 0 }) || compare(left.phase, right.phase) || compare(left.task, right.task));
|
|
252
|
+
const activeTask = selection.kind === "active" && selection.candidate
|
|
253
|
+
? summarizeTask(selection.candidate.task)
|
|
254
|
+
: null;
|
|
255
|
+
return {
|
|
256
|
+
selection,
|
|
257
|
+
activeTask,
|
|
258
|
+
nextFeature: selection.candidate?.feature ? summarizeFeature(selection.candidate.feature) : null,
|
|
259
|
+
nextPhase: selection.candidate ? summarizePhase(selection.candidate.phase) : null,
|
|
260
|
+
nextTask: selection.candidate ? summarizeTask(selection.candidate.task) : null,
|
|
261
|
+
claims: buildRecommendationClaims(selection, evidence, dependencyReadyCandidates),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
113
264
|
/**
|
|
114
265
|
* Build an explicit resume-required proposal. `ref` is the already-formatted
|
|
115
266
|
* composite reference (F00x/P00x/T00x) supplied by the caller; `snapshot` is
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const TASK_START_ERROR_CODES: readonly ["PLAN_NOT_FOUND", "TASK_NOT_FOUND", "TASK_DONE", "CONTEXT_READ_REQUIRED", "REQUIREMENTS_READ_REQUIRED", "START_NOT_ALLOWED", "ACTIVE_TASK_CONFLICT", "PERSISTENCE_VERIFICATION_FAILED"];
|
|
1
|
+
export declare const TASK_START_ERROR_CODES: readonly ["PLAN_NOT_FOUND", "TASK_NOT_FOUND", "TASK_DONE", "PROJECT_GUIDELINES_READ_REQUIRED", "CONTEXT_READ_REQUIRED", "REQUIREMENTS_READ_REQUIRED", "START_NOT_ALLOWED", "ACTIVE_TASK_CONFLICT", "PERSISTENCE_VERIFICATION_FAILED"];
|
|
2
2
|
export type TaskStartErrorCode = (typeof TASK_START_ERROR_CODES)[number];
|
|
3
3
|
export interface TaskStartDeniedOutcome {
|
|
4
4
|
started: false;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"task-start-outcome.d.ts","sourceRoot":"","sources":["../src/task-start-outcome.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,sBAAsB,
|
|
1
|
+
{"version":3,"file":"task-start-outcome.d.ts","sourceRoot":"","sources":["../src/task-start-outcome.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,sBAAsB,uOAUzB,CAAC;AAEX,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEzE,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,KAAK,CAAC;IACf,SAAS,EAAE,kBAAkB,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,yBAAyB;IACxC,OAAO,EAAE,IAAI,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,aAAa,CAAC;IACtB,cAAc,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,MAAM,gBAAgB,GAAG,sBAAsB,GAAG,yBAAyB,CAAC;AAElF,wBAAgB,eAAe,CAC7B,SAAS,EAAE,kBAAkB,EAC7B,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EAAE,EACrB,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,EAAE,CAAA;CAAO,GAC3D,sBAAsB,CASxB;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,cAAc,UAAQ,GAAG,yBAAyB,CAEpG"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
interface WriterOwner {
|
|
2
|
+
token: string;
|
|
3
|
+
pid: number;
|
|
4
|
+
hostname: string;
|
|
5
|
+
cwd: string;
|
|
6
|
+
acquiredAt: string;
|
|
7
|
+
}
|
|
8
|
+
export interface PlanWriterBusyDetails {
|
|
9
|
+
errorCode: "PLAN_WRITER_BUSY";
|
|
10
|
+
planRoot: string;
|
|
11
|
+
lockPath: string;
|
|
12
|
+
waitedMs: number;
|
|
13
|
+
owner?: WriterOwner | undefined;
|
|
14
|
+
}
|
|
15
|
+
export declare class PlanWriterBusyError extends Error {
|
|
16
|
+
readonly details: PlanWriterBusyDetails;
|
|
17
|
+
readonly code = "PLAN_WRITER_BUSY";
|
|
18
|
+
constructor(details: PlanWriterBusyDetails);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Serialize one logical planner mutation across processes for a planner root.
|
|
22
|
+
* Nested writes in the same async transaction are re-entrant. Reads never take
|
|
23
|
+
* this lock, so secondary processes remain available for inspection.
|
|
24
|
+
*/
|
|
25
|
+
export declare function withPlanRootWriteLock<T>(root: string, fn: () => Promise<T>): Promise<T>;
|
|
26
|
+
export {};
|
|
27
|
+
//# sourceMappingURL=write-coordination.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"write-coordination.d.ts","sourceRoot":"","sources":["../src/write-coordination.ts"],"names":[],"mappings":"AAUA,UAAU,WAAW;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,kBAAkB,CAAC;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;CACjC;AAED,qBAAa,mBAAoB,SAAQ,KAAK;aAGhB,OAAO,EAAE,qBAAqB;IAF1D,QAAQ,CAAC,IAAI,sBAAsB;gBAEP,OAAO,EAAE,qBAAqB;CAO3D;AAwLD;;;;GAIG;AACH,wBAAsB,qBAAqB,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAa7F"}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
|
+
const DEFAULT_STALE_MS = 30_000;
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
8
|
+
const DEFAULT_RETRY_MS = 20;
|
|
9
|
+
export class PlanWriterBusyError extends Error {
|
|
10
|
+
details;
|
|
11
|
+
code = "PLAN_WRITER_BUSY";
|
|
12
|
+
constructor(details) {
|
|
13
|
+
const owner = details.owner
|
|
14
|
+
? ` Active writer: pid ${details.owner.pid} on ${details.owner.hostname}, acquired ${details.owner.acquiredAt}.`
|
|
15
|
+
: "";
|
|
16
|
+
super(`PLAN_WRITER_BUSY: another process is mutating ${details.planRoot}; waited ${details.waitedMs}ms.${owner} Read-only operations remain available; retry the write after the active mutation finishes.`);
|
|
17
|
+
this.details = details;
|
|
18
|
+
this.name = "PlanWriterBusyError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const heldRoots = new AsyncLocalStorage();
|
|
22
|
+
function positiveEnvMs(name, fallback) {
|
|
23
|
+
const raw = Number.parseInt(process.env[name] ?? "", 10);
|
|
24
|
+
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
|
25
|
+
}
|
|
26
|
+
async function readOwner(lockPath) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(await readFile(join(lockPath, "owner.json"), "utf8"));
|
|
29
|
+
if (typeof parsed.token !== "string"
|
|
30
|
+
|| typeof parsed.pid !== "number"
|
|
31
|
+
|| typeof parsed.hostname !== "string"
|
|
32
|
+
|| typeof parsed.cwd !== "string"
|
|
33
|
+
|| typeof parsed.acquiredAt !== "string")
|
|
34
|
+
return undefined;
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function ownerIsActive(owner, heartbeatStale) {
|
|
42
|
+
if (owner.hostname !== hostname())
|
|
43
|
+
return !heartbeatStale;
|
|
44
|
+
try {
|
|
45
|
+
process.kill(owner.pid, 0);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
return error.code === "EPERM";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function pathExists(path) {
|
|
53
|
+
try {
|
|
54
|
+
await stat(path);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function acquirePlanRootLock(planRoot) {
|
|
62
|
+
const locksRoot = join(planRoot, ".local", "locks");
|
|
63
|
+
const lockPath = join(locksRoot, "writer.lock");
|
|
64
|
+
const recoveryPath = join(locksRoot, "writer-recovery.lock");
|
|
65
|
+
const startedAt = Date.now();
|
|
66
|
+
const timeoutMs = positiveEnvMs("AGENT_PLAN_WRITE_LOCK_TIMEOUT_MS", DEFAULT_TIMEOUT_MS);
|
|
67
|
+
const staleMs = positiveEnvMs("AGENT_PLAN_WRITE_LOCK_STALE_MS", DEFAULT_STALE_MS);
|
|
68
|
+
const retryMs = positiveEnvMs("AGENT_PLAN_WRITE_LOCK_RETRY_MS", DEFAULT_RETRY_MS);
|
|
69
|
+
const owner = {
|
|
70
|
+
token: randomUUID(),
|
|
71
|
+
pid: process.pid,
|
|
72
|
+
hostname: hostname(),
|
|
73
|
+
cwd: process.cwd(),
|
|
74
|
+
acquiredAt: new Date().toISOString(),
|
|
75
|
+
};
|
|
76
|
+
for (;;) {
|
|
77
|
+
try {
|
|
78
|
+
await mkdir(locksRoot, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error.code !== "ENOENT")
|
|
82
|
+
throw error;
|
|
83
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, retryMs));
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (await pathExists(recoveryPath)) {
|
|
87
|
+
try {
|
|
88
|
+
const recovery = await stat(recoveryPath);
|
|
89
|
+
if (Date.now() - recovery.mtimeMs > staleMs) {
|
|
90
|
+
await rm(recoveryPath, { recursive: true, force: true });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const waitedMs = Date.now() - startedAt;
|
|
98
|
+
if (waitedMs >= timeoutMs) {
|
|
99
|
+
const currentOwner = await readOwner(lockPath);
|
|
100
|
+
throw new PlanWriterBusyError({
|
|
101
|
+
errorCode: "PLAN_WRITER_BUSY",
|
|
102
|
+
planRoot,
|
|
103
|
+
lockPath,
|
|
104
|
+
waitedMs,
|
|
105
|
+
...(currentOwner ? { owner: currentOwner } : {}),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, retryMs));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
await mkdir(lockPath);
|
|
113
|
+
try {
|
|
114
|
+
await writeFile(join(lockPath, "owner.json"), JSON.stringify(owner, null, 2), "utf8");
|
|
115
|
+
// A stale-owner recovery may have started after our initial check. Its
|
|
116
|
+
// sentinel wins: withdraw this new lock and retry after recovery ends.
|
|
117
|
+
if (await pathExists(recoveryPath)) {
|
|
118
|
+
const persistedOwner = await readOwner(lockPath);
|
|
119
|
+
if (persistedOwner?.token === owner.token) {
|
|
120
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => { });
|
|
121
|
+
}
|
|
122
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, retryMs));
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
const persistedOwner = await readOwner(lockPath);
|
|
128
|
+
if (!persistedOwner || persistedOwner.token === owner.token) {
|
|
129
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => { });
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
const heartbeat = setInterval(() => {
|
|
134
|
+
const now = new Date();
|
|
135
|
+
void utimes(lockPath, now, now).catch(() => { });
|
|
136
|
+
}, Math.max(250, Math.floor(staleMs / 3)));
|
|
137
|
+
heartbeat.unref();
|
|
138
|
+
return async () => {
|
|
139
|
+
clearInterval(heartbeat);
|
|
140
|
+
const persistedOwner = await readOwner(lockPath);
|
|
141
|
+
if (persistedOwner?.token === owner.token) {
|
|
142
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => { });
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
const fsError = error;
|
|
148
|
+
if (fsError.code === "ENOENT") {
|
|
149
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, retryMs));
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (fsError.code !== "EEXIST")
|
|
153
|
+
throw error;
|
|
154
|
+
const currentOwner = await readOwner(lockPath);
|
|
155
|
+
let stale = false;
|
|
156
|
+
try {
|
|
157
|
+
const info = await stat(lockPath);
|
|
158
|
+
stale = Date.now() - info.mtimeMs > staleMs;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (currentOwner ? !ownerIsActive(currentOwner, stale) : stale) {
|
|
164
|
+
try {
|
|
165
|
+
await mkdir(recoveryPath);
|
|
166
|
+
try {
|
|
167
|
+
const confirmedOwner = await readOwner(lockPath);
|
|
168
|
+
let confirmedStale = false;
|
|
169
|
+
try {
|
|
170
|
+
const info = await stat(lockPath);
|
|
171
|
+
confirmedStale = Date.now() - info.mtimeMs > staleMs;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (confirmedOwner ? !ownerIsActive(confirmedOwner, confirmedStale) : confirmedStale) {
|
|
177
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => { });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
await rm(recoveryPath, { recursive: true, force: true }).catch(() => { });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch (recoveryError) {
|
|
185
|
+
if (recoveryError.code !== "EEXIST")
|
|
186
|
+
throw recoveryError;
|
|
187
|
+
}
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const waitedMs = Date.now() - startedAt;
|
|
191
|
+
if (waitedMs >= timeoutMs) {
|
|
192
|
+
throw new PlanWriterBusyError({
|
|
193
|
+
errorCode: "PLAN_WRITER_BUSY",
|
|
194
|
+
planRoot,
|
|
195
|
+
lockPath,
|
|
196
|
+
waitedMs,
|
|
197
|
+
...(currentOwner ? { owner: currentOwner } : {}),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, retryMs));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Serialize one logical planner mutation across processes for a planner root.
|
|
206
|
+
* Nested writes in the same async transaction are re-entrant. Reads never take
|
|
207
|
+
* this lock, so secondary processes remain available for inspection.
|
|
208
|
+
*/
|
|
209
|
+
export async function withPlanRootWriteLock(root, fn) {
|
|
210
|
+
const planRoot = resolve(root);
|
|
211
|
+
const active = heldRoots.getStore();
|
|
212
|
+
if (active?.has(planRoot))
|
|
213
|
+
return fn();
|
|
214
|
+
const release = await acquirePlanRootLock(planRoot);
|
|
215
|
+
const next = new Set(active ?? []);
|
|
216
|
+
next.add(planRoot);
|
|
217
|
+
try {
|
|
218
|
+
return await heldRoots.run(next, fn);
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
await release();
|
|
222
|
+
}
|
|
223
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-plan/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.27",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Harness-agnostic core for Agent Plan: schemas, persistence, ordering, status rollups, and markdown rendering.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"dist/**/*.js",
|
|
17
17
|
"dist/**/*.d.ts",
|
|
18
18
|
"dist/**/*.d.ts.map",
|
|
19
|
+
"planner-skill.md",
|
|
20
|
+
"skills/**/*.md",
|
|
19
21
|
"README.md",
|
|
20
22
|
"LICENSE"
|
|
21
23
|
],
|