@dtlabs/ai-toolkit 0.17.2 → 0.17.4

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,21 @@
1
1
  ## 0.1.4 (2026-07-21)
2
+ ## [0.17.4](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.3...v0.17.4) (2026-10-13)
3
+
4
+
5
+ ### Bug Fixes
6
+
7
+ * **workflow-control:** repair unescaped control chars in recovered JSON ([ebeddb5](https://github.com/dtlab-tech/ai-toolkit/commit/ebeddb5465ac988f94bd28d20b7f9dfdd967c2c9))
8
+ * **workflow-control:** repair unescaped control chars in recovered JSON ([0ce2466](https://github.com/dtlab-tech/ai-toolkit/commit/0ce24666b6d4d09b25ca71b1ed3b9f0ba7c06d7a))
9
+
10
+ ## [0.17.3](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.2...v0.17.3) (2026-10-12)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **execution-ledger:** close PID-reuse gap in lock-staleness liveness check ([4a9a365](https://github.com/dtlab-tech/ai-toolkit/commit/4a9a3652bf8e7844accff46851b2e1286e24c0d7))
16
+ * **task-executor:** session-list clutter and PID-reuse lock-liveness gap ([e000a6f](https://github.com/dtlab-tech/ai-toolkit/commit/e000a6faa2e75eac5bb65069eb9b37b102a14cf8))
17
+ * **task-executor:** stop every dispatch from cluttering the Claude Code session list ([8676d66](https://github.com/dtlab-tech/ai-toolkit/commit/8676d66157bceeafb7fa225cabeb8a666dbac955))
18
+
2
19
  ## [0.17.2](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.1...v0.17.2) (2026-10-12)
3
20
 
4
21
 
@@ -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 || [];
@@ -300,6 +305,41 @@ function createControl(options) {
300
305
  return api;
301
306
  }
302
307
 
308
+ // A model asked to emit a JSON blob as part of a chat reply (rather than through a real
309
+ // serializer, i.e. whenever structured_output didn't get populated and extraction has to
310
+ // recover the payload from free text) is prone to one specific, well-understood defect: a
311
+ // long free-text field (a "report") gets its literal line breaks typed as real newlines
312
+ // instead of the required `\n` escape. The JSON otherwise reads as entirely correct to a
313
+ // human, but JSON.parse rejects it outright ("Bad control character in string literal") —
314
+ // confirmed against a real run where the model's JSON was discarded for exactly this reason
315
+ // despite carrying the correct ```json fence tag and schema-correct structure. This is a
316
+ // narrow, mechanical repair for that one defect, not a lenient parser: it tracks whether
317
+ // each character lies inside a double-quoted JSON string literal (honoring \" and \\
318
+ // escapes) and re-escapes a raw newline/CR/tab ONLY there, leaving structural whitespace
319
+ // between tokens untouched. Applied as a fallback after a plain JSON.parse fails, never in
320
+ // place of one, so already-valid JSON is never second-guessed.
321
+ function _repairUnescapedControlCharsInStrings(text) {
322
+ let result = '';
323
+ let inString = false;
324
+ let escaped = false;
325
+ for (let i = 0; i < text.length; i++) {
326
+ const ch = text[i];
327
+ if (inString) {
328
+ if (escaped) { result += ch; escaped = false; }
329
+ else if (ch === '\\') { result += ch; escaped = true; }
330
+ else if (ch === '"') { result += ch; inString = false; }
331
+ else if (ch === '\n') { result += '\\n'; }
332
+ else if (ch === '\r') { result += '\\r'; }
333
+ else if (ch === '\t') { result += '\\t'; }
334
+ else { result += ch; }
335
+ } else {
336
+ if (ch === '"') inString = true;
337
+ result += ch;
338
+ }
339
+ }
340
+ return result;
341
+ }
342
+
303
343
  // The model is not consistent about which delimiter it uses to mark its final structured
304
344
  // answer inside otherwise-free text — observed empirically, same agent and schema, run to
305
345
  // run: an <structured_output> tag, a ```json fence, and an unprompted <json> tag. Growing
@@ -327,8 +367,15 @@ function extractDelimitedStructuredOutput(text, schema) {
327
367
  const last = text.lastIndexOf('}');
328
368
  if (first !== -1 && last > first) candidates.push(text.slice(first, last + 1));
329
369
  for (const raw of candidates) {
370
+ const trimmed = raw.trim();
371
+ let parsed;
372
+ try {
373
+ parsed = JSON.parse(trimmed);
374
+ } catch (e) {
375
+ try { parsed = JSON.parse(_repairUnescapedControlCharsInStrings(trimmed)); }
376
+ catch (e2) { continue; }
377
+ }
330
378
  try {
331
- const parsed = JSON.parse(raw.trim());
332
379
  validateSchema(parsed, schema);
333
380
  return parsed;
334
381
  } catch (e) { /* try the next candidate, or fall through to null */ }
@@ -349,7 +396,18 @@ function extractDelimitedStructuredOutput(text, schema) {
349
396
  // remains the one thing that fails closed on it; this function only undoes wrapping, it is not
350
397
  // itself a validation gate.
351
398
  function extractJsonDocument(text) {
352
- const tryParse = candidate => { try { JSON.parse(candidate); return candidate; } catch (e) { return null; } };
399
+ // Same "try the candidate as-is, then try it with raw control characters inside
400
+ // string literals re-escaped" fallback as extractDelimitedStructuredOutput, and
401
+ // for the identical reason: a long free-text field (e.g. a task description)
402
+ // typed with literal line breaks instead of `\n` is otherwise indistinguishable
403
+ // from genuinely malformed JSON, even though it's one mechanical fix away from
404
+ // valid. Returns the (possibly repaired) STRING to write to disk, never the
405
+ // parsed object — this function's contract is text recovery, not parsing.
406
+ const tryParse = candidate => {
407
+ try { JSON.parse(candidate); return candidate; } catch (e) {}
408
+ const repaired = _repairUnescapedControlCharsInStrings(candidate);
409
+ try { JSON.parse(repaired); return repaired; } catch (e) { return null; }
410
+ };
353
411
  const whole = tryParse(text.trim());
354
412
  if (whole != null) return whole;
355
413
  const fenceMatches = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)];
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.4",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",