@mjasnikovs/pi-task 0.21.5 → 0.21.7
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/task/requirements.d.ts +32 -0
- package/dist/task/requirements.js +32 -0
- package/dist/workers/research-cache.js +172 -22
- package/package.json +1 -1
|
@@ -149,6 +149,38 @@ export declare function buildOwnedRequirementsBlock(owned: OwnedRequirement[]):
|
|
|
149
149
|
* quote appears anywhere in the spec — belt-obeying reps aren't double-stated.
|
|
150
150
|
* No CONSTRAINTS section (shape-invalid spec) → returned unchanged; this runs
|
|
151
151
|
* only on specs the shape gate already accepted.
|
|
152
|
+
*
|
|
153
|
+
* NOT EXTENDED TO CONSUMER TASKS — REFUTED AT STEP 0, 2026-07-27. The proposal
|
|
154
|
+
* was to classify owned requirements INVARIANT (prohibition-shaped) vs
|
|
155
|
+
* DELIVERABLE and propagate the INVARIANTs from here to every task whose spec
|
|
156
|
+
* names the same symbol/file, because mx5 run 17 gave all three Hono-RPC
|
|
157
|
+
* obligations to TASK_0021 (which complied perfectly) while the four CONSUMER
|
|
158
|
+
* tasks that never saw them — 0027/0031/0033/0034 — hand-wrote casts and shipped
|
|
159
|
+
* 7 dead client call sites. It was not built, for two measured reasons
|
|
160
|
+
* (scripts/owned-consumer-generality-step0.ts, re-runnable):
|
|
161
|
+
*
|
|
162
|
+
* 1. IT DOES NOT GENERALIZE. The task's own kill condition was <20% of a second
|
|
163
|
+
* stack's OWNED requirements being prohibition-shaped. IAR1, 8 live
|
|
164
|
+
* regenerations of the real plan-time pipeline over its real 10-task list:
|
|
165
|
+
* 2/42 pooled = 4.8% (narrow four-phrase reading 1/42 = 2.4%). mx5 itself is
|
|
166
|
+
* 7/33 = 21.2% only under the BROAD rule above; under "never/don't/must
|
|
167
|
+
* not/do not" it is 2/33 = 6.1%. The structural reason is in accountCoverage
|
|
168
|
+
* right here: a prohibition the map leaves NONE is already carried
|
|
169
|
+
* cross-cutting to every task, so in the five reps that logged the split 18
|
|
170
|
+
* of IAR1's 19 prohibition-shaped requirements were never owner-only in the
|
|
171
|
+
* first place. mx5's 7 leaked because the model mapped them to a TASK.
|
|
172
|
+
* 2. THE TARGETING RULE MISSES ITS OWN MOTIVATING CASE. Symbol/file relevance
|
|
173
|
+
* would not have reached the four violators for the clause they actually
|
|
174
|
+
* broke ("If a call isn't fully typed end-to-end via `hc`, fix the route
|
|
175
|
+
* chaining/export, don't paper over it…"): its only extractable symbol is
|
|
176
|
+
* `chaining/export`, which no consumer spec contains — 0 consumers. Its
|
|
177
|
+
* siblings would have reached all four, attaching to 13/41 tasks each; across
|
|
178
|
+
* mx5's 33 owned requirements the mean attach rate is 21% of all tasks and
|
|
179
|
+
* 5/33 would attach to more than half of them (the task's own I1 trigger).
|
|
180
|
+
*
|
|
181
|
+
* Do not re-open on mx5 evidence alone. A future attempt needs a second stack
|
|
182
|
+
* where prohibition-shaped requirements actually land OWNED, and a targeting rule
|
|
183
|
+
* that survives a clause whose symbols are prose.
|
|
152
184
|
*/
|
|
153
185
|
export declare function appendOwnedConstraints(spec: string, owned: OwnedRequirement[]): string;
|
|
154
186
|
/** The decompose-prompt ledger block (goal E's belt): the grounded requirement
|
|
@@ -556,6 +556,38 @@ export function buildOwnedRequirementsBlock(owned) {
|
|
|
556
556
|
* quote appears anywhere in the spec — belt-obeying reps aren't double-stated.
|
|
557
557
|
* No CONSTRAINTS section (shape-invalid spec) → returned unchanged; this runs
|
|
558
558
|
* only on specs the shape gate already accepted.
|
|
559
|
+
*
|
|
560
|
+
* NOT EXTENDED TO CONSUMER TASKS — REFUTED AT STEP 0, 2026-07-27. The proposal
|
|
561
|
+
* was to classify owned requirements INVARIANT (prohibition-shaped) vs
|
|
562
|
+
* DELIVERABLE and propagate the INVARIANTs from here to every task whose spec
|
|
563
|
+
* names the same symbol/file, because mx5 run 17 gave all three Hono-RPC
|
|
564
|
+
* obligations to TASK_0021 (which complied perfectly) while the four CONSUMER
|
|
565
|
+
* tasks that never saw them — 0027/0031/0033/0034 — hand-wrote casts and shipped
|
|
566
|
+
* 7 dead client call sites. It was not built, for two measured reasons
|
|
567
|
+
* (scripts/owned-consumer-generality-step0.ts, re-runnable):
|
|
568
|
+
*
|
|
569
|
+
* 1. IT DOES NOT GENERALIZE. The task's own kill condition was <20% of a second
|
|
570
|
+
* stack's OWNED requirements being prohibition-shaped. IAR1, 8 live
|
|
571
|
+
* regenerations of the real plan-time pipeline over its real 10-task list:
|
|
572
|
+
* 2/42 pooled = 4.8% (narrow four-phrase reading 1/42 = 2.4%). mx5 itself is
|
|
573
|
+
* 7/33 = 21.2% only under the BROAD rule above; under "never/don't/must
|
|
574
|
+
* not/do not" it is 2/33 = 6.1%. The structural reason is in accountCoverage
|
|
575
|
+
* right here: a prohibition the map leaves NONE is already carried
|
|
576
|
+
* cross-cutting to every task, so in the five reps that logged the split 18
|
|
577
|
+
* of IAR1's 19 prohibition-shaped requirements were never owner-only in the
|
|
578
|
+
* first place. mx5's 7 leaked because the model mapped them to a TASK.
|
|
579
|
+
* 2. THE TARGETING RULE MISSES ITS OWN MOTIVATING CASE. Symbol/file relevance
|
|
580
|
+
* would not have reached the four violators for the clause they actually
|
|
581
|
+
* broke ("If a call isn't fully typed end-to-end via `hc`, fix the route
|
|
582
|
+
* chaining/export, don't paper over it…"): its only extractable symbol is
|
|
583
|
+
* `chaining/export`, which no consumer spec contains — 0 consumers. Its
|
|
584
|
+
* siblings would have reached all four, attaching to 13/41 tasks each; across
|
|
585
|
+
* mx5's 33 owned requirements the mean attach rate is 21% of all tasks and
|
|
586
|
+
* 5/33 would attach to more than half of them (the task's own I1 trigger).
|
|
587
|
+
*
|
|
588
|
+
* Do not re-open on mx5 evidence alone. A future attempt needs a second stack
|
|
589
|
+
* where prohibition-shaped requirements actually land OWNED, and a targeting rule
|
|
590
|
+
* that survives a clause whose symbols are prose.
|
|
559
591
|
*/
|
|
560
592
|
export function appendOwnedConstraints(spec, owned) {
|
|
561
593
|
if (owned.length === 0)
|
|
@@ -62,6 +62,34 @@
|
|
|
62
62
|
* to tell its docs entries apart, so it still falls back to a fresh id: every
|
|
63
63
|
* inconclusive path costs time, never correctness.
|
|
64
64
|
*
|
|
65
|
+
* CONCURRENT WRITERS (nexttask TASK 4). Storing is a read-modify-write over ONE file,
|
|
66
|
+
* and its writers are concurrent on two axes at once: makeWorkerTool registers the
|
|
67
|
+
* research tools with executionMode 'parallel', so a single child can issue 4-6 docs
|
|
68
|
+
* calls in the same millisecond, and the research phase runs four worker children as
|
|
69
|
+
* separate PROCESSES. Unsynchronised, that is a classic lost update — last writer wins,
|
|
70
|
+
* and everything read before it is discarded. Measured on this box: 40 concurrent stores
|
|
71
|
+
* left 1 entry in-process and 11 of 40 across four child processes.
|
|
72
|
+
*
|
|
73
|
+
* So the read-modify-write is serialised twice over: an in-process queue per cache file
|
|
74
|
+
* (siblings inside one child never touch the filesystem lock at all) wrapped in an
|
|
75
|
+
* advisory lock directory beside the file, which is what makes it hold across processes.
|
|
76
|
+
* An atomic `mkdir` is the lock — it is the one primitive that both POSIX and Windows
|
|
77
|
+
* give us with create-or-fail semantics and no fd bookkeeping.
|
|
78
|
+
*
|
|
79
|
+
* The lock is BEST-EFFORT LIKE EVERYTHING ELSE HERE: acquisition is bounded, and a
|
|
80
|
+
* writer that cannot get in within the timeout SKIPS its store rather than waiting. A
|
|
81
|
+
* skipped store costs one re-lookup later; a blocked store would stall a worker, which
|
|
82
|
+
* this cache is never allowed to do.
|
|
83
|
+
*
|
|
84
|
+
* WHAT THE FIX IS WORTH, measured before it was built (scripts/research-cache-
|
|
85
|
+
* write-loss-step0.ts, re-runnable): the lost updates are real but nearly free. mx5 lost
|
|
86
|
+
* ~70 of 204 attempted keys, all in the docs channel — and asked ZERO of them twice, so
|
|
87
|
+
* nothing lost was ever wanted again. IAR1 and godot-engine retained more distinct keys
|
|
88
|
+
* than the logs show attempted, i.e. lost nothing at all. Estimated recovery on all three
|
|
89
|
+
* projects: 0s per run. This is a correctness fix, not a performance one; it stops the
|
|
90
|
+
* cache silently discarding work, and it will matter to a run whose research phase does
|
|
91
|
+
* repeat itself. Do not oversell it.
|
|
92
|
+
*
|
|
65
93
|
* Stored under `.pi-tasks/` (sibling of env-notes.md / contracts.md), which the
|
|
66
94
|
* git-state guard and discardEdits both exclude. Best-effort throughout: any I/O or
|
|
67
95
|
* parse failure falls back to a live fetch — the cache only ever saves time, it can
|
|
@@ -79,6 +107,23 @@ export const RESEARCH_RUN_ID_ENV = 'PI_TASK_RUN_ID';
|
|
|
79
107
|
* lookups (dozens), so a real run never evicts a still-useful digest.
|
|
80
108
|
*/
|
|
81
109
|
const MAX_ENTRIES = 250;
|
|
110
|
+
/** Lock directory guarding the cache file's read-modify-write, created beside it. */
|
|
111
|
+
const LOCK_SUFFIX = '.lock';
|
|
112
|
+
/**
|
|
113
|
+
* How long a writer waits for the lock before giving up and skipping its store. Sized
|
|
114
|
+
* well above a real critical section (one small read + one small write, sub-millisecond
|
|
115
|
+
* at these file sizes) and well below anything a worker would notice.
|
|
116
|
+
*/
|
|
117
|
+
const LOCK_TIMEOUT_MS = 2_000;
|
|
118
|
+
/** Poll interval while the lock is held by someone else. */
|
|
119
|
+
const LOCK_POLL_MS = 10;
|
|
120
|
+
/**
|
|
121
|
+
* A lock older than this is treated as abandoned and removed. Two writers can both
|
|
122
|
+
* decide that and both proceed, which degrades exactly to the pre-lock behaviour (one
|
|
123
|
+
* lost update) — strictly better than a crashed child wedging the cache for the rest of
|
|
124
|
+
* the run. Sized far above the critical section, so a live holder is never stolen from.
|
|
125
|
+
*/
|
|
126
|
+
const LOCK_STALE_MS = 30_000;
|
|
82
127
|
/**
|
|
83
128
|
* Schema marker for per-entry package provenance. A file without it was written by a
|
|
84
129
|
* version that stored no `pkg` on its entries, so its docs entries are indistinguishable
|
|
@@ -197,10 +242,26 @@ export async function resumeResearchRun(cwd, enabled) {
|
|
|
197
242
|
delete process.env[RESEARCH_RUN_ID_ENV];
|
|
198
243
|
return { runId: undefined, reused: false, entries: 0, dropped: 0 };
|
|
199
244
|
}
|
|
200
|
-
|
|
201
|
-
|
|
245
|
+
// Prune under the same lock a store takes. In practice a resume runs before this
|
|
246
|
+
// run's first worker child exists, so there is nothing to race — but the prune is a
|
|
247
|
+
// read-modify-write over the same file, and it costs nothing to make that true by
|
|
248
|
+
// construction rather than by scheduling. Falls back to an unlocked prune if the
|
|
249
|
+
// lock is unavailable, which is exactly the pre-lock behaviour.
|
|
250
|
+
const pruned = (await withCacheLock(cwd, () => pruneCache(cwd))) ?? (await pruneCache(cwd));
|
|
251
|
+
if (!pruned)
|
|
202
252
|
return { runId: configureResearchRun(true), reused: false, entries: 0, dropped: 0 };
|
|
203
|
-
|
|
253
|
+
process.env[RESEARCH_RUN_ID_ENV] = pruned.runId;
|
|
254
|
+
return { runId: pruned.runId, reused: true, entries: pruned.entries, dropped: pruned.dropped };
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Drop the entries the manifest has invalidated and persist the result, returning the
|
|
258
|
+
* reusable run id — or null when there is nothing to reason with (no cache file, or one
|
|
259
|
+
* predating per-entry package provenance).
|
|
260
|
+
*/
|
|
261
|
+
async function pruneCache(cwd) {
|
|
262
|
+
const file = await readCacheFile(cwd);
|
|
263
|
+
if (!file || file.pkgv !== PKG_PROVENANCE_VERSION)
|
|
264
|
+
return null;
|
|
204
265
|
const deps = await depsMap(cwd);
|
|
205
266
|
const kept = {};
|
|
206
267
|
let dropped = 0;
|
|
@@ -214,8 +275,7 @@ export async function resumeResearchRun(cwd, enabled) {
|
|
|
214
275
|
// stale digests behind under a reused id.
|
|
215
276
|
if (dropped > 0)
|
|
216
277
|
await writeCacheFile(cwd, { runId: file.runId, entries: kept, pkgv: file.pkgv });
|
|
217
|
-
|
|
218
|
-
return { runId: file.runId, reused: true, entries: Object.keys(kept).length, dropped };
|
|
278
|
+
return { runId: file.runId, entries: Object.keys(kept).length, dropped };
|
|
219
279
|
}
|
|
220
280
|
async function readCacheFile(cwd) {
|
|
221
281
|
try {
|
|
@@ -257,6 +317,91 @@ async function writeCacheFile(cwd, out) {
|
|
|
257
317
|
// best-effort cache
|
|
258
318
|
}
|
|
259
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* Serialises this process's own writers per cache file, so the 4-6 parallel tool calls
|
|
322
|
+
* a single research child issues in one turn queue in memory instead of contending for
|
|
323
|
+
* the lock directory. Purely an optimisation: the cross-process lock below is what makes
|
|
324
|
+
* the store correct.
|
|
325
|
+
*/
|
|
326
|
+
const inProcessQueues = new Map();
|
|
327
|
+
/**
|
|
328
|
+
* Take the advisory lock, or return false once `deadline` passes. Never throws: an
|
|
329
|
+
* unexpected filesystem error is reported as "not acquired", which the caller turns
|
|
330
|
+
* into a skipped store.
|
|
331
|
+
*/
|
|
332
|
+
async function acquireLock(lockPath, deadline) {
|
|
333
|
+
for (;;) {
|
|
334
|
+
try {
|
|
335
|
+
// mkdir is create-or-fail: exactly one caller can win it.
|
|
336
|
+
await fsp.mkdir(lockPath);
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
if (err.code !== 'EEXIST')
|
|
341
|
+
return false;
|
|
342
|
+
try {
|
|
343
|
+
const st = await fsp.stat(lockPath);
|
|
344
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
345
|
+
await fsp.rmdir(lockPath).catch(() => { });
|
|
346
|
+
if (Date.now() >= deadline)
|
|
347
|
+
return false;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// vanished between mkdir and stat ⇒ the holder just released it
|
|
353
|
+
if (Date.now() >= deadline)
|
|
354
|
+
return false;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (Date.now() >= deadline)
|
|
358
|
+
return false;
|
|
359
|
+
await new Promise(resolve => setTimeout(resolve, LOCK_POLL_MS));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Run `fn` as the sole writer of this project's cache file, across both the calling
|
|
365
|
+
* process's own parallel tool calls and every other pi child sharing the tree. Returns
|
|
366
|
+
* `fn`'s result, or undefined when the lock could not be taken in time — the caller then
|
|
367
|
+
* SKIPS the write (invariant I1: bounded wait, never a deadlock and never a stall).
|
|
368
|
+
*/
|
|
369
|
+
async function withCacheLock(cwd, fn) {
|
|
370
|
+
const file = researchCacheFile(cwd);
|
|
371
|
+
const prior = inProcessQueues.get(file) ?? Promise.resolve();
|
|
372
|
+
let release;
|
|
373
|
+
const mine = new Promise(resolve => (release = resolve));
|
|
374
|
+
// The chain is rejection-free by construction: `mine` only ever resolves, and the
|
|
375
|
+
// root is Promise.resolve(), so no waiter can inherit a rejection.
|
|
376
|
+
const tail = prior.then(() => mine);
|
|
377
|
+
inProcessQueues.set(file, tail);
|
|
378
|
+
await prior.catch(() => { });
|
|
379
|
+
try {
|
|
380
|
+
const lockPath = `${file}${LOCK_SUFFIX}`;
|
|
381
|
+
let held = false;
|
|
382
|
+
try {
|
|
383
|
+
await fsp.mkdir(tasksDir(cwd), { recursive: true });
|
|
384
|
+
held = await acquireLock(lockPath, Date.now() + LOCK_TIMEOUT_MS);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
held = false;
|
|
388
|
+
}
|
|
389
|
+
if (!held)
|
|
390
|
+
return undefined;
|
|
391
|
+
try {
|
|
392
|
+
return await fn();
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
await fsp.rmdir(lockPath).catch(() => { });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
finally {
|
|
399
|
+
release();
|
|
400
|
+
// Keep the map from growing one entry per project for the process's lifetime.
|
|
401
|
+
if (inProcessQueues.get(file) === tail)
|
|
402
|
+
inProcessQueues.delete(file);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
260
405
|
/**
|
|
261
406
|
* Store a successful result under `key` for the current run. A file written for a
|
|
262
407
|
* different run id is discarded and started fresh (first write of a new run drops the
|
|
@@ -272,24 +417,29 @@ async function writeCacheFile(cwd, out) {
|
|
|
272
417
|
*/
|
|
273
418
|
export async function storeResearch(cwd, runId, key, text, details, pkg) {
|
|
274
419
|
try {
|
|
275
|
-
|
|
276
|
-
|
|
420
|
+
// Resolved OUTSIDE the critical section: it reads package.json, which no other
|
|
421
|
+
// writer can be mutating, and keeping it out holds the lock for the file
|
|
422
|
+
// read/write alone. The version is still the one current at store time.
|
|
277
423
|
const pkgVersion = pkg === undefined ? undefined : await declaredVersion(cwd, pkg);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
424
|
+
await withCacheLock(cwd, async () => {
|
|
425
|
+
const existing = await readCacheFile(cwd);
|
|
426
|
+
const entries = existing && existing.runId === runId ? existing.entries : {};
|
|
427
|
+
entries[key] = {
|
|
428
|
+
text,
|
|
429
|
+
details,
|
|
430
|
+
at: Date.now(),
|
|
431
|
+
...(pkg === undefined ? {} : { pkg }),
|
|
432
|
+
...(pkgVersion === undefined ? {} : { pkgVersion })
|
|
433
|
+
};
|
|
434
|
+
// Evict oldest by write time if over the cap.
|
|
435
|
+
const keys = Object.keys(entries);
|
|
436
|
+
if (keys.length > MAX_ENTRIES) {
|
|
437
|
+
const ordered = keys.sort((a, b) => entries[a].at - entries[b].at);
|
|
438
|
+
for (const k of ordered.slice(0, keys.length - MAX_ENTRIES))
|
|
439
|
+
delete entries[k];
|
|
440
|
+
}
|
|
441
|
+
await writeCacheFile(cwd, { runId, entries, pkgv: PKG_PROVENANCE_VERSION });
|
|
442
|
+
});
|
|
293
443
|
}
|
|
294
444
|
catch {
|
|
295
445
|
// best-effort cache
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.7",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|