@mjasnikovs/pi-task 0.21.4 → 0.21.6
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/phases.js +44 -0
- package/dist/workers/research-cache.js +172 -22
- package/package.json +1 -1
package/dist/task/phases.js
CHANGED
|
@@ -1343,6 +1343,50 @@ export const PHASES = [
|
|
|
1343
1343
|
}
|
|
1344
1344
|
}
|
|
1345
1345
|
];
|
|
1346
|
+
// INTEGRATION-DEPTH APPEND (2026-07-27): the lever proposed for this exact site —
|
|
1347
|
+
// deterministically append a known-runnable integration command to the VERIFY block
|
|
1348
|
+
// whenever a task's ACCEPTANCE claims runtime behaviour — is REFUTED at STEP 0 and was
|
|
1349
|
+
// NOT built. Nothing below is wired; this is the record.
|
|
1350
|
+
//
|
|
1351
|
+
// The defect is real and reproduces on four unrelated stacks. Measured with
|
|
1352
|
+
// scripts/verify-integration-depth-step0.ts (published metric regex in that file;
|
|
1353
|
+
// re-runnable, no model time): VERIFY blocks that boot or hit the real integrated
|
|
1354
|
+
// product — mx5 7/41, IAR1 3/10, godot-engine 2/20, runner 0/2, total 12/73 (16.4%).
|
|
1355
|
+
// That total OVERSTATES the truth: godot's two hits only match because the block greps
|
|
1356
|
+
// a URL out of CLAUDE.md, and IAR1's TASK_0006 curls a GitHub tarball to check a hash,
|
|
1357
|
+
// so genuine product integration is ~9/73 (~12%). The addressable class — runtime claim
|
|
1358
|
+
// in ACCEPTANCE, a boot command available with provenance, static-only VERIFY anyway —
|
|
1359
|
+
// is 29/73 (39.7%), clearing the task's 25% kill condition, but 27 of those 29 are mx5.
|
|
1360
|
+
//
|
|
1361
|
+
// What kills the lever is its input, not its premise. It needs a command that is
|
|
1362
|
+
// (1) provenance-bearing, (2) proven runnable, (3) terminating, and (4) integration-
|
|
1363
|
+
// shaped. Measured on scratch clones with scripts/integration-command-provenance.ts,
|
|
1364
|
+
// that intersection is EMPTY on every stack available here:
|
|
1365
|
+
// mx5 `bun run dev` is the only command matching the metric and it does not
|
|
1366
|
+
// terminate (probe killed it at 60s) — appending it hangs the verify child
|
|
1367
|
+
// until the 15-minute command watchdog and then FAILs. Every command that DOES
|
|
1368
|
+
// terminate (`bun run build` exit 0) fails the metric.
|
|
1369
|
+
// godot `godot --headless --quit` (0.8s, exit 0) and the real GUT runner (1.7s,
|
|
1370
|
+
// exit 0) are both appendable — and neither matches the metric, which is
|
|
1371
|
+
// web-shaped (curl/localhost/PORT=/run dev/http://). Nothing appendable can
|
|
1372
|
+
// move the registered metric on a non-web stack, so the two-stack A/B the
|
|
1373
|
+
// task requires is unsatisfiable by construction, not by sample size.
|
|
1374
|
+
// IAR1 no candidate at all: cmake is absent from this box (the N5 finding).
|
|
1375
|
+
// R4 forbids swapping the metric after seeing results, so a broadened "boots the real
|
|
1376
|
+
// product" metric is a separate pre-registered experiment, not a rescue of this one.
|
|
1377
|
+
//
|
|
1378
|
+
// The deeper finding: on the stack holding 93% of the addressable mass, integration is
|
|
1379
|
+
// not a COMMAND. mx5's 7 integrated blocks are whole task-specific procedures — pick a
|
|
1380
|
+
// free port (3911/41234/42421/3001), export DATABASE_URL, boot, seed, log in, assert N
|
|
1381
|
+
// endpoints, tear down. A host-side append cannot synthesize that from provenance
|
|
1382
|
+
// (launch-contract.md records script NAMES only — no port, no health URL), and
|
|
1383
|
+
// synthesizing it from the task's own `## verified tooling` is the N5 fabrication road.
|
|
1384
|
+
// That machinery already exists in ONE place that owns ports, seeding and teardown: the
|
|
1385
|
+
// final gate's render check. The mx5-class defect belongs there (nexttask TASK 6), not
|
|
1386
|
+
// in per-task VERIFY blocks.
|
|
1387
|
+
//
|
|
1388
|
+
// Durable assets kept: both rigs above and their unit tests. Do NOT wire an append here
|
|
1389
|
+
// without a command source that satisfies all four properties at once.
|
|
1346
1390
|
export async function postCommitPhase(phase, deps, pc, out) {
|
|
1347
1391
|
if (phase.name !== 'refine')
|
|
1348
1392
|
return;
|
|
@@ -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.6",
|
|
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",
|