@mjasnikovs/pi-task 0.38.9 → 0.38.10
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.
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** The env var the orchestrator stamps with the per-run id children inherit. */
|
|
2
2
|
export declare const RESEARCH_RUN_ID_ENV = "PI_TASK_RUN_ID";
|
|
3
|
+
/** Is this a transient filesystem error worth retrying inside the caller's deadline? */
|
|
4
|
+
export declare function isRetryableFsError(err: unknown): boolean;
|
|
3
5
|
export declare function researchCacheFile(cwd: string): string;
|
|
4
6
|
/**
|
|
5
7
|
* The current run's id, or undefined when caching is off (the orchestrator did not
|
|
@@ -117,6 +117,12 @@ const LOCK_SUFFIX = '.lock';
|
|
|
117
117
|
const LOCK_TIMEOUT_MS = 2_000;
|
|
118
118
|
/** Poll interval while the lock is held by someone else. */
|
|
119
119
|
const LOCK_POLL_MS = 10;
|
|
120
|
+
/**
|
|
121
|
+
* How long the atomic replace retries a transient refusal. Short: the lock is HELD for
|
|
122
|
+
* every one of these milliseconds, so this trades a bounded stall for not losing a
|
|
123
|
+
* write the lock was taken to protect.
|
|
124
|
+
*/
|
|
125
|
+
const RENAME_TIMEOUT_MS = 500;
|
|
120
126
|
/**
|
|
121
127
|
* A lock older than this is treated as abandoned and removed. Two writers can both
|
|
122
128
|
* decide that and both proceed, which degrades exactly to the pre-lock behaviour (one
|
|
@@ -124,6 +130,32 @@ const LOCK_POLL_MS = 10;
|
|
|
124
130
|
* the run. Sized far above the critical section, so a live holder is never stolen from.
|
|
125
131
|
*/
|
|
126
132
|
const LOCK_STALE_MS = 30_000;
|
|
133
|
+
/**
|
|
134
|
+
* Filesystem errors that mean "try again in a moment", not "this will never work".
|
|
135
|
+
*
|
|
136
|
+
* POSIX gives `mkdir` two clean answers when someone else holds the lock: it succeeds,
|
|
137
|
+
* or it is EEXIST. Windows has a third. A directory that another process is removing
|
|
138
|
+
* enters a DELETE-PENDING state, and a create against it fails with EPERM/EACCES/EBUSY
|
|
139
|
+
* instead of EEXIST — so the exact moment the previous writer released the lock is a
|
|
140
|
+
* window in which the next writer's `mkdir` fails with a code that used to be read as
|
|
141
|
+
* fatal. The store was then silently skipped: no throw, no log, exit code 0, one entry
|
|
142
|
+
* missing. That is the whole of CI's `39 of 40` on windows-latest; the same run's
|
|
143
|
+
* ubuntu half is green because POSIX never produces the code.
|
|
144
|
+
*
|
|
145
|
+
* `rename` over an existing file has the same shape on Windows — it fails while any
|
|
146
|
+
* other handle is open on the target, including a scanner's — so the cache write
|
|
147
|
+
* retries on this set too.
|
|
148
|
+
*/
|
|
149
|
+
const RETRYABLE_FS_CODES = new Set(['EEXIST', 'EPERM', 'EACCES', 'EBUSY', 'ENOTEMPTY']);
|
|
150
|
+
/** Is this a transient filesystem error worth retrying inside the caller's deadline? */
|
|
151
|
+
export function isRetryableFsError(err) {
|
|
152
|
+
const code = err?.code;
|
|
153
|
+
return code !== undefined && RETRYABLE_FS_CODES.has(code);
|
|
154
|
+
}
|
|
155
|
+
/** Does this error mean the lock directory is genuinely held right now? */
|
|
156
|
+
function isHeldError(err) {
|
|
157
|
+
return err?.code === 'EEXIST';
|
|
158
|
+
}
|
|
127
159
|
/**
|
|
128
160
|
* Schema marker for per-entry package provenance. A file without it was written by a
|
|
129
161
|
* version that stored no `pkg` on its entries, so its docs entries are indistinguishable
|
|
@@ -307,15 +339,35 @@ export async function lookupResearch(cwd, runId, key) {
|
|
|
307
339
|
}
|
|
308
340
|
/** Write the cache file atomic-ish, so a concurrent reader never sees it half-written. */
|
|
309
341
|
async function writeCacheFile(cwd, out) {
|
|
342
|
+
const tmp = `${researchCacheFile(cwd)}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
310
343
|
try {
|
|
311
344
|
await fsp.mkdir(tasksDir(cwd), { recursive: true });
|
|
312
|
-
const tmp = `${researchCacheFile(cwd)}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
313
345
|
await fsp.writeFile(tmp, JSON.stringify(out), 'utf8');
|
|
314
|
-
|
|
346
|
+
// The replace, not the write, is the part Windows can transiently refuse — any
|
|
347
|
+
// other open handle on the target (a reader, a scanner) fails it with EPERM.
|
|
348
|
+
// The whole point of the lock above is that this write is not lost, so a
|
|
349
|
+
// transient refusal is retried inside the same bounded budget rather than
|
|
350
|
+
// swallowed. The lock is still held throughout.
|
|
351
|
+
const deadline = Date.now() + RENAME_TIMEOUT_MS;
|
|
352
|
+
for (;;) {
|
|
353
|
+
try {
|
|
354
|
+
await fsp.rename(tmp, researchCacheFile(cwd));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
if (!isRetryableFsError(err) || Date.now() >= deadline)
|
|
359
|
+
throw err;
|
|
360
|
+
await new Promise(resolve => setTimeout(resolve, LOCK_POLL_MS));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
315
363
|
}
|
|
316
364
|
catch {
|
|
317
365
|
// best-effort cache
|
|
318
366
|
}
|
|
367
|
+
finally {
|
|
368
|
+
// Never leave a .tmp behind for a replace that never happened.
|
|
369
|
+
await fsp.rm(tmp, { force: true }).catch(() => { });
|
|
370
|
+
}
|
|
319
371
|
}
|
|
320
372
|
/**
|
|
321
373
|
* Serialises this process's own writers per cache file, so the 4-6 parallel tool calls
|
|
@@ -337,8 +389,18 @@ async function acquireLock(lockPath, deadline) {
|
|
|
337
389
|
return true;
|
|
338
390
|
}
|
|
339
391
|
catch (err) {
|
|
340
|
-
if (err
|
|
392
|
+
if (!isRetryableFsError(err))
|
|
341
393
|
return false;
|
|
394
|
+
// Not EEXIST but still retryable ⇒ Windows delete-pending (see
|
|
395
|
+
// RETRYABLE_FS_CODES). There is no lock to inspect for staleness: the
|
|
396
|
+
// directory is on its way out, so wait one poll and try to create it again
|
|
397
|
+
// rather than reporting a hold that nobody has.
|
|
398
|
+
if (!isHeldError(err)) {
|
|
399
|
+
if (Date.now() >= deadline)
|
|
400
|
+
return false;
|
|
401
|
+
await new Promise(resolve => setTimeout(resolve, LOCK_POLL_MS));
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
342
404
|
try {
|
|
343
405
|
const st = await fsp.stat(lockPath);
|
|
344
406
|
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.10",
|
|
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",
|