@dtlabs/ai-toolkit 0.17.2 → 0.17.3

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/CHANGELOG.md CHANGED
@@ -1,4 +1,13 @@
1
1
  ## 0.1.4 (2026-07-21)
2
+ ## [0.17.3](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.2...v0.17.3) (2026-10-12)
3
+
4
+
5
+ ### Bug Fixes
6
+
7
+ * **execution-ledger:** close PID-reuse gap in lock-staleness liveness check ([4a9a365](https://github.com/dtlab-tech/ai-toolkit/commit/4a9a3652bf8e7844accff46851b2e1286e24c0d7))
8
+ * **task-executor:** session-list clutter and PID-reuse lock-liveness gap ([e000a6f](https://github.com/dtlab-tech/ai-toolkit/commit/e000a6faa2e75eac5bb65069eb9b37b102a14cf8))
9
+ * **task-executor:** stop every dispatch from cluttering the Claude Code session list ([8676d66](https://github.com/dtlab-tech/ai-toolkit/commit/8676d66157bceeafb7fa225cabeb8a666dbac955))
10
+
2
11
  ## [0.17.2](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.1...v0.17.2) (2026-10-12)
3
12
 
4
13
 
@@ -4,6 +4,10 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
  const crypto = require('crypto');
7
+ // Namespaced (not destructured) so tests can jest.spyOn(ownership, '_queryProcessStartTimeSync')
8
+ // — a destructured reference would capture the original function at require
9
+ // time and bypass any later spy on the module's own export.
10
+ const ownership = require('./task-executor/ownership');
7
11
 
8
12
  // ── Internal I/O helpers (stubs — implemented by INFRA-TASK-BE-03) ──────────
9
13
 
@@ -131,6 +135,78 @@ function _sleepSync(ms) {
131
135
  while (Date.now() < end) { /* busy-wait for synchronous cross-process backoff */ }
132
136
  }
133
137
 
138
+ // This process's own OS-level start time, probed via ownership.js's
139
+ // host:pid:startTime technique exactly ONCE per process lifetime (not once
140
+ // per lock acquisition) and cached forever after — a process's own start
141
+ // time never changes. Recording this in every lock token we write is what
142
+ // later lets a CONTENDED acquisition attempt tell "the recorded owner is
143
+ // still running" apart from "a recycled pid now belongs to someone else"
144
+ // (see _confirmSameOwnerProcess). `undefined` means not yet probed;
145
+ // `null` means the probe was inconclusive (non-Windows, or it failed) —
146
+ // callers must treat that the same as "no evidence either way", never as
147
+ // proof of death.
148
+ let _selfProcessStartTime;
149
+ function _getSelfProcessStartTime() {
150
+ if (_selfProcessStartTime === undefined) {
151
+ const probe = ownership._queryProcessStartTimeSync(process.pid);
152
+ _selfProcessStartTime = (probe && probe.found) ? probe.startTime : null;
153
+ }
154
+ return _selfProcessStartTime;
155
+ }
156
+
157
+ // Per-nonce verdict cache for the PID-reuse check below. A lock's nonce is
158
+ // unique per acquisition (crypto.randomBytes at write time), so a verdict
159
+ // for a given nonce never goes stale: either the recorded owner is
160
+ // confirmed still the same OS process (forever, until it exits), or it was
161
+ // already confirmed to be a different process that reused the pid
162
+ // (forever — that fact doesn't change either). Caching here is what keeps
163
+ // the expensive OS probe to once per contended lock episode instead of once
164
+ // per ~100ms poll in _acquireLock's retry loop. Bounded defensively; a
165
+ // single process is not expected to ever accumulate more than a handful of
166
+ // distinct contended nonces in practice.
167
+ const _ownerProcessVerdictCache = new Map();
168
+
169
+ // Resolves whether `token.pid` is still the SAME OS process that wrote this
170
+ // lock, using the host:pid:startTime identity technique (same one
171
+ // ownership.js already uses for task leases) — `process.kill(pid, 0)` alone
172
+ // cannot tell a still-alive original owner apart from an unrelated process
173
+ // that reused a recycled pid after the owner crashed.
174
+ //
175
+ // Returns:
176
+ // true — confirmed same process (startTime probes match)
177
+ // false — confirmed a DIFFERENT process now holds this pid (mismatch)
178
+ // null — inconclusive (non-Windows, probe failed, or token predates this
179
+ // field) — caller must fall back to trusting process.kill() alone,
180
+ // exactly like before this check existed; never treated as death.
181
+ function _confirmSameOwnerProcess(token) {
182
+ if (typeof token.processStartTime !== 'string' || !token.nonce) return null;
183
+
184
+ const cached = _ownerProcessVerdictCache.get(token.nonce);
185
+ if (cached !== undefined) return cached;
186
+
187
+ const probe = ownership._queryProcessStartTimeSync(token.pid);
188
+ // Inconclusive probe (non-Windows, or the query failed) or the OS
189
+ // confirms no such pid despite process.kill() saying alive (a narrow
190
+ // race): do not cache — there is nothing stable to remember, and a
191
+ // later retry might get a conclusive answer.
192
+ if (!probe) return null;
193
+ if (!probe.found) return null;
194
+
195
+ const verdict = probe.startTime === token.processStartTime;
196
+ if (_ownerProcessVerdictCache.size > 500) _ownerProcessVerdictCache.clear();
197
+ _ownerProcessVerdictCache.set(token.nonce, verdict);
198
+ return verdict;
199
+ }
200
+
201
+ // Test-only reset: both caches are intentionally process-lifetime-scoped in
202
+ // production (see their own comments for why), which would otherwise leak
203
+ // a mocked _queryProcessStartTimeSync's behavior across unrelated test cases
204
+ // sharing the same Jest worker process.
205
+ function _resetOwnerProcessCaches() {
206
+ _selfProcessStartTime = undefined;
207
+ _ownerProcessVerdictCache.clear();
208
+ }
209
+
134
210
  function _acquireLock(lockPath, opts) {
135
211
  const deadlineMs = (opts && opts.deadlineMs != null) ? opts.deadlineMs : 5000;
136
212
  const retryIntervalMs = (opts && opts.retryIntervalMs != null) ? opts.retryIntervalMs : 100;
@@ -142,7 +218,12 @@ function _acquireLock(lockPath, opts) {
142
218
  fd = fs.openSync(lockPath, 'wx');
143
219
  const nonce = crypto.randomBytes(16).toString('hex');
144
220
  const startedAt = new Date().toISOString();
145
- const ownerToken = { pid: process.pid, startedAt, nonce };
221
+ // processStartTime: this process's own OS start time (cached once per
222
+ // process lifetime, see _getSelfProcessStartTime) — null on non-Windows
223
+ // or when the probe is unavailable, matching ownership.js's own
224
+ // null-means-inconclusive convention. Lets a later contended acquirer
225
+ // tell "still us" apart from "a recycled pid, now someone else".
226
+ const ownerToken = { pid: process.pid, startedAt, nonce, processStartTime: _getSelfProcessStartTime() };
146
227
  fs.writeSync(fd, JSON.stringify(ownerToken));
147
228
  fs.fsyncSync(fd);
148
229
  fs.closeSync(fd);
@@ -270,7 +351,15 @@ function _isLockStale(content, stat) {
270
351
  }
271
352
  }
272
353
 
273
- if (ownerAlive) {
354
+ // process.kill(pid, 0) alone cannot tell a still-alive original owner
355
+ // apart from an unrelated process that reused a recycled pid after the
356
+ // owner crashed. _confirmSameOwnerProcess returns false only when it has
357
+ // positive OS evidence that today's occupant of this pid started at a
358
+ // different time than the token recorded — a confirmed PID reuse, not a
359
+ // guess. true or null (inconclusive) both fall through to the original
360
+ // "trust process.kill()" behavior, so nothing regresses on non-Windows
361
+ // or for locks written before this field existed.
362
+ if (ownerAlive && _confirmSameOwnerProcess(token) !== false) {
274
363
  return 'live';
275
364
  }
276
365
 
@@ -827,6 +916,9 @@ module.exports = {
827
916
  _acquireLock,
828
917
  _releaseLock,
829
918
  _isLockStale,
919
+ _confirmSameOwnerProcess,
920
+ _getSelfProcessStartTime,
921
+ _resetOwnerProcessCaches,
830
922
  _validateMetadata,
831
923
  _extractStoredMetadata,
832
924
  _metadataEqual,
@@ -600,9 +600,16 @@ async function dispatchTaskAttempt(args) {
600
600
 
601
601
  // 4. Only now: dispatch the verified agent.
602
602
  const prompt = typeof args.prompt === 'string' ? args.prompt : _buildTaskPrompt(task);
603
+ // --no-session-persistence: this dispatch is a one-shot --print invocation
604
+ // that is never resumed (no code path in this codebase passes --resume /
605
+ // --continue / --session-id for a task-executor spawn; replan/resume
606
+ // re-dispatch with a fresh prompt, never reattach to a prior Claude
607
+ // conversation), so a saved, resumable session transcript has no purpose
608
+ // here — only cost: every dispatch would otherwise accumulate as its own
609
+ // entry in the user's Claude Code session list.
603
610
  const baseSpawnArgs = Array.isArray(args.spawnArgs)
604
611
  ? args.spawnArgs
605
- : ['--print', '--output-format', 'json', '--agent', identity.nativeName].concat(EXECUTOR_PERMISSION_BYPASS_ARGS);
612
+ : ['--print', '--output-format', 'json', '--agent', identity.nativeName, '--no-session-persistence'].concat(EXECUTOR_PERMISSION_BYPASS_ARGS);
606
613
  // Append the same processIdentity tag already persisted above as one extra
607
614
  // trailing argv element, so it appears verbatim in the spawned process's
608
615
  // own command line — this is what makes _findLiveTaggedProcess's later OS
@@ -905,7 +912,7 @@ async function runReview(args) {
905
912
  const prompt = _buildReviewPrompt(task, diff);
906
913
  const spawnArgs = Array.isArray(args.spawnArgs)
907
914
  ? args.spawnArgs
908
- : ['--print', '--output-format', 'json', '--agent', identity.nativeName].concat(EXECUTOR_PERMISSION_BYPASS_ARGS);
915
+ : ['--print', '--output-format', 'json', '--agent', identity.nativeName, '--no-session-persistence'].concat(EXECUTOR_PERMISSION_BYPASS_ARGS);
909
916
  const spawnResult = await claudeProcess.spawnClaudeAgent({
910
917
  claudePath: args.claudePath,
911
918
  args: spawnArgs,
@@ -587,4 +587,10 @@ module.exports = {
587
587
  reclaimLease,
588
588
  checkOwnerLiveness,
589
589
  createOwnershipGuard,
590
+ // Exported for execution-ledger.js's lock-staleness check (US-xx liveness
591
+ // parity): the same host:pid:startTime identity technique proven here is
592
+ // the only reliable defense against a recycled PID being misread as the
593
+ // original lock owner. See execution-ledger.js's own comment at its call
594
+ // site for why this is only invoked lazily/cached there, never per-poll.
595
+ _queryProcessStartTimeSync,
590
596
  };
@@ -139,7 +139,12 @@ function createControl(options) {
139
139
  const identity = await resolve(id);
140
140
  const definition = definitionFields(read(identity.path));
141
141
  return activity(opts.label || id, async () => {
142
- const cliArgs = ['--print', '--output-format', 'json', '--agent', identity.nativeName];
142
+ // --no-session-persistence: this worker call is a one-shot --print
143
+ // invocation that is never resumed, so a saved session transcript has
144
+ // no purpose — only cost, accumulating as a stray entry in the user's
145
+ // Claude Code session list (see lib/task-executor/index.js's dispatch
146
+ // call sites for the same reasoning).
147
+ const cliArgs = ['--print', '--output-format', 'json', '--agent', identity.nativeName, '--no-session-persistence'];
143
148
  if (opts.schema) cliArgs.push('--json-schema', JSON.stringify(opts.schema));
144
149
  if (options.workerMaxBudgetUsd != null) cliArgs.push('--max-budget-usd', String(options.workerMaxBudgetUsd));
145
150
  const outputs = opts.outputs || [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.17.2",
3
+ "version": "0.17.3",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",