@kelceyp/caw-server 1.0.279 → 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.
@@ -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
 
@@ -386,25 +387,95 @@ let teardownReason = null;
386
387
  let startedAt = null;
387
388
  let endedAt = null;
388
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
+
389
411
  // Forward declarations (assigned after ptyProcess is created)
390
412
  let ptyProcess = null;
391
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
+
392
438
  const startOrResetWatchdog = () => {
393
- if (watchdogTimer) clearTimeout(watchdogTimer);
394
- 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();
395
451
  watchdogTimer = setTimeout(() => {
396
452
  log(`Watchdog expired: ${WATCHDOG_TIMEOUT_MS / 60000}m elapsed since last deferred Stop, force-killing claude`);
397
- teardown('watchdog');
453
+ emitEvent('expired', { elapsedSinceArmMs: Date.now() - armedAt });
454
+ teardown(TEARDOWN_REASONS.WATCHDOG);
398
455
  }, WATCHDOG_TIMEOUT_MS);
399
456
  };
400
457
 
401
- const teardown = (reason = 'stop') => {
458
+ const teardown = (reason = TEARDOWN_REASONS.STOP) => {
402
459
  if (teardownInitiated) return;
403
460
  teardownInitiated = true;
404
461
  endedAt = Date.now();
405
462
  teardownReason = reason;
406
463
  log(`Teardown (reason=${reason}): killing claude`);
407
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
+ });
408
479
  if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
409
480
  stopPolling();
410
481
  if (ptyProcess) ptyProcess.kill('SIGINT');
