@kelceyp/caw-server 1.0.278 → 1.0.280

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.
@@ -41,6 +41,7 @@ export function emitResult({
41
41
  effectiveSessionId = null,
42
42
  startedAt = null,
43
43
  endedAt = null,
44
+ teardownReason = null,
44
45
  alreadyEmitted = false,
45
46
  suppressFallback = false,
46
47
  write = defaultWrite,
@@ -67,6 +68,14 @@ export function emitResult({
67
68
  if (startedAt !== null && endedAt !== null) {
68
69
  metadata.durationMs = endedAt - startedAt;
69
70
  }
71
+ // As1033 Task 6: append teardownReason ('stop' | 'watchdog') so the
72
+ // downstream stream handler (Task 5 landed the capture) and the
73
+ // JobRunner classifier gate (Task 7) can distinguish a clean Stop
74
+ // finish from a watchdog force-kill. Field order of the four
75
+ // pre-existing fields is preserved byte-for-byte.
76
+ if (teardownReason !== null) {
77
+ metadata.teardownReason = teardownReason;
78
+ }
70
79
  // D11 test-only override: when the wrapper's CAW_PTY_SUPPRESS_STOP_FALLBACK
71
80
  // gate is set, blank the `result` field on the success branch only. The
72
81
  // wire is deliberately identical to a real empty-message turn — that IS
@@ -25,6 +25,7 @@ import { tmpdir, homedir } from 'os';
25
25
  import { execSync } from 'child_process';
26
26
  import { readSentinelPayload, hasPendingWork } from './pty-deferred-teardown.mjs';
27
27
  import { emitResult as emitResultLine } from './pty-emit-result.mjs';
28
+ import { TEARDOWN_REASONS } from './teardownReasons.mjs';
28
29
 
29
30
  // --- Arg parsing ---
30
31
 
@@ -370,6 +371,11 @@ const SUPPRESS_STOP_FALLBACK = process.env.CAW_PTY_SUPPRESS_STOP_FALLBACK === '1
370
371
 
371
372
  let watchdogTimer = null;
372
373
  let teardownInitiated = false;
374
+ // Captured at the top of teardown() on both paths (clean-Stop passes 'stop',
375
+ // watchdog callback passes 'watchdog'). Read by emitResult() on the success
376
+ // branch and written to metadata.teardownReason so the downstream
377
+ // stream-handler and classifier (Task 7) can distinguish the two paths.
378
+ let teardownReason = null;
373
379
 
374
380
  // Duration capture: wall clock from `pty.spawn` to top of `teardown()`.
375
381
  // `endedAt` is set by whichever teardown path fires first (normal SIGUSR1
@@ -381,24 +387,95 @@ let teardownInitiated = false;
381
387
  let startedAt = null;
382
388
  let endedAt = null;
383
389
 
390
+ // Module-scope armed timestamp for the watchdog observability channel (As1012
391
+ // Task 7). Written by `startOrResetWatchdog` on every call (initial arm and
392
+ // each subsequent reset) and read by both `teardown()` and the setTimeout
393
+ // expiry closure so `elapsedSinceArmMs` can be computed after the timer has
394
+ // released. On a `reset` call the elapsed value is captured BEFORE `armedAt`
395
+ // is overwritten so the emitted event carries the duration of the *previous*
396
+ // armed interval (see item 4's ordering invariant).
397
+ let armedAt = null;
398
+
399
+ // Module-scope snapshot of the sentinel counts observed at the most recent
400
+ // deferral, for the `WATCHDOG` observability channel (As1012 Task 13). Written
401
+ // in the SIGUSR1 handler's defer branch BEFORE `startOrResetWatchdog()` so the
402
+ // counts are in place if the watchdog subsequently fires; read from
403
+ // `teardown()` when `reason === TEARDOWN_REASONS.WATCHDOG`. Lifted for the
404
+ // same reason as `armedAt`: the SIGUSR1 handler's `bgCount` / `cronCount`
405
+ // locals are block-scoped and out of scope inside the `setTimeout` closure
406
+ // that eventually calls `teardown(TEARDOWN_REASONS.WATCHDOG)`. Overwritten on
407
+ // every deferral, so it always holds the LAST deferral's counts — that is the
408
+ // documented last-seen-snapshot semantic (design.md §Data on the wire).
409
+ let lastDeferredCounts = null;
410
+
384
411
  // Forward declarations (assigned after ptyProcess is created)
385
412
  let ptyProcess = null;
386
413
 
414
+ // --- Tagged event channel (As1012 Task 7) ---
415
+ // Structured lifecycle events on stderr: `[caw:pty:event] <json>\n`. Consumed
416
+ // downstream by JobRunner's `createStderrSplitter` (Task 6), which routes
417
+ // matching lines through the operational tier (`caw:ops:pty:info`). Prose
418
+ // stderr (every `log()` line above) is untouched — the splitter forwards
419
+ // non-tagged bytes byte-identically to today's `stderrChunks` collector.
420
+ // EPIPE is swallowed so a closed downstream pipe cannot crash the wrapper
421
+ // (stderr is diagnostic; the tagged channel is best-effort).
422
+ const EVENT_TAG = '[caw:pty:event] ';
423
+ const emitEvent = (type, fields = {}) => {
424
+ const payload = {
425
+ type,
426
+ wrapperPid: process.pid,
427
+ claudePid: ptyProcess?.pid,
428
+ agentSessionId: effectiveSessionId,
429
+ ...fields
430
+ };
431
+ try {
432
+ process.stderr.write(`${EVENT_TAG}${JSON.stringify(payload)}\n`);
433
+ } catch (err) {
434
+ if (err && err.code !== 'EPIPE') throw err;
435
+ }
436
+ };
437
+
387
438
  const startOrResetWatchdog = () => {
388
- if (watchdogTimer) clearTimeout(watchdogTimer);
389
- log(`Watchdog ${watchdogTimer ? 'reset' : 'armed'}: ${WATCHDOG_TIMEOUT_MS / 60000}m timeout`);
439
+ const wasArmed = watchdogTimer !== null;
440
+ if (wasArmed) clearTimeout(watchdogTimer);
441
+ log(`Watchdog ${wasArmed ? 'reset' : 'armed'}: ${WATCHDOG_TIMEOUT_MS / 60000}m timeout`);
442
+ // Ordering invariant (item 4): capture elapsedSinceArmMs BEFORE `armedAt`
443
+ // is overwritten so the `reset` event carries the duration of the previous
444
+ // armed interval. `armed` (first call) carries no elapsed value.
445
+ if (wasArmed) {
446
+ emitEvent('reset', { elapsedSinceArmMs: Date.now() - armedAt });
447
+ } else {
448
+ emitEvent('armed');
449
+ }
450
+ armedAt = Date.now();
390
451
  watchdogTimer = setTimeout(() => {
391
- log(`Watchdog expired after ${WATCHDOG_TIMEOUT_MS / 60000}m with no progress, force-killing claude`);
392
- teardown();
452
+ log(`Watchdog expired: ${WATCHDOG_TIMEOUT_MS / 60000}m elapsed since last deferred Stop, force-killing claude`);
453
+ emitEvent('expired', { elapsedSinceArmMs: Date.now() - armedAt });
454
+ teardown(TEARDOWN_REASONS.WATCHDOG);
393
455
  }, WATCHDOG_TIMEOUT_MS);
394
456
  };
395
457
 
396
- const teardown = () => {
458
+ const teardown = (reason = TEARDOWN_REASONS.STOP) => {
397
459
  if (teardownInitiated) return;
398
460
  teardownInitiated = true;
399
461
  endedAt = Date.now();
400
- log('Teardown: stop received, no pending work, killing claude');
462
+ teardownReason = reason;
463
+ log(`Teardown (reason=${reason}): killing claude`);
401
464
  log(`Duration: ${startedAt !== null ? (endedAt - startedAt) : 'unknown'} ms (startedAt=${startedAt}, endedAt=${endedAt})`);
465
+ // As1012 Task 13 — `pendingBgCount` / `pendingCronCount` ride only on the
466
+ // `reason=watchdog` teardown, using the same conditional-spread idiom as
467
+ // `elapsedSinceArmMs`. No null-guard is needed: `startOrResetWatchdog()`
468
+ // has exactly one call site (the defer branch below), and that branch
469
+ // always writes `lastDeferredCounts` BEFORE arming the timer — so a
470
+ // `reason=watchdog` teardown is only reachable after at least one
471
+ // deferral, and at least one of the two counts is guaranteed ≥ 1.
472
+ emitEvent('teardown', {
473
+ reason,
474
+ ...(armedAt !== null ? { elapsedSinceArmMs: Date.now() - armedAt } : {}),
475
+ ...(reason === TEARDOWN_REASONS.WATCHDOG && lastDeferredCounts !== null
476
+ ? { pendingBgCount: lastDeferredCounts.bgCount, pendingCronCount: lastDeferredCounts.cronCount }
477
+ : {})
478
+ });
402
479
  if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
403
480
  stopPolling();
404
481
  if (ptyProcess) ptyProcess.kill('SIGINT');
@@ -413,12 +490,12 @@ process.on('SIGUSR1', () => {
413
490
  const payload = readSentinelPayload(sentinelPath);
414
491
  if (!payload) {
415
492
  log(`Warning: failed to read sentinel, proceeding with teardown`);
416
- teardown();
493
+ teardown(TEARDOWN_REASONS.SENTINEL_UNREADABLE);
417
494
  return;
418
495
  }
419
496
  if (payload.error) {
420
497
  log(`StopFailure received: error=${payload.error}`);
421
- teardown();
498
+ teardown(TEARDOWN_REASONS.STOP_FAILURE);
422
499
  return;
423
500
  }
424
501
  const bgCount = Array.isArray(payload.background_tasks) ? payload.background_tasks.length : 0;
@@ -426,10 +503,14 @@ process.on('SIGUSR1', () => {
426
503
  log(`Sentinel: background_tasks=${bgCount}, session_crons=${cronCount}`);
427
504
  if (hasPendingWork(payload)) {
428
505
  log('Deferring teardown: pending background work');
506
+ // As1012 Task 13 — capture BEFORE arming so the counts are already in
507
+ // place if the timer subsequently fires. Members named to match the
508
+ // locals above; the lift is a zero-translation copy.
509
+ lastDeferredCounts = { bgCount, cronCount };
429
510
  startOrResetWatchdog();
430
511
  return;
431
512
  }
432
- teardown();
513
+ teardown(TEARDOWN_REASONS.STOP);
433
514
  });
434
515
 
435
516
  writeFileSync(wrapperPidPath, String(process.pid));
@@ -654,6 +735,7 @@ const emitResult = (hookData) => {
654
735
  effectiveSessionId,
655
736
  startedAt,
656
737
  endedAt,
738
+ teardownReason,
657
739
  alreadyEmitted: resultEmitted || failureEmitted,
658
740
  suppressFallback: SUPPRESS_STOP_FALLBACK,
659
741
  log
@@ -733,6 +815,10 @@ ptyProcess.onExit(({ exitCode }) => {
733
815
  // teardown() never ran on this path (Stop hook did not fire), so `endedAt` is
734
816
  // unset and `durationMs` is deliberately omitted from the emission — the
735
817
  // service appends the pre-existing `'0'` duration sentinel (Decision D3).
818
+ // As1063 Task 1 item 9: track whether this branch was the one that set
819
+ // failureEmitted so the onExit-branch diagnostic below can distinguish
820
+ // `matched-prompt-too-long` from `emitted-failure` (both set the flag).
821
+ let matchedPromptTooLong = false;
736
822
  if (!resultEmitted && !failureEmitted && ttyBuffer.includes('Prompt is too long')) {
737
823
  log('Prompt-too-long: omitting durationMs (teardown() never ran, endedAt unset)');
738
824
  process.stdout.write(JSON.stringify({
@@ -741,11 +827,30 @@ ptyProcess.onExit(({ exitCode }) => {
741
827
  message: 'Prompt is too long'
742
828
  }) + '\n');
743
829
  failureEmitted = true;
830
+ matchedPromptTooLong = true;
744
831
  }
745
832
 
746
833
  // Clean up temp directory
747
834
  try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
748
835
 
836
+ // As1063 Task 1 item 9: emit which onExit branch was taken along with
837
+ // the wrapper exit reason (teardownReason) and a bounded ttyBuffer tail.
838
+ // Bounded per lifecycle event by the `exited` guard at the top of onExit
839
+ // (no unbounded per-turn accumulation). Written to stderr via log() so
840
+ // JobRunner's stderr splitter captures it into the outputTail without
841
+ // affecting the wrapper's stdout emission contract.
842
+ const branchTaken = resultEmitted
843
+ ? 'emitted-result'
844
+ : matchedPromptTooLong
845
+ ? 'matched-prompt-too-long'
846
+ : failureEmitted
847
+ ? 'emitted-failure'
848
+ : 'no-emission';
849
+ // Cap the tty tail to 256 chars and escape newlines so the line stays on
850
+ // one log entry regardless of what claude last wrote.
851
+ const ttyTail = ttyBuffer.slice(-256).replace(/\n/g, '\\n').replace(/\r/g, '\\r');
852
+ log(`onExit-branch: ${branchTaken} exitCode=${exitCode} teardownReason=${teardownReason ?? 'unset'} ttyTail="${ttyTail}"`);
853
+
749
854
  log(`Claude exited (code: ${exitCode})`);
750
855
 
751
856
  if (failureEmitted) {