@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.
- package/dist/main.js +366 -366
- package/dist/pty-wrapper.mjs +105 -7
- package/dist/teardownReasons.mjs +100 -0
- package/package.json +2 -2
package/dist/pty-wrapper.mjs
CHANGED
|
@@ -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
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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)",
|