@dreki-gg/pi-plan-mode 0.24.0 → 0.24.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.
- package/CHANGELOG.md +9 -0
- package/extensions/plan-mode/__tests__/concurrent-writes.test.ts +85 -0
- package/extensions/plan-mode/__tests__/reconcile.test.ts +37 -0
- package/extensions/plan-mode/initiative.ts +16 -12
- package/extensions/plan-mode/reconcile.ts +31 -2
- package/extensions/plan-mode/storage/file-lock.ts +49 -0
- package/extensions/plan-mode/storage/initiatives-manifest.ts +62 -20
- package/extensions/plan-mode/storage/plans-manifest.ts +72 -36
- package/extensions/plan-mode/storage/task-storage.ts +20 -14
- package/extensions/plan-mode/tools/reconcile-plans.ts +13 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# @dreki-gg/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 0.24.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Fix lost-update race on the plan/initiative registries and stop reconcile from regressing finished plans.
|
|
8
|
+
|
|
9
|
+
- **Serialized registry writes:** concurrent tool calls in one block (e.g. several `submit_initiative` / `submit_plan` / `revise_plan` at once) previously ran an unguarded read-modify-write against `plans.jsonl` / `initiatives.jsonl`, so only the last write survived. A process-wide per-file lock (`withFileLock`) now serializes the read→modify→write of both registries and per-plan `tasks.jsonl`, eliminating the lost updates. Atomic writes already guarded against torn files; this closes the in-process concurrency gap.
|
|
10
|
+
- **No wrong-direction reconcile:** `reconcile_plans --apply` now only records completion (`in-progress → done`) and never auto-downgrades a `done` plan back to `in-progress`. A done plan with incomplete tasks (work merged but tasks never marked done) is surfaced as `needs-tasks-done` for a human to resolve by marking the tasks done, instead of being silently regressed.
|
|
11
|
+
|
|
3
12
|
## 0.24.0
|
|
4
13
|
|
|
5
14
|
### Minor Changes
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: concurrent read-modify-write on the registries must not lose
|
|
3
|
+
* updates. Each `Effect.runPromise` mimics a separate tool execute running in
|
|
4
|
+
* the same block (e.g. three `submit_initiative` calls). Before the file-lock
|
|
5
|
+
* fix, their reads all saw the same starting file and the last write clobbered
|
|
6
|
+
* the rest — only one entry survived.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
10
|
+
import { Effect } from 'effect';
|
|
11
|
+
import { chdir } from 'node:process';
|
|
12
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
|
|
16
|
+
import { readPlansManifest, upsertPlanEntry } from '../storage/plans-manifest.js';
|
|
17
|
+
import {
|
|
18
|
+
readInitiativesManifest,
|
|
19
|
+
upsertInitiativeEntry,
|
|
20
|
+
} from '../storage/initiatives-manifest.js';
|
|
21
|
+
import { writeTasksJsonl, readTasksJsonl, updateTask } from '../storage/task-storage.js';
|
|
22
|
+
import type { TaskMeta, TaskRecord } from '../types.js';
|
|
23
|
+
|
|
24
|
+
// Each call gets its OWN runPromise so the writes are genuinely concurrent —
|
|
25
|
+
// the same shape as independent tool executes sharing one Node process.
|
|
26
|
+
const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
|
|
27
|
+
Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
|
|
28
|
+
|
|
29
|
+
const originalCwd = process.cwd();
|
|
30
|
+
let dir: string;
|
|
31
|
+
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-concurrent-'));
|
|
34
|
+
chdir(dir);
|
|
35
|
+
});
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
chdir(originalCwd);
|
|
38
|
+
await rm(dir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('concurrent registry writes', () => {
|
|
42
|
+
test('5 concurrent plan upserts all persist (no lost updates)', async () => {
|
|
43
|
+
const names = ['a', 'b', 'c', 'd', 'e'];
|
|
44
|
+
await Promise.all(
|
|
45
|
+
names.map((n) => run(upsertPlanEntry(n, { status: 'in-progress', title: n.toUpperCase() }))),
|
|
46
|
+
);
|
|
47
|
+
const entries = await run(readPlansManifest());
|
|
48
|
+
expect(entries.map((e) => e.name).sort()).toEqual(names);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('3 concurrent initiative upserts all persist (no lost updates)', async () => {
|
|
52
|
+
const names = ['one', 'two', 'three'];
|
|
53
|
+
await Promise.all(
|
|
54
|
+
names.map((n) => run(upsertInitiativeEntry(n, { status: 'in-progress', title: n }))),
|
|
55
|
+
);
|
|
56
|
+
const entries = await run(readInitiativesManifest());
|
|
57
|
+
expect(entries.map((e) => e.name).sort()).toEqual([...names].sort());
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('concurrent task updates to the same plan all persist', async () => {
|
|
61
|
+
const planDir = join('.plans', 'p');
|
|
62
|
+
const meta: TaskMeta = {
|
|
63
|
+
_type: 'meta',
|
|
64
|
+
plan_name: 'p',
|
|
65
|
+
title: 'P',
|
|
66
|
+
created_at: 'now',
|
|
67
|
+
};
|
|
68
|
+
const tasks: TaskRecord[] = ['t-001', 't-002', 't-003'].map((id) => ({
|
|
69
|
+
_type: 'task',
|
|
70
|
+
id,
|
|
71
|
+
description: id,
|
|
72
|
+
status: 'pending',
|
|
73
|
+
created_at: 'now',
|
|
74
|
+
updated_at: 'now',
|
|
75
|
+
}));
|
|
76
|
+
await run(writeTasksJsonl(planDir, meta, tasks));
|
|
77
|
+
|
|
78
|
+
await Promise.all(
|
|
79
|
+
tasks.map((t) => run(updateTask(planDir, t.id, { status: 'done' }))),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const snapshot = await run(readTasksJsonl(planDir));
|
|
83
|
+
expect(snapshot?.tasks.every((t) => t.status === 'done')).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -117,6 +117,43 @@ describe('applyReconcile', () => {
|
|
|
117
117
|
const [entry] = await runPlanIO(readPlansManifest());
|
|
118
118
|
expect(entry.status).toBe('done');
|
|
119
119
|
});
|
|
120
|
+
|
|
121
|
+
test('classifies an upgrade (in-progress registry, done tasks) as direction:upgrade', async () => {
|
|
122
|
+
await runPlanIO(writeTasksJsonl('.plans/up', meta('up'), [task('t-001', 'done')]));
|
|
123
|
+
await runPlanIO(upsertPlanEntry('up', { status: 'in-progress', title: 'Up' }));
|
|
124
|
+
const rows = await runPlanIO(collectPlanDrift());
|
|
125
|
+
expect(rows.find((r) => r.name === 'up')!.direction).toBe('upgrade');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a done plan with incomplete tasks is flagged downgrade and NOT auto-repaired', async () => {
|
|
129
|
+
// Work merged but tasks never marked done: registry done, tasks in-progress.
|
|
130
|
+
await runPlanIO(
|
|
131
|
+
writeTasksJsonl('.plans/merged', meta('merged'), [task('t-001', 'pending')]),
|
|
132
|
+
);
|
|
133
|
+
await runPlanIO(
|
|
134
|
+
writePlansManifest([
|
|
135
|
+
{
|
|
136
|
+
_type: 'plan',
|
|
137
|
+
name: 'merged',
|
|
138
|
+
status: 'done',
|
|
139
|
+
title: 'Merged',
|
|
140
|
+
created_at: now,
|
|
141
|
+
completed_at: now,
|
|
142
|
+
},
|
|
143
|
+
]),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const rows = await runPlanIO(collectPlanDrift());
|
|
147
|
+
const merged = rows.find((r) => r.name === 'merged')!;
|
|
148
|
+
expect(merged.drift).toBe('status');
|
|
149
|
+
expect(merged.direction).toBe('downgrade');
|
|
150
|
+
|
|
151
|
+
// --apply must NOT regress it back to in-progress.
|
|
152
|
+
const repaired = await runPlanIO(applyReconcile(rows));
|
|
153
|
+
expect(repaired.map((r) => r.name)).not.toContain('merged');
|
|
154
|
+
const [entry] = await runPlanIO(readPlansManifest());
|
|
155
|
+
expect(entry.status).toBe('done');
|
|
156
|
+
});
|
|
120
157
|
});
|
|
121
158
|
|
|
122
159
|
describe('initiative drift', () => {
|
|
@@ -18,8 +18,8 @@ import { FileSystem } from './effects/filesystem.js';
|
|
|
18
18
|
import type { JsonlParseError, JsonlValidationError, PlanWriteError } from './errors.js';
|
|
19
19
|
import { readPlansManifest, type PlanManifestEntry } from './storage/plans-manifest.js';
|
|
20
20
|
import {
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
applyInitiativeUpsert,
|
|
22
|
+
mutateInitiativesManifest,
|
|
23
23
|
} from './storage/initiatives-manifest.js';
|
|
24
24
|
import type { PlanStatus } from './types.js';
|
|
25
25
|
|
|
@@ -139,16 +139,20 @@ type ReconcileError = JsonlParseError | JsonlValidationError | PlanWriteError;
|
|
|
139
139
|
export function reconcileInitiativeStatus(
|
|
140
140
|
name: string,
|
|
141
141
|
): Effect.Effect<void, ReconcileError, FileSystem> {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
142
|
+
// The whole read-decide-write runs under the initiatives lock so a concurrent
|
|
143
|
+
// writer cannot slip a status change between our read and our write.
|
|
144
|
+
return mutateInitiativesManifest((initiatives) =>
|
|
145
|
+
Effect.gen(function* () {
|
|
146
|
+
const existing = initiatives.find((entry) => entry.name === name);
|
|
147
|
+
if (!existing) return false;
|
|
148
|
+
if (existing.status === 'superseded' || existing.status === 'abandoned') return false;
|
|
149
|
+
const plans = yield* readPlansManifest();
|
|
150
|
+
const status: PlanStatus = isInitiativeFinalizable(name, plans) ? 'done' : 'in-progress';
|
|
151
|
+
if (existing.status === status) return false;
|
|
152
|
+
applyInitiativeUpsert(initiatives, name, { status, title: existing.title });
|
|
153
|
+
return true;
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
/**
|
|
@@ -55,6 +55,18 @@ export interface PlanDriftRow {
|
|
|
55
55
|
* - undefined : in sync
|
|
56
56
|
*/
|
|
57
57
|
drift?: 'status' | 'registry-only' | 'orphan';
|
|
58
|
+
/**
|
|
59
|
+
* For `status` drift, the direction the registry would move if projected from
|
|
60
|
+
* tasks:
|
|
61
|
+
* - 'upgrade' : registry `in-progress` → tasks `done` (safe; auto-repaired)
|
|
62
|
+
* - 'downgrade' : registry `done` → tasks `in-progress` (NOT auto-repaired)
|
|
63
|
+
*
|
|
64
|
+
* A downgrade almost always means "work merged but tasks were never marked
|
|
65
|
+
* done" — auto-projecting tasks→registry there would REGRESS a finished plan
|
|
66
|
+
* back to in-progress (the wrong direction). We surface it for a human to
|
|
67
|
+
* resolve by marking the tasks done instead.
|
|
68
|
+
*/
|
|
69
|
+
direction?: 'upgrade' | 'downgrade';
|
|
58
70
|
}
|
|
59
71
|
|
|
60
72
|
type CollectError = JsonlParseError | JsonlValidationError | MissingMetaRecord;
|
|
@@ -92,6 +104,12 @@ export function collectPlanDrift(): Effect.Effect<PlanDriftRow[], CollectError,
|
|
|
92
104
|
// Terminal statuses (superseded/abandoned) are intentional — never drift.
|
|
93
105
|
const isTerminalManual = entry.status === 'superseded' || entry.status === 'abandoned';
|
|
94
106
|
const drift = !isTerminalManual && entry.status !== derivedStatus ? 'status' : undefined;
|
|
107
|
+
const direction =
|
|
108
|
+
drift === 'status'
|
|
109
|
+
? derivedStatus === 'done'
|
|
110
|
+
? ('upgrade' as const)
|
|
111
|
+
: ('downgrade' as const)
|
|
112
|
+
: undefined;
|
|
95
113
|
rows.push({
|
|
96
114
|
name: entry.name,
|
|
97
115
|
registryStatus: entry.status,
|
|
@@ -101,6 +119,7 @@ export function collectPlanDrift(): Effect.Effect<PlanDriftRow[], CollectError,
|
|
|
101
119
|
total,
|
|
102
120
|
hasTasks: true,
|
|
103
121
|
drift,
|
|
122
|
+
direction,
|
|
104
123
|
});
|
|
105
124
|
}
|
|
106
125
|
|
|
@@ -186,8 +205,15 @@ export function applyInitiativeReconcile(
|
|
|
186
205
|
|
|
187
206
|
/**
|
|
188
207
|
* Repair `status`-class drift by projecting derived status into the registry.
|
|
189
|
-
*
|
|
190
|
-
*
|
|
208
|
+
*
|
|
209
|
+
* Safety: only `upgrade` drift (registry `in-progress` → tasks `done`) is
|
|
210
|
+
* auto-repaired. A `downgrade` (registry `done` → tasks `in-progress`) is
|
|
211
|
+
* reported but NEVER auto-applied — it almost always means work merged without
|
|
212
|
+
* marking tasks done, and projecting tasks→registry there would regress a
|
|
213
|
+
* finished plan. The human resolves it by marking the tasks done instead.
|
|
214
|
+
*
|
|
215
|
+
* Orphans and registry-only rows are likewise reported but not auto-fixed.
|
|
216
|
+
* Returns the rows that were repaired.
|
|
191
217
|
*/
|
|
192
218
|
export function applyReconcile(
|
|
193
219
|
rows: PlanDriftRow[],
|
|
@@ -196,6 +222,9 @@ export function applyReconcile(
|
|
|
196
222
|
const repaired: PlanDriftRow[] = [];
|
|
197
223
|
for (const row of rows) {
|
|
198
224
|
if (row.drift !== 'status' || !row.derivedStatus) continue;
|
|
225
|
+
// Guard against the wrong-direction projection: never auto-regress a
|
|
226
|
+
// `done` plan back to `in-progress`.
|
|
227
|
+
if (row.direction === 'downgrade') continue;
|
|
199
228
|
yield* reconcilePlanStatus(row.name, row.derivedStatus === 'done', row.title);
|
|
200
229
|
// Repairing a plan's status can flip its parent initiative's projection.
|
|
201
230
|
yield* reconcileInitiativeForPlan(row.name);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide keyed mutex for serializing read-modify-write on shared files.
|
|
3
|
+
*
|
|
4
|
+
* Pi runs every tool call in a single Node process. When several tool calls run
|
|
5
|
+
* in one block (e.g. three `submit_initiative` calls, or concurrent
|
|
6
|
+
* `submit_plan` / `revise_plan`), each does an independent
|
|
7
|
+
* read → modify → write against the same registry file. Without serialization
|
|
8
|
+
* their reads all observe the same starting state and the last write clobbers
|
|
9
|
+
* the rest — a classic lost-update race.
|
|
10
|
+
*
|
|
11
|
+
* `withFileLock` wraps a read-modify-write critical section so only one runs at
|
|
12
|
+
* a time per `key` (the registry path). The semaphore is created eagerly with
|
|
13
|
+
* `unsafeMakeSemaphore` and cached per key, so its permit count lives in plain
|
|
14
|
+
* shared memory and serializes correctly even across independent
|
|
15
|
+
* `Effect.runPromise` invocations (separate tool executes).
|
|
16
|
+
*
|
|
17
|
+
* NOTE: this guards against in-process concurrency only. Atomic writes
|
|
18
|
+
* (`writeFileAtomic`) still protect against torn files from other processes,
|
|
19
|
+
* but cross-process registry coordination is out of scope.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { Effect } from 'effect';
|
|
23
|
+
|
|
24
|
+
const locks = new Map<string, Effect.Semaphore>();
|
|
25
|
+
|
|
26
|
+
function lockFor(key: string): Effect.Semaphore {
|
|
27
|
+
let lock = locks.get(key);
|
|
28
|
+
if (!lock) {
|
|
29
|
+
lock = Effect.unsafeMakeSemaphore(1);
|
|
30
|
+
locks.set(key, lock);
|
|
31
|
+
}
|
|
32
|
+
return lock;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run `effect` while holding the single permit for `key`. Concurrent callers
|
|
37
|
+
* with the same key queue and run one at a time; the permit is always released,
|
|
38
|
+
* even on failure or interruption.
|
|
39
|
+
*
|
|
40
|
+
* Do NOT nest `withFileLock` for the same key inside another — the permit is
|
|
41
|
+
* not reentrant and would deadlock. Express composite read-modify-write as one
|
|
42
|
+
* locked section instead.
|
|
43
|
+
*/
|
|
44
|
+
export function withFileLock<A, E, R>(
|
|
45
|
+
key: string,
|
|
46
|
+
effect: Effect.Effect<A, E, R>,
|
|
47
|
+
): Effect.Effect<A, E, R> {
|
|
48
|
+
return Effect.suspend(() => lockFor(key).withPermits(1)(effect));
|
|
49
|
+
}
|
|
@@ -18,6 +18,7 @@ import { FileSystem } from '../effects/filesystem.js';
|
|
|
18
18
|
import { JsonlParseError, JsonlValidationError, PlanWriteError } from '../errors.js';
|
|
19
19
|
import { decodeInitiativeManifestEntry } from '../schema.js';
|
|
20
20
|
import type { InitiativeStatus } from '../types.js';
|
|
21
|
+
import { withFileLock } from './file-lock.js';
|
|
21
22
|
|
|
22
23
|
const MANIFEST_DIR = '.plans';
|
|
23
24
|
const MANIFEST_PATH = '.plans/initiatives.jsonl';
|
|
@@ -86,27 +87,68 @@ export function writeInitiativesManifest(
|
|
|
86
87
|
});
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
export interface InitiativeUpsert {
|
|
91
|
+
status: InitiativeStatus;
|
|
92
|
+
title?: string;
|
|
93
|
+
reason?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Pure transform: upsert `name` into the in-memory `entries` array, preserving
|
|
98
|
+
* created_at from any existing entry. No IO — shared by the locked
|
|
99
|
+
* `upsertInitiativeEntry` and `reconcileInitiativeStatus` so both flow through
|
|
100
|
+
* one serialized read-modify-write and never nest locks.
|
|
101
|
+
*/
|
|
102
|
+
export function applyInitiativeUpsert(
|
|
103
|
+
entries: InitiativeManifestEntry[],
|
|
104
|
+
name: string,
|
|
105
|
+
updates: InitiativeUpsert,
|
|
106
|
+
): void {
|
|
107
|
+
const now = new Date().toISOString();
|
|
108
|
+
const index = entries.findIndex((entry) => entry.name === name);
|
|
109
|
+
const existing = index === -1 ? undefined : entries[index];
|
|
110
|
+
const entry: InitiativeManifestEntry = {
|
|
111
|
+
_type: 'initiative',
|
|
112
|
+
name,
|
|
113
|
+
status: updates.status,
|
|
114
|
+
title: updates.title ?? existing?.title ?? 'Untitled initiative',
|
|
115
|
+
created_at: existing?.created_at ?? now,
|
|
116
|
+
// Terminal statuses record a completion timestamp; reopening clears it.
|
|
117
|
+
completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
|
|
118
|
+
reason: updates.reason ?? existing?.reason,
|
|
119
|
+
};
|
|
120
|
+
if (index === -1) entries.push(entry);
|
|
121
|
+
else entries[index] = entry;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Serialized read-modify-write of the initiatives registry. Holds a
|
|
126
|
+
* process-wide lock on the manifest path across the whole read → transform →
|
|
127
|
+
* write so concurrent tool calls cannot clobber each other. `transform` may run
|
|
128
|
+
* IO (e.g. read the plans manifest to project status) and mutates the entries
|
|
129
|
+
* array in place, returning `true` when it changed something.
|
|
130
|
+
*/
|
|
131
|
+
export function mutateInitiativesManifest<E, R>(
|
|
132
|
+
transform: (entries: InitiativeManifestEntry[]) => Effect.Effect<boolean, E, R>,
|
|
133
|
+
): Effect.Effect<void, ReadError | PlanWriteError | E, FileSystem | R> {
|
|
134
|
+
return withFileLock(
|
|
135
|
+
MANIFEST_PATH,
|
|
136
|
+
Effect.gen(function* () {
|
|
137
|
+
const entries = yield* readInitiativesManifest();
|
|
138
|
+
const changed = yield* transform(entries);
|
|
139
|
+
if (changed) yield* writeInitiativesManifest(entries);
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
89
144
|
export function upsertInitiativeEntry(
|
|
90
145
|
name: string,
|
|
91
|
-
updates:
|
|
146
|
+
updates: InitiativeUpsert,
|
|
92
147
|
): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
|
|
93
|
-
return
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
_type: 'initiative',
|
|
100
|
-
name,
|
|
101
|
-
status: updates.status,
|
|
102
|
-
title: updates.title ?? existing?.title ?? 'Untitled initiative',
|
|
103
|
-
created_at: existing?.created_at ?? now,
|
|
104
|
-
// Terminal statuses record a completion timestamp; reopening clears it.
|
|
105
|
-
completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
|
|
106
|
-
reason: updates.reason ?? existing?.reason,
|
|
107
|
-
};
|
|
108
|
-
if (index === -1) entries.push(entry);
|
|
109
|
-
else entries[index] = entry;
|
|
110
|
-
yield* writeInitiativesManifest(entries);
|
|
111
|
-
});
|
|
148
|
+
return mutateInitiativesManifest((entries) =>
|
|
149
|
+
Effect.sync(() => {
|
|
150
|
+
applyInitiativeUpsert(entries, name, updates);
|
|
151
|
+
return true;
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
112
154
|
}
|
|
@@ -3,6 +3,7 @@ import { FileSystem } from '../effects/filesystem.js';
|
|
|
3
3
|
import { JsonlParseError, JsonlValidationError, PlanWriteError } from '../errors.js';
|
|
4
4
|
import { decodePlanManifestEntry } from '../schema.js';
|
|
5
5
|
import type { PlanStatus } from '../types.js';
|
|
6
|
+
import { withFileLock } from './file-lock.js';
|
|
6
7
|
|
|
7
8
|
const MANIFEST_DIR = '.plans';
|
|
8
9
|
const MANIFEST_PATH = '.plans/plans.jsonl';
|
|
@@ -71,39 +72,74 @@ export function writePlansManifest(
|
|
|
71
72
|
});
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
export interface PlanUpsert {
|
|
76
|
+
status: PlanStatus;
|
|
77
|
+
title?: string;
|
|
78
|
+
reason?: string;
|
|
79
|
+
/** Parent initiative name; preserved when omitted. */
|
|
80
|
+
initiative?: string;
|
|
81
|
+
/** Plan-level dependencies (plan names); preserved when omitted. */
|
|
82
|
+
depends_on?: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pure transform: upsert `name` into the in-memory `entries` array, preserving
|
|
87
|
+
* created_at / membership / deps from any existing entry. No IO — shared by the
|
|
88
|
+
* locked `upsertPlanEntry` and `reconcilePlanStatus` so both flow through one
|
|
89
|
+
* serialized read-modify-write and never nest locks.
|
|
90
|
+
*/
|
|
91
|
+
export function applyPlanUpsert(
|
|
92
|
+
entries: PlanManifestEntry[],
|
|
93
|
+
name: string,
|
|
94
|
+
updates: PlanUpsert,
|
|
95
|
+
): void {
|
|
96
|
+
const now = new Date().toISOString();
|
|
97
|
+
const index = entries.findIndex((entry) => entry.name === name);
|
|
98
|
+
const existing = index === -1 ? undefined : entries[index];
|
|
99
|
+
const entry: PlanManifestEntry = {
|
|
100
|
+
_type: 'plan',
|
|
101
|
+
name,
|
|
102
|
+
status: updates.status,
|
|
103
|
+
title: updates.title ?? existing?.title ?? 'Untitled plan',
|
|
104
|
+
created_at: existing?.created_at ?? now,
|
|
105
|
+
// Terminal statuses record a completion timestamp; reopening clears it.
|
|
106
|
+
completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
|
|
107
|
+
reason: updates.reason ?? existing?.reason,
|
|
108
|
+
// Membership + plan-level deps are preserved across status-only upserts.
|
|
109
|
+
initiative: updates.initiative ?? existing?.initiative,
|
|
110
|
+
depends_on: updates.depends_on ?? existing?.depends_on,
|
|
111
|
+
};
|
|
112
|
+
if (index === -1) entries.push(entry);
|
|
113
|
+
else entries[index] = entry;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Serialized read-modify-write of the plans registry. Holds a process-wide lock
|
|
118
|
+
* on the manifest path across the whole read → transform → write so concurrent
|
|
119
|
+
* tool calls cannot clobber each other (lost-update race). `transform` mutates
|
|
120
|
+
* the entries array in place and returns `true` when it changed something
|
|
121
|
+
* (return `false` to skip the rewrite).
|
|
122
|
+
*/
|
|
123
|
+
export function mutatePlansManifest(
|
|
124
|
+
transform: (entries: PlanManifestEntry[]) => boolean,
|
|
125
|
+
): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
|
|
126
|
+
return withFileLock(
|
|
127
|
+
MANIFEST_PATH,
|
|
128
|
+
Effect.gen(function* () {
|
|
129
|
+
const entries = yield* readPlansManifest();
|
|
130
|
+
const changed = transform(entries);
|
|
131
|
+
if (changed) yield* writePlansManifest(entries);
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
74
136
|
export function upsertPlanEntry(
|
|
75
137
|
name: string,
|
|
76
|
-
updates:
|
|
77
|
-
status: PlanStatus;
|
|
78
|
-
title?: string;
|
|
79
|
-
reason?: string;
|
|
80
|
-
/** Parent initiative name; preserved when omitted. */
|
|
81
|
-
initiative?: string;
|
|
82
|
-
/** Plan-level dependencies (plan names); preserved when omitted. */
|
|
83
|
-
depends_on?: string[];
|
|
84
|
-
},
|
|
138
|
+
updates: PlanUpsert,
|
|
85
139
|
): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
|
|
86
|
-
return
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const index = entries.findIndex((entry) => entry.name === name);
|
|
90
|
-
const existing = index === -1 ? undefined : entries[index];
|
|
91
|
-
const entry: PlanManifestEntry = {
|
|
92
|
-
_type: 'plan',
|
|
93
|
-
name,
|
|
94
|
-
status: updates.status,
|
|
95
|
-
title: updates.title ?? existing?.title ?? 'Untitled plan',
|
|
96
|
-
created_at: existing?.created_at ?? now,
|
|
97
|
-
// Terminal statuses record a completion timestamp; reopening clears it.
|
|
98
|
-
completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
|
|
99
|
-
reason: updates.reason ?? existing?.reason,
|
|
100
|
-
// Membership + plan-level deps are preserved across status-only upserts.
|
|
101
|
-
initiative: updates.initiative ?? existing?.initiative,
|
|
102
|
-
depends_on: updates.depends_on ?? existing?.depends_on,
|
|
103
|
-
};
|
|
104
|
-
if (index === -1) entries.push(entry);
|
|
105
|
-
else entries[index] = entry;
|
|
106
|
-
yield* writePlansManifest(entries);
|
|
140
|
+
return mutatePlansManifest((entries) => {
|
|
141
|
+
applyPlanUpsert(entries, name, updates);
|
|
142
|
+
return true;
|
|
107
143
|
});
|
|
108
144
|
}
|
|
109
145
|
|
|
@@ -123,16 +159,16 @@ export function reconcilePlanStatus(
|
|
|
123
159
|
finalizable: boolean,
|
|
124
160
|
title?: string,
|
|
125
161
|
): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
|
|
126
|
-
return
|
|
127
|
-
const entries = yield* readPlansManifest();
|
|
162
|
+
return mutatePlansManifest((entries) => {
|
|
128
163
|
const existing = entries.find((entry) => entry.name === name);
|
|
129
164
|
// Reconcile only reflects task state for KNOWN plans; never conjure an
|
|
130
165
|
// entry for an unregistered plan (orphans are surfaced, not auto-created).
|
|
131
|
-
if (!existing) return;
|
|
166
|
+
if (!existing) return false;
|
|
132
167
|
// Do not resurrect / clobber an explicitly closed plan.
|
|
133
|
-
if (existing.status === 'superseded' || existing.status === 'abandoned') return;
|
|
168
|
+
if (existing.status === 'superseded' || existing.status === 'abandoned') return false;
|
|
134
169
|
const status: PlanStatus = finalizable ? 'done' : 'in-progress';
|
|
135
|
-
if (existing.status === status) return; // no change
|
|
136
|
-
|
|
170
|
+
if (existing.status === status) return false; // no change
|
|
171
|
+
applyPlanUpsert(entries, name, { status, title });
|
|
172
|
+
return true;
|
|
137
173
|
});
|
|
138
174
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from '../errors.js';
|
|
12
12
|
import { decodeTasksLine } from '../schema.js';
|
|
13
13
|
import type { TaskMeta, TaskRecord } from '../types.js';
|
|
14
|
+
import { withFileLock } from './file-lock.js';
|
|
14
15
|
|
|
15
16
|
const TASKS_FILE = 'tasks.jsonl';
|
|
16
17
|
|
|
@@ -86,20 +87,25 @@ export function updateTask(
|
|
|
86
87
|
ReadError | PlanWriteError | TasksFileNotFound | TaskNotFound,
|
|
87
88
|
FileSystem
|
|
88
89
|
> {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
// Serialize the read-modify-write so concurrent task updates to the same plan
|
|
91
|
+
// (e.g. parallel update_task / update_tasks calls) cannot clobber each other.
|
|
92
|
+
return withFileLock(
|
|
93
|
+
join(planDir, TASKS_FILE),
|
|
94
|
+
Effect.gen(function* () {
|
|
95
|
+
const snapshot = yield* readTasksJsonl(planDir);
|
|
96
|
+
if (!snapshot) return yield* Effect.fail(new TasksFileNotFound({ planDir }));
|
|
92
97
|
|
|
93
|
-
|
|
94
|
-
|
|
98
|
+
const index = snapshot.tasks.findIndex((task) => task.id === taskId);
|
|
99
|
+
if (index === -1) return yield* Effect.fail(new TaskNotFound({ planDir, taskId }));
|
|
95
100
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
const updated: TaskRecord = {
|
|
102
|
+
...snapshot.tasks[index],
|
|
103
|
+
...updates,
|
|
104
|
+
updated_at: new Date().toISOString(),
|
|
105
|
+
};
|
|
106
|
+
snapshot.tasks[index] = updated;
|
|
107
|
+
yield* writeTasksJsonl(planDir, snapshot.meta, snapshot.tasks);
|
|
108
|
+
return updated;
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
105
111
|
}
|
|
@@ -29,6 +29,10 @@ function describeRow(row: PlanDriftRow): string {
|
|
|
29
29
|
return ` ⚠ ${row.name}${progress} — tasks.jsonl with no registry entry (orphan)`;
|
|
30
30
|
}
|
|
31
31
|
if (row.drift === 'status') {
|
|
32
|
+
if (row.direction === 'downgrade') {
|
|
33
|
+
// Wrong-direction projection: do NOT auto-repair. Mark tasks done instead.
|
|
34
|
+
return ` ⚠ ${row.name}${progress} — registry ${row.registryStatus}, tasks say ${row.derivedStatus} (likely merged; mark tasks done — not auto-repaired)`;
|
|
35
|
+
}
|
|
32
36
|
return ` ✗ ${row.name}${progress} — registry ${row.registryStatus}, tasks say ${row.derivedStatus}`;
|
|
33
37
|
}
|
|
34
38
|
return ` ✓ ${row.name}${progress} — ${row.registryStatus} (in sync)`;
|
|
@@ -44,6 +48,7 @@ export function registerReconcilePlansTool(pi: ExtensionAPI, runPlanIO: RunPlanI
|
|
|
44
48
|
promptGuidelines: [
|
|
45
49
|
'Use reconcile_plans to audit .plans/ when registry status looks stale (e.g. a fully-done plan still in-progress).',
|
|
46
50
|
'Run it read-only first; pass apply:true once you have reviewed the reported drift.',
|
|
51
|
+
'apply:true only records completion (in-progress→done). It never regresses a done plan back to in-progress — if a done plan shows incomplete tasks (work merged but tasks not marked), mark those tasks done instead.',
|
|
47
52
|
],
|
|
48
53
|
parameters: Type.Object({
|
|
49
54
|
apply: Type.Optional(
|
|
@@ -76,7 +81,12 @@ export function registerReconcilePlansTool(pi: ExtensionAPI, runPlanIO: RunPlanI
|
|
|
76
81
|
? ` ✗ ${row.name} (initiative, ${row.members} plans) — registry ${row.registryStatus}, plans say ${row.derivedStatus}`
|
|
77
82
|
: ` ✓ ${row.name} (initiative, ${row.members} plans) — ${row.registryStatus} (in sync)`,
|
|
78
83
|
);
|
|
79
|
-
const statusDrift = drifted.filter(
|
|
84
|
+
const statusDrift = drifted.filter(
|
|
85
|
+
(r) => r.drift === 'status' && r.direction === 'upgrade',
|
|
86
|
+
).length;
|
|
87
|
+
const downgrades = drifted.filter(
|
|
88
|
+
(r) => r.drift === 'status' && r.direction === 'downgrade',
|
|
89
|
+
).length;
|
|
80
90
|
const orphans = drifted.filter((r) => r.drift === 'orphan').length;
|
|
81
91
|
const registryOnly = drifted.filter((r) => r.drift === 'registry-only').length;
|
|
82
92
|
|
|
@@ -89,6 +99,7 @@ export function registerReconcilePlansTool(pi: ExtensionAPI, runPlanIO: RunPlanI
|
|
|
89
99
|
|
|
90
100
|
const summary = [
|
|
91
101
|
`status-drift ${statusDrift}`,
|
|
102
|
+
`needs-tasks-done ${downgrades}`,
|
|
92
103
|
`orphan ${orphans}`,
|
|
93
104
|
`registry-only ${registryOnly}`,
|
|
94
105
|
].join(', ');
|
|
@@ -102,7 +113,7 @@ export function registerReconcilePlansTool(pi: ExtensionAPI, runPlanIO: RunPlanI
|
|
|
102
113
|
details: {
|
|
103
114
|
applied: Boolean(params.apply),
|
|
104
115
|
repaired: repaired.map((r) => r.name),
|
|
105
|
-
drift: drifted.map((r) => ({ name: r.name, kind: r.drift })),
|
|
116
|
+
drift: drifted.map((r) => ({ name: r.name, kind: r.drift, direction: r.direction })),
|
|
106
117
|
total: rows.length,
|
|
107
118
|
initiative_repaired: initiativeRepaired.map((r) => r.name),
|
|
108
119
|
initiative_drift: initiativeDrifted.map((r) => ({ name: r.name, kind: r.drift })),
|
package/package.json
CHANGED