@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.
- package/dist/main.js +369 -367
- package/dist/pty-emit-result.mjs +9 -0
- package/dist/pty-wrapper.mjs +114 -9
- package/dist/public_html/main.js +229 -229
- package/dist/teardownReasons.mjs +100 -0
- package/package.json +2 -2
package/dist/pty-emit-result.mjs
CHANGED
|
@@ -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
|
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
|
|
|
@@ -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
|
-
|
|
389
|
-
|
|
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
|
|
392
|
-
|
|
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
|
-
|
|
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) {
|