@@ -419,12 +490,12 @@ process.on('SIGUSR1', () => {
419
490
  const payload = readSentinelPayload(sentinelPath);
420
491
  if (!payload) {
421
492
  log(`Warning: failed to read sentinel, proceeding with teardown`);
422
- teardown();
493
+ teardown(TEARDOWN_REASONS.SENTINEL_UNREADABLE);
423
494
  return;
424
495
  }
425
496
  if (payload.error) {
426
497
  log(`StopFailure received: error=${payload.error}`);
427
- teardown();
498
+ teardown(TEARDOWN_REASONS.STOP_FAILURE);
428
499
  return;
429
500
  }
430
501
  const bgCount = Array.isArray(payload.background_tasks) ? payload.background_tasks.length : 0;
@@ -432,10 +503,14 @@ process.on('SIGUSR1', () => {
432
503
  log(`Sentinel: background_tasks=${bgCount}, session_crons=${cronCount}`);
433
504
  if (hasPendingWork(payload)) {
434
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 };
435
510
  startOrResetWatchdog();
436
511
  return;
437
512
  }
438
- teardown();
513
+ teardown(TEARDOWN_REASONS.STOP);
439
514
  });
440
515
 
441
516
  writeFileSync(wrapperPidPath, String(process.pid));
@@ -740,6 +815,10 @@ ptyProcess.onExit(({ exitCode }) => {
740
815
  // teardown() never ran on this path (Stop hook did not fire), so `endedAt` is
741
816
  // unset and `durationMs` is deliberately omitted from the emission — the
742
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;
743
822
  if (!resultEmitted && !failureEmitted && ttyBuffer.includes('Prompt is too long')) {
744
823
  log('Prompt-too-long: omitting durationMs (teardown() never ran, endedAt unset)');
745
824
  process.stdout.write(JSON.stringify({
@@ -748,11 +827,30 @@ ptyProcess.onExit(({ exitCode }) => {
748
827
  message: 'Prompt is too long'
749
828
  }) + '\n');
750
829
  failureEmitted = true;
830
+ matchedPromptTooLong = true;
751
831
  }
752
832
 
753
833
  // Clean up temp directory
754
834
  try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
755
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
+
756
854
  log(`Claude exited (code: ${exitCode})`);
757
855
 
758
856
  if (failureEmitted) {
@@ -0,0 +1,100 @@
1
+ /**
2
+ * teardownReasons.mjs — closed enum for the pty-wrapper's `teardown()` reason,
3
+ * shared between the observability path (As1012) and the classification path
4
+ * (As1033).
5
+ *
6
+ * Authored under As1012 as part of the S2 workshop's Q4 outcome — imported by
7
+ * `pty-wrapper.mjs` (emit site) and by `JobRunner.js`'s watchdog gate + the
8
+ * stream-handler metadata plumbing on the As1033 side.
9
+ *
10
+ * Non-forced-path contract (joint As1012 × As1033)
11
+ * ------------------------------------------------
12
+ *
13
+ * 1. `teardown()` runs on every graceful exit path. Four call sites; four
14
+ * enum values: `WATCHDOG` (expiry), `SENTINEL_UNREADABLE`, `STOP_FAILURE`,
15
+ * `STOP` (normal completion via the Stop hook). Normal completion is NOT a
16
+ * no-teardown case — it is `teardown('stop')`. **`STOP` is a real value.**
17
+ *
18
+ * 2. `emitResult` always writes `metadata.teardownReason` when it runs, with
19
+ * a value from this enum. The field is NOT optional on the graceful path —
20
+ * consumers read it directly without an `undefined` check.
21
+ *
22
+ * 3. **`metadata.teardownReason` is omitted (not `null`) whenever the pty
23
+ * child exits without `teardown()` firing.** Absence is the signal — the
24
+ * enum does not carry a *no-teardown* value. Four non-teardown exit
25
+ * paths, listed exhaustively so a future debugger does not misread the
26
+ * gap:
27
+ *
28
+ * a. **Child self-exit** — `claude` returns of its own accord before
29
+ * the Stop hook fires (rare; e.g. an unhandled internal error).
30
+ * `onExit` runs with a non-zero code; `emitResult` runs; the field
31
+ * is omitted because `teardown()` never called `emitResult` with a
32
+ * reason. Classifier reads a distinct exit class from `exitCode`.
33
+ * b. **External signal to the wrapper** (SIGTERM/SIGINT delivered
34
+ * externally, not from the wrapper's own deadline path). Wrapper
35
+ * may or may not manage to emit anything depending on the signal
36
+ * and its handler set at the moment.
37
+ * c. **Prompt-too-long emission** — the wrapper's stdout emits its
38
+ * result branch directly without routing through `teardown()`
39
+ * (`pty-wrapper.mjs:733` and its neighbours; see
40
+ * Easy-to-Miss #5 in As1012's design). Field omitted.
41
+ * d. **As1006's F2 SIGKILL** — the wrapper-self-terminate path with a
42
+ * process-group SIGKILL of the PTY child under the wrapper's own
43
+ * deadline. SIGKILL is unmaskable; the wrapper is dead before it
44
+ * can emit; the entire `type:"result"` JSONL line is absent.
45
+ *
46
+ * **`As1006's F2 SIGKILL path is an OUT-OF-ENUM EXIT PATH`, as are the
47
+ * other three above.** Do not read this enum as enumerating every wrapper
48
+ * exit. An F2-killed job leaves NO wrapper-side breadcrumb via this
49
+ * enum — that gap is by design, not by omission. As1033's dissent from
50
+ * workshop round 3 is honoured by this note.
51
+ *
52
+ * The classifier's contract for any of the four non-teardown cases:
53
+ * `turnSignals.teardownReason === undefined` is a **distinct exit
54
+ * class** — spawn failure / SIGKILL / crash / prompt-too-long / external
55
+ * signal — not a teardown. Classification gate ordering (As1033):
56
+ * `WATCHDOG` gate first (positive match on `reason === WATCHDOG`); other
57
+ * reasons fall through unchanged; missing reason routes to the
58
+ * spawn-failure / no-result branch.
59
+ *
60
+ * 4. Lifecycle events (`armed`, `reset`, `expired`, `teardown`) share the
61
+ * enum only via the `teardown` event's `reason` field. `armed` / `reset`
62
+ * / `expired` observability events carry `type`, not `reason` — no enum
63
+ * value on those.
64
+ *
65
+ * 5. **The enum is closed at story-close.** Adding a value requires a
66
+ * coordinated change to both this module AND the classification gate in
67
+ * `JobRunner.js`. This is a maintenance rule, not a note — treat the set
68
+ * below as fixed.
69
+ *
70
+ * Consumers
71
+ * ---------
72
+ *
73
+ * - `pty-wrapper.mjs` — emit sites: `teardown(reason)` calls at the watchdog
74
+ * expiry closure (`WATCHDOG`), sentinel-read failure (`SENTINEL_UNREADABLE`),
75
+ * StopFailure hook (`STOP_FAILURE`), clean no-pending-work path (`STOP`).
76
+ * - `pty-emit-result.mjs` — writes `metadata.teardownReason` on the result
77
+ * JSONL line (As1033).
78
+ * - `claude-session.js` — `getMetadata()` propagates the value into
79
+ * `turnSignals.teardownReason` (As1033).
80
+ * - `JobRunner.js` — watchdog classification gate reads
81
+ * `turnSignals.teardownReason` and compares against `TEARDOWN_REASONS.WATCHDOG`
82
+ * (As1033).
83
+ * - `Log.js` observability — the `WATCHDOG event=teardown reason=<value>`
84
+ * line format (As1012) references the enum values verbatim.
85
+ *
86
+ * Packaging
87
+ * ---------
88
+ *
89
+ * Copied verbatim into `dist/` by `server/package.json`'s `build:all` script,
90
+ * alongside `pty-wrapper.mjs`, `pty-emit-result.mjs`, and
91
+ * `pty-deferred-teardown.mjs`. Not bundled — the wrapper is executed as a
92
+ * standalone node process and imports this module by relative path.
93
+ */
94
+
95
+ export const TEARDOWN_REASONS = Object.freeze({
96
+ WATCHDOG: 'watchdog',
97
+ STOP: 'stop',
98
+ SENTINEL_UNREADABLE: 'sentinel-unreadable',
99
+ STOP_FAILURE: 'stop-failure',
100
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kelceyp/caw-server",
3
- "version": "1.0.279",
3
+ "version": "1.0.280",
4
4
  "description": "CAW server - REST API, MCP server and core features",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -19,7 +19,7 @@
19
19
  "build": "bash scripts/bun-build.sh",
20
20
  "build:client": "cd ../client && bun run build",
21
21
  "build:cli": "cd ../cli && bun run build && cp dist/cli.js ../server/dist/",
22
- "build:all": "bun run clean && bun run generate:applyPojo && bun run build:client && bun run build && cp src/core/services/embedding-worker.js dist/embedding-worker.js && cp src/core/agents/pty-wrapper.mjs dist/pty-wrapper.mjs && cp src/core/agents/pty-deferred-teardown.mjs dist/pty-deferred-teardown.mjs && cp src/core/agents/pty-emit-result.mjs dist/pty-emit-result.mjs && bun run build:cli",
22
+ "build:all": "bun run clean && bun run generate:applyPojo && bun run build:client && bun run build && cp src/core/services/embedding-worker.js dist/embedding-worker.js && cp src/core/agents/pty-wrapper.mjs dist/pty-wrapper.mjs && cp src/core/agents/pty-deferred-teardown.mjs dist/pty-deferred-teardown.mjs && cp src/core/agents/pty-emit-result.mjs dist/pty-emit-result.mjs && cp src/core/agents/teardownReasons.mjs dist/teardownReasons.mjs && bun run build:cli",
23
23
  "dev": "STATIC_PATH=../client/src bun --define __BUILTIN_POSTHOG_KEY__=null run src/main.js",
24
24
  "start": "node dist/main.js",
25
25
  "test": "bun test --define __BUILTIN_POSTHOG_KEY__=null test/unit test/integration test/component test/cli test/investigation && (cd ../client && bun test)",