@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.
@@ -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
- const file = await readCacheFile(cwd);
201
- if (!file || file.pkgv !== PKG_PROVENANCE_VERSION) {
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
- process.env[RESEARCH_RUN_ID_ENV] = file.runId;
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
- const existing = await readCacheFile(cwd);
276
- const entries = existing && existing.runId === runId ? existing.entries : {};
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
- entries[key] = {
279
- text,
280
- details,
281
- at: Date.now(),
282
- ...(pkg === undefined ? {} : { pkg }),
283
- ...(pkgVersion === undefined ? {} : { pkgVersion })
284
- };
285
- // Evict oldest by write time if over the cap.
286
- const keys = Object.keys(entries);
287
- if (keys.length > MAX_ENTRIES) {
288
- const ordered = keys.sort((a, b) => entries[a].at - entries[b].at);
289
- for (const k of ordered.slice(0, keys.length - MAX_ENTRIES))
290
- delete entries[k];
291
- }
292
- await writeCacheFile(cwd, { runId, entries, pkgv: PKG_PROVENANCE_VERSION });
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.4",
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",