@mjasnikovs/pi-task 0.18.38 → 0.18.39

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.
@@ -1518,13 +1518,15 @@ async function handleTaskAutoResume(_args, ctx) {
1518
1518
  autoRunning = true;
1519
1519
  armTerminalCancel(ctx);
1520
1520
  try {
1521
- // Reuse the interrupted run's research-cache id when the cache proves it still
1522
- // describes the same dependency surface (F10). mx5 run 13 resumed three times and
1523
- // each resume's fresh id discarded a working 201-entry cache; anything inconclusive
1524
- // still falls back to a fresh id and a re-fetch. See resumeResearchRun.
1521
+ // Reuse the interrupted run's research-cache id, dropping only the entries whose
1522
+ // own package moved version (F10). mx5 run 13 resumed three times and each
1523
+ // resume's fresh id discarded a working 201-entry cache; run 14 then showed a
1524
+ // whole-file freshness gate can never hold on a greenfield run that installs
1525
+ // packages as it goes, so invalidation is per entry. See resumeResearchRun.
1525
1526
  const research = await resumeResearchRun(cwd, getConfig().researchCache);
1526
1527
  if (research.reused) {
1527
- logPlanDebug(cwd, `research cache: resume reused ${research.entries} entr(ies)`);
1528
+ logPlanDebug(cwd, `research cache: resume reused ${research.entries} entr(ies), `
1529
+ + `dropped ${research.dropped} stale`);
1528
1530
  }
1529
1531
  const abort = new AbortController();
1530
1532
  // Resume only runs the loop (runTask); no planning children, so the loader
@@ -5,6 +5,13 @@ import { resolvePackage as defaultResolvePackage } from './docs-resolve.js';
5
5
  import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
6
6
  import { npmVersionLookup as defaultNpmVersionLookup } from './npm-version.js';
7
7
  import { type SpawnFn } from '../shared/child-process.js';
8
+ /**
9
+ * The package NAME a module specifier belongs to — `hono/client` → `hono`,
10
+ * `@scope/name/sub` → `@scope/name`. The cache stores this (not the raw specifier) as an
11
+ * entry's package provenance, so a subpath lookup is matched against package.json's key
12
+ * and invalidated with its package rather than living forever unmatched.
13
+ */
14
+ export declare function packageRootOf(module: string): string;
8
15
  export interface PiWorkerDocsInternals {
9
16
  resolvePackage?: typeof defaultResolvePackage;
10
17
  ensureIndexed?: typeof defaultEnsureIndexed;
@@ -21,6 +21,16 @@ const Params = Type.Object({
21
21
  description: 'What to extract from the docs. The child pi reads ranked chunks and returns ONLY content answering this.'
22
22
  })
23
23
  });
24
+ /**
25
+ * The package NAME a module specifier belongs to — `hono/client` → `hono`,
26
+ * `@scope/name/sub` → `@scope/name`. The cache stores this (not the raw specifier) as an
27
+ * entry's package provenance, so a subpath lookup is matched against package.json's key
28
+ * and invalidated with its package rather than living forever unmatched.
29
+ */
30
+ export function packageRootOf(module) {
31
+ const parts = module.trim().split('/');
32
+ return parts[0].startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
33
+ }
24
34
  function pinDetails(pin) {
25
35
  return pin ? { versionSource: pin.source, declaredRange: pin.range } : {};
26
36
  }
@@ -249,6 +259,12 @@ export function registerPiWorkerDocs(pi, internals = {}) {
249
259
  cacheKey: params => params.module === '.' ?
250
260
  null
251
261
  : `${normalizeQuery(params.module)}::${normalizeQuery(params.query)}`,
262
+ // Package provenance for per-entry resume invalidation: a docs digest describes
263
+ // one package at one declared version, so a resume drops it only when THAT
264
+ // package moves — an unrelated install no longer discards it. Package names are
265
+ // matched against package.json verbatim (npm names are case-sensitive), unlike
266
+ // the cache key, which normalises for phrasing collisions.
267
+ cachePkg: params => (params.module === '.' ? undefined : packageRootOf(params.module)),
252
268
  // Only a completed lookup (child exited 0) is a real answer; not-installed,
253
269
  // no-chunks, resolve/cache errors, and aborts omit childExitCode:0 and fall
254
270
  // through to a live retry next time.
@@ -23,31 +23,32 @@ export declare function configureResearchRun(enabled: boolean): string | undefin
23
23
  */
24
24
  export declare function normalizeQuery(s: string): string;
25
25
  /**
26
- * A stable fingerprint of the project's declared dependency surface — the only input a
27
- * cached docs digest actually depends on (a digest summarises a package's INSTALLED
28
- * types/README at a pinned version). Built from package.json's dependency blocks with
29
- * keys sorted, so formatting churn or an unrelated field edit does not invalidate it,
30
- * while any add/remove/version-bump does.
26
+ * The project's declared dependency surface as a name→range map, flattened across every
27
+ * dependency block (a package listed in two blocks resolves to the first range seen, in
28
+ * block order — a real manifest does not disagree with itself, and a disagreement can
29
+ * only make the comparison stricter, i.e. re-fetch).
31
30
  *
32
- * Returns undefined when the manifest is missing or unparseable — the caller then has
33
- * NO positive evidence of freshness and must not reuse. Deliberately NOT the lockfile's
34
- * hash or mtime: a lockfile is rewritten by installs that do not change any resolved
35
- * version, which would defeat reuse for no correctness gain.
31
+ * Returns undefined when the manifest is missing or unparseable: the caller then cannot
32
+ * prove any package's version and must treat every package-scoped entry as unprovable.
33
+ * Deliberately NOT the lockfile: it is rewritten by installs that change no resolved
34
+ * version, which would drop digests for no correctness gain.
36
35
  */
37
- export declare function depsFingerprint(cwd: string): Promise<string | undefined>;
36
+ export declare function depsMap(cwd: string): Promise<Record<string, string> | undefined>;
38
37
  /**
39
- * Resume hook: reuse the interrupted run's cache id when — and only when — the file
40
- * proves it describes the same dependency surface. Returns the id now stamped into the
41
- * environment (reused or fresh), or undefined when caching is off.
38
+ * Resume hook: keep the interrupted run's cache id and PRUNE the entries the manifest
39
+ * has invalidated, rather than discarding the whole cache because anything moved. Returns
40
+ * the id now stamped into the environment (reused or fresh), how many entries survived,
41
+ * and how many were dropped; undefined id when caching is off.
42
42
  *
43
- * Every uncertain path falls through to a fresh id, which merely re-fetches:
44
- * caching disabled, no/corrupt cache file, a file with no fingerprint (written before
45
- * this shipped), an unreadable manifest, or a fingerprint that no longer matches.
43
+ * Falls through to a fresh id only where there is nothing to reuse or nothing to reason
44
+ * with: caching disabled, no/corrupt cache file, or a file predating per-entry package
45
+ * provenance (`pkgv`) whose docs entries cannot be told apart from its search entries.
46
46
  */
47
47
  export declare function resumeResearchRun(cwd: string, enabled: boolean): Promise<{
48
48
  runId: string | undefined;
49
49
  reused: boolean;
50
50
  entries: number;
51
+ dropped: number;
51
52
  }>;
52
53
  /**
53
54
  * Look up a cached result for `key` in the current run. Returns undefined on a miss,
@@ -61,6 +62,13 @@ export declare function lookupResearch(cwd: string, runId: string, key: string):
61
62
  * Store a successful result under `key` for the current run. A file written for a
62
63
  * different run id is discarded and started fresh (first write of a new run drops the
63
64
  * prior run's contents — self-healing per-run isolation without an explicit clear).
65
+ *
66
+ * `pkg` is the npm package the answer is ABOUT, supplied by package-scoped tools (docs).
67
+ * Its declared version is resolved HERE, at store time, so a package installed mid-run
68
+ * is stamped with the version its own digest was taken against — not with whatever the
69
+ * manifest happened to say at the run's first write. That is what makes a later resume
70
+ * able to prune this one entry instead of the whole file.
71
+ *
64
72
  * Best-effort: any failure is swallowed, leaving the caller's live result untouched.
65
73
  */
66
- export declare function storeResearch(cwd: string, runId: string, key: string, text: string, details: unknown): Promise<void>;
74
+ export declare function storeResearch(cwd: string, runId: string, key: string, text: string, details: unknown, pkg?: string): Promise<void>;
@@ -35,19 +35,38 @@
35
35
  * 5-task run and fails badly for a 32-task one.
36
36
  *
37
37
  * So a resume now REUSES the interrupted run's id — but only on POSITIVE evidence that
38
- * the digests still describe the same dependency surface. The staleness that matters is
39
- * a package version moving under a cached answer (a docs digest summarises the
40
- * INSTALLED types of a pinned package), so the file records a fingerprint of the
41
- * manifest's dependency block and a resume reuses the id only when that fingerprint is
42
- * unchanged. Missing, unparseable, or fingerprint-less file ⇒ fresh id and a re-fetch:
43
- * every inconclusive path costs time, never correctness.
38
+ * the digests still describe the same dependency surface.
39
+ *
40
+ * PER-PACKAGE INVALIDATION (mx5 run 14). The first shape of that evidence was one md5
41
+ * over the whole dependency block: any change anywhere ⇒ wipe. That gate can never hold
42
+ * on the projects /task actually builds — a greenfield run ADDS dependencies every few
43
+ * tasks (run 14: hono/zod at T10, shadcn at T25, playwright-ct at T28), so all five of
44
+ * its resumes saw a moved fingerprint and the run ended with ONE cached entry. Adding a
45
+ * package invalidates nothing that was already cached; only the package a digest is
46
+ * ABOUT going stale does.
47
+ *
48
+ * So invalidation is now per entry. Every docs entry records the package it describes
49
+ * and the version declared for it at store time (a structured field on the entry, not
50
+ * something re-parsed out of the key — keys embed the tool name with a \0 separator and
51
+ * are the wrong place to carry meaning). A resume keeps the run id and drops only the
52
+ * entries whose package changed version or left the manifest.
53
+ *
54
+ * STALENESS TRADE, decided deliberately: search and fetch entries are kept
55
+ * unconditionally, even across a dependency bump. A kept search result about package X
56
+ * may describe an older X. That is accepted — docs is the version-sensitive channel (it
57
+ * is version-pinned to the INSTALLED package since pi-worker fixes #1) and search is
58
+ * discovery, where re-running every query on every resume costs far more than the rare
59
+ * staleness costs. A run that must not tolerate it can disable the cache outright.
60
+ *
61
+ * A file written before per-entry provenance shipped (no `pkgv` marker) carries no way
62
+ * to tell its docs entries apart, so it still falls back to a fresh id: every
63
+ * inconclusive path costs time, never correctness.
44
64
  *
45
65
  * Stored under `.pi-tasks/` (sibling of env-notes.md / contracts.md), which the
46
66
  * git-state guard and discardEdits both exclude. Best-effort throughout: any I/O or
47
67
  * parse failure falls back to a live fetch — the cache only ever saves time, it can
48
68
  * never change an answer or block a worker.
49
69
  */
50
- import { createHash } from 'node:crypto';
51
70
  import * as fsp from 'node:fs/promises';
52
71
  import * as path from 'node:path';
53
72
  import { tasksDir } from '../task/task-io.js';
@@ -60,6 +79,12 @@ export const RESEARCH_RUN_ID_ENV = 'PI_TASK_RUN_ID';
60
79
  * lookups (dozens), so a real run never evicts a still-useful digest.
61
80
  */
62
81
  const MAX_ENTRIES = 250;
82
+ /**
83
+ * Schema marker for per-entry package provenance. A file without it was written by a
84
+ * version that stored no `pkg` on its entries, so its docs entries are indistinguishable
85
+ * from its search entries and cannot be pruned selectively ⇒ no reuse.
86
+ */
87
+ const PKG_PROVENANCE_VERSION = 2;
63
88
  export function researchCacheFile(cwd) {
64
89
  return path.join(tasksDir(cwd), RESEARCH_CACHE_FILE);
65
90
  }
@@ -100,18 +125,17 @@ export function normalizeQuery(s) {
100
125
  return s.replace(/\s+/g, ' ').trim().toLowerCase();
101
126
  }
102
127
  /**
103
- * A stable fingerprint of the project's declared dependency surface — the only input a
104
- * cached docs digest actually depends on (a digest summarises a package's INSTALLED
105
- * types/README at a pinned version). Built from package.json's dependency blocks with
106
- * keys sorted, so formatting churn or an unrelated field edit does not invalidate it,
107
- * while any add/remove/version-bump does.
128
+ * The project's declared dependency surface as a name→range map, flattened across every
129
+ * dependency block (a package listed in two blocks resolves to the first range seen, in
130
+ * block order — a real manifest does not disagree with itself, and a disagreement can
131
+ * only make the comparison stricter, i.e. re-fetch).
108
132
  *
109
- * Returns undefined when the manifest is missing or unparseable — the caller then has
110
- * NO positive evidence of freshness and must not reuse. Deliberately NOT the lockfile's
111
- * hash or mtime: a lockfile is rewritten by installs that do not change any resolved
112
- * version, which would defeat reuse for no correctness gain.
133
+ * Returns undefined when the manifest is missing or unparseable: the caller then cannot
134
+ * prove any package's version and must treat every package-scoped entry as unprovable.
135
+ * Deliberately NOT the lockfile: it is rewritten by installs that change no resolved
136
+ * version, which would drop digests for no correctness gain.
113
137
  */
114
- export async function depsFingerprint(cwd) {
138
+ export async function depsMap(cwd) {
115
139
  try {
116
140
  const raw = await fsp.readFile(path.join(cwd, 'package.json'), 'utf8');
117
141
  const pkg = JSON.parse(raw);
@@ -121,49 +145,77 @@ export async function depsFingerprint(cwd) {
121
145
  'peerDependencies',
122
146
  'optionalDependencies'
123
147
  ];
124
- const parts = [];
148
+ const out = {};
125
149
  for (const block of blocks) {
126
150
  const deps = pkg[block];
127
151
  if (!deps || typeof deps !== 'object')
128
152
  continue;
129
- const entries = Object.entries(deps)
130
- .filter(([, v]) => typeof v === 'string')
131
- .sort(([a], [b]) => a < b ? -1
132
- : a > b ? 1
133
- : 0)
134
- .map(([k, v]) => `${k}@${String(v)}`);
135
- if (entries.length > 0)
136
- parts.push(`${block}:${entries.join(',')}`);
153
+ for (const [name, range] of Object.entries(deps)) {
154
+ if (typeof range === 'string' && !(name in out))
155
+ out[name] = range;
156
+ }
137
157
  }
138
- // No dependency block at all is a real, stable state (a dependency-free repo) —
139
- // fingerprint it as such rather than failing, so reuse still works there.
140
- return createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 32);
158
+ // No dependency block at all is a real, stable state (a dependency-free repo).
159
+ return out;
141
160
  }
142
161
  catch {
143
162
  return undefined;
144
163
  }
145
164
  }
165
+ /** The version declared for `pkg`, or undefined when it is not in the manifest. */
166
+ async function declaredVersion(cwd, pkg) {
167
+ return (await depsMap(cwd))?.[pkg];
168
+ }
146
169
  /**
147
- * Resume hook: reuse the interrupted run's cache id when — and only when — the file
148
- * proves it describes the same dependency surface. Returns the id now stamped into the
149
- * environment (reused or fresh), or undefined when caching is off.
170
+ * Is this entry still current against the manifest as it stands now?
150
171
  *
151
- * Every uncertain path falls through to a fresh id, which merely re-fetches:
152
- * caching disabled, no/corrupt cache file, a file with no fingerprint (written before
153
- * this shipped), an unreadable manifest, or a fingerprint that no longer matches.
172
+ * - no `pkg` (search/fetch, and docs answers about nothing installable): KEPT — see the
173
+ * staleness trade in the file header.
174
+ * - `pkg` with no recorded version (not in the manifest when stored): KEPT — nothing in
175
+ * package.json can have moved under it.
176
+ * - `pkg` whose declared version is unchanged: KEPT.
177
+ * - `pkg` whose version moved, which left the manifest, or an unreadable manifest
178
+ * (no evidence either way): DROPPED.
179
+ */
180
+ function entryStillFresh(entry, deps) {
181
+ if (entry.pkg === undefined || entry.pkgVersion === undefined)
182
+ return true;
183
+ return deps?.[entry.pkg] === entry.pkgVersion;
184
+ }
185
+ /**
186
+ * Resume hook: keep the interrupted run's cache id and PRUNE the entries the manifest
187
+ * has invalidated, rather than discarding the whole cache because anything moved. Returns
188
+ * the id now stamped into the environment (reused or fresh), how many entries survived,
189
+ * and how many were dropped; undefined id when caching is off.
190
+ *
191
+ * Falls through to a fresh id only where there is nothing to reuse or nothing to reason
192
+ * with: caching disabled, no/corrupt cache file, or a file predating per-entry package
193
+ * provenance (`pkgv`) whose docs entries cannot be told apart from its search entries.
154
194
  */
155
195
  export async function resumeResearchRun(cwd, enabled) {
156
196
  if (!enabled) {
157
197
  delete process.env[RESEARCH_RUN_ID_ENV];
158
- return { runId: undefined, reused: false, entries: 0 };
198
+ return { runId: undefined, reused: false, entries: 0, dropped: 0 };
159
199
  }
160
200
  const file = await readCacheFile(cwd);
161
- const current = await depsFingerprint(cwd);
162
- if (file && file.deps !== undefined && current !== undefined && file.deps === current) {
163
- process.env[RESEARCH_RUN_ID_ENV] = file.runId;
164
- return { runId: file.runId, reused: true, entries: Object.keys(file.entries).length };
201
+ if (!file || file.pkgv !== PKG_PROVENANCE_VERSION) {
202
+ return { runId: configureResearchRun(true), reused: false, entries: 0, dropped: 0 };
165
203
  }
166
- return { runId: configureResearchRun(true), reused: false, entries: 0 };
204
+ const deps = await depsMap(cwd);
205
+ const kept = {};
206
+ let dropped = 0;
207
+ for (const [key, entry] of Object.entries(file.entries)) {
208
+ if (entryStillFresh(entry, deps))
209
+ kept[key] = entry;
210
+ else
211
+ dropped++;
212
+ }
213
+ // Persist the pruning now, so a crash between here and the first store cannot leave
214
+ // stale digests behind under a reused id.
215
+ if (dropped > 0)
216
+ 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 };
167
219
  }
168
220
  async function readCacheFile(cwd) {
169
221
  try {
@@ -193,17 +245,43 @@ export async function lookupResearch(cwd, runId, key) {
193
245
  const entry = file.entries[key];
194
246
  return entry ? { text: entry.text, details: entry.details } : undefined;
195
247
  }
248
+ /** Write the cache file atomic-ish, so a concurrent reader never sees it half-written. */
249
+ async function writeCacheFile(cwd, out) {
250
+ try {
251
+ await fsp.mkdir(tasksDir(cwd), { recursive: true });
252
+ const tmp = `${researchCacheFile(cwd)}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
253
+ await fsp.writeFile(tmp, JSON.stringify(out), 'utf8');
254
+ await fsp.rename(tmp, researchCacheFile(cwd));
255
+ }
256
+ catch {
257
+ // best-effort cache
258
+ }
259
+ }
196
260
  /**
197
261
  * Store a successful result under `key` for the current run. A file written for a
198
262
  * different run id is discarded and started fresh (first write of a new run drops the
199
263
  * prior run's contents — self-healing per-run isolation without an explicit clear).
264
+ *
265
+ * `pkg` is the npm package the answer is ABOUT, supplied by package-scoped tools (docs).
266
+ * Its declared version is resolved HERE, at store time, so a package installed mid-run
267
+ * is stamped with the version its own digest was taken against — not with whatever the
268
+ * manifest happened to say at the run's first write. That is what makes a later resume
269
+ * able to prune this one entry instead of the whole file.
270
+ *
200
271
  * Best-effort: any failure is swallowed, leaving the caller's live result untouched.
201
272
  */
202
- export async function storeResearch(cwd, runId, key, text, details) {
273
+ export async function storeResearch(cwd, runId, key, text, details, pkg) {
203
274
  try {
204
275
  const existing = await readCacheFile(cwd);
205
276
  const entries = existing && existing.runId === runId ? existing.entries : {};
206
- entries[key] = { text, details, at: Date.now() };
277
+ 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
+ };
207
285
  // Evict oldest by write time if over the cap.
208
286
  const keys = Object.keys(entries);
209
287
  if (keys.length > MAX_ENTRIES) {
@@ -211,21 +289,7 @@ export async function storeResearch(cwd, runId, key, text, details) {
211
289
  for (const k of ordered.slice(0, keys.length - MAX_ENTRIES))
212
290
  delete entries[k];
213
291
  }
214
- // Stamp the dependency surface these entries were produced against, so a later
215
- // resume can prove they are still fresh. Unreadable manifest ⇒ field omitted,
216
- // which reads as "cannot prove freshness" and simply denies reuse.
217
- //
218
- // FROZEN at the run's first write, deliberately: if a task installs a package
219
- // mid-run and we re-fingerprinted here, the new fingerprint would bless digests
220
- // taken BEFORE the install as current. Keeping the original means a mid-run
221
- // install makes a later resume mismatch and re-fetch — the safe direction.
222
- const deps = existing && existing.runId === runId ? existing.deps : await depsFingerprint(cwd);
223
- const out = deps === undefined ? { runId, entries } : { runId, entries, deps };
224
- await fsp.mkdir(tasksDir(cwd), { recursive: true });
225
- // Atomic-ish write so a concurrent reader never sees a half-written file.
226
- const tmp = `${researchCacheFile(cwd)}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
227
- await fsp.writeFile(tmp, JSON.stringify(out), 'utf8');
228
- await fsp.rename(tmp, researchCacheFile(cwd));
292
+ await writeCacheFile(cwd, { runId, entries, pkgv: PKG_PROVENANCE_VERSION });
229
293
  }
230
294
  catch {
231
295
  // best-effort cache
@@ -48,6 +48,14 @@ export interface WorkerToolSpec<TParams extends TSchema, TDetails> {
48
48
  * key is namespaced by tool name, so keys need only be unique within a tool.
49
49
  */
50
50
  cacheKey?(params: Static<TParams>): string | null;
51
+ /**
52
+ * The npm package this call's answer is ABOUT, for a package-scoped tool (docs).
53
+ * Recorded as structured provenance on the cache entry so a resume can drop just the
54
+ * entries whose package moved version, instead of the whole run's cache (run 14: a
55
+ * greenfield run adds packages every few tasks, so a whole-file gate never holds).
56
+ * Omit — or return undefined — and the entry survives every resume of its run.
57
+ */
58
+ cachePkg?(params: Static<TParams>): string | undefined;
51
59
  /**
52
60
  * Whether a produced result is safe to cache. Only a SUCCESS is memoised — an
53
61
  * error, empty, or aborted result must fall through so a transient failure never
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.38",
3
+ "version": "0.18.39",
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",