@oar-sh/oar 0.9.4 → 0.9.6
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/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
- package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
- package/CHANGELOG.md +199 -0
- package/README.md +35 -6
- package/package.json +2 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +411 -19
- package/server/claude-worker/claude-session-worker.mjs +5 -0
- package/server/claude-worker/claude-turn-failure.mjs +92 -0
- package/server/claude-worker/claude-turn-publisher.mjs +52 -3
- package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
- package/server/copilot-worker/copilot-model-switch.mjs +29 -3
- package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
- package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
- package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +7 -0
- package/server/grok-worker/acp-client.mjs +68 -12
- package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
- package/server/grok-worker/grok-session-worker.mjs +5 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- package/server/public/app/api-client.js +17 -2
- package/server/public/app/bootstrap.js +3 -0
- package/server/public/app/composer-control-state.mjs +4 -3
- package/server/public/app/conversation-view.js +97 -4
- package/server/public/app/journal-view.js +6 -0
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/relay-board-inline.mjs +70 -0
- package/server/public/app/relay-board-view.js +157 -61
- package/server/public/app/relay-error-ctas.mjs +12 -3
- package/server/public/app/socket-handlers.js +9 -0
- package/server/public/app/steered-turn-merge.mjs +10 -1
- package/server/public/app/store.js +4 -1
- package/server/public/app/subagent-fold.mjs +61 -0
- package/server/public/app/usage-limit-ui.js +225 -0
- package/server/public/index.html +97 -4
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +30 -0
- package/server/routes/messages-routes.mjs +93 -17
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/sessions-routes.mjs +40 -2
- package/server/server-runtime.mjs +33 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +45 -20
- package/server/services/remote-relay-dispatcher.mjs +328 -21
- package/server/services/remote-relay-inbound.mjs +35 -0
- package/server/services/session-worker-launch-service.mjs +17 -2
- package/server/services/session-worker-process-service.mjs +85 -32
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-supervisor-service.mjs +33 -1
- package/server/services/usage-limit-pause-service.mjs +441 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-usage-limit.mjs +257 -0
- package/shared/control-poller.mjs +69 -20
- package/shared/failure-note-text.mjs +38 -0
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +33 -2
- package/shared/steer-settle-failure.mjs +22 -0
- package/shared/steer-settle-markers.mjs +24 -0
- package/shared/worker-runtime/process-tree.mjs +483 -0
- package/shared/worker-runtime/relay-errors.mjs +31 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
- package/shared/worker-runtime/turn-liveness.mjs +249 -0
- package/shared/worker-runtime/worker-log-file.mjs +97 -0
|
@@ -37,13 +37,20 @@ import {
|
|
|
37
37
|
import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
|
|
38
38
|
import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
|
|
39
39
|
import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
|
|
40
|
-
import {
|
|
40
|
+
import { createTurnLiveness, resolveTurnStallWindows } from '../../shared/worker-runtime/turn-liveness.mjs';
|
|
41
|
+
import {
|
|
42
|
+
ANSWERED_ELSEWHERE_KIND,
|
|
43
|
+
DELIVERY_ANSWERED_ELSEWHERE_TEXT,
|
|
44
|
+
STEER_FOLDED_TEXT,
|
|
45
|
+
STEER_STOPPED_TEXT,
|
|
46
|
+
} from '../../shared/steer-settle-markers.mjs';
|
|
41
47
|
import { stripSteerNote, withSteerNoteContent } from '../../shared/steer-note.mjs';
|
|
42
48
|
import {
|
|
43
49
|
stripSlashCommandGuard,
|
|
44
50
|
stripSlashCommandGuardContent,
|
|
45
51
|
withSlashCommandGuardContent,
|
|
46
52
|
} from '../../shared/slash-command-guard.mjs';
|
|
53
|
+
import { createUsageLimitTracker } from '../../shared/claude-usage-limit.mjs';
|
|
47
54
|
|
|
48
55
|
/**
|
|
49
56
|
* Which system-prompt append a relay mode gets (claude-sdk-adapter's
|
|
@@ -446,6 +453,10 @@ export function createClaudeSessionRunner({
|
|
|
446
453
|
// next delivery.
|
|
447
454
|
getThinking = () => ({ enabled: null, display: '' }),
|
|
448
455
|
lifecyclePollMs = 5_000,
|
|
456
|
+
// How long a RUNNING turn may go without a message from the CLI before
|
|
457
|
+
// that is written to the log. Nothing is failed over it: see
|
|
458
|
+
// `noteQuietTurn`. `{ model, tool }` in ms; 0 for either turns it off.
|
|
459
|
+
quietTurnWindowsMs = undefined,
|
|
449
460
|
// A settled task's continuation normally begins within ~1s; when nothing
|
|
450
461
|
// arrives inside this window the notification was silent (skip_transcript)
|
|
451
462
|
// and must stop pinning the process.
|
|
@@ -464,6 +475,17 @@ export function createClaudeSessionRunner({
|
|
|
464
475
|
// takes to distinguish a fold from a queued run. Far below the watchdog so a
|
|
465
476
|
// folded row settles as answered, not failed with a "resend" error.
|
|
466
477
|
steeredFoldGraceMs = 2_000,
|
|
478
|
+
// How long a NON-steered entry may sit unattached after a continuation that
|
|
479
|
+
// opened over it has published its answer, with the stream quiet and no
|
|
480
|
+
// turn starting, before it is settled as answered by that continuation. The
|
|
481
|
+
// CLI starts the turn of a message queued behind another one when that
|
|
482
|
+
// turn ends, and every turn opens with an init, which takes the entry out
|
|
483
|
+
// of this settle for good. So the window only has to outlast the gap up to
|
|
484
|
+
// that init (input processing, hooks); it is sized well above it because a
|
|
485
|
+
// settle that comes too early costs the user the real answer's place.
|
|
486
|
+
// Without the settle the row held every later message out of the worker
|
|
487
|
+
// until the watchdog failed it minutes later. 0 = disabled.
|
|
488
|
+
strandedSettleMs = 45_000,
|
|
467
489
|
// Backstop for the compaction hold. A compaction is announced ONCE by
|
|
468
490
|
// `status: 'compacting'` (the CLI's periodic re-emit is gated on the
|
|
469
491
|
// remote-control client's activity callback, which the plain SDK `query()`
|
|
@@ -530,6 +552,9 @@ export function createClaudeSessionRunner({
|
|
|
530
552
|
// fixed per process, and the first delivery can beat the startup GET.
|
|
531
553
|
const remoteRelayGate = remoteRelayToolGate || createRemoteRelayToolGate({ api });
|
|
532
554
|
void remoteRelayGate.refresh();
|
|
555
|
+
// The subscription's usage limit as the CLI reports it. Runner-level: the
|
|
556
|
+
// limit is the account's, not the CLI process's.
|
|
557
|
+
const usageLimit = createUsageLimitTracker();
|
|
533
558
|
const resolveWorkflowSessionDir = resolveWorkflowSessionDirImpl || createDefaultWorkflowSessionDirResolver();
|
|
534
559
|
let claudeNativeSessionId = '';
|
|
535
560
|
let proc = null;
|
|
@@ -793,6 +818,8 @@ export function createClaudeSessionRunner({
|
|
|
793
818
|
function attachDeliveredContext(index = 0, { provisional = false } = {}) {
|
|
794
819
|
const [entry] = proc.pendingDelivered.splice(index, 1);
|
|
795
820
|
entry.ctx.deliveredEntry = entry;
|
|
821
|
+
// It has a turn now; an adoption that is unwound puts it back unmarked.
|
|
822
|
+
entry.overtakenBy = null;
|
|
796
823
|
// A provisional (post-compaction) attach may still be unwound back onto
|
|
797
824
|
// the queue; its un-mark waits for the adoption to commit.
|
|
798
825
|
if (!provisional) unmarkConsumedOnOwnTurn(entry);
|
|
@@ -869,7 +896,89 @@ export function createClaudeSessionRunner({
|
|
|
869
896
|
function handBackAdoptedContext(ctx) {
|
|
870
897
|
restoreAdoptedContext(ctx);
|
|
871
898
|
dbg('post-compaction adoption handed back to a task continuation', ctx.message?.id || '(no id)');
|
|
872
|
-
return openContinuationContext();
|
|
899
|
+
return openContinuationContext({ evidence: 'a task notification replayed into the adopted turn' });
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Mark the next turn as one the CLI opened by itself. `corroborated` says
|
|
904
|
+
* whether the signal proves that on its own (a turn opener's replay, the
|
|
905
|
+
* Stop guard standing down) or only suggests it (a bare init, which the CLI
|
|
906
|
+
* also emits for reasons of its own). Sticky until the boundary is spent: a
|
|
907
|
+
* weaker signal behind a proven one does not take the proof back.
|
|
908
|
+
*
|
|
909
|
+
* Every marking starts with nothing superseding it, a re-marking of an
|
|
910
|
+
* armed boundary included. A bare init marks only with nothing queued, so
|
|
911
|
+
* it precedes any later push; and a boundary can stay armed with the flag
|
|
912
|
+
* set, when the message whose init set it was failed by the watchdog
|
|
913
|
+
* without a turn ever opening. Left standing, that flag made the next turn
|
|
914
|
+
* the CLI opened by itself look superseded by an init it never saw.
|
|
915
|
+
*/
|
|
916
|
+
function markSelfOpenedBoundary(reason, { corroborated = false } = {}) {
|
|
917
|
+
if (proc.lastBoundary === 'self-opened' && proc.boundaryCorroborated) return;
|
|
918
|
+
proc.lastBoundary = 'self-opened';
|
|
919
|
+
proc.boundarySuperseded = false;
|
|
920
|
+
proc.boundaryCorroborated = corroborated;
|
|
921
|
+
proc.boundaryReason = reason;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* What says the CLI really opened the turn the boundary announces, or ''
|
|
926
|
+
* when nothing does. Beyond the signal itself:
|
|
927
|
+
* - a task settled and no continuation has opened since. Counted, not
|
|
928
|
+
* timed, and not reset by a turn ending: a task that settles early in a
|
|
929
|
+
* long turn has its continuation open after that turn, by which time
|
|
930
|
+
* both the notification grace and `settleSeqAtTurnEnd` have moved on.
|
|
931
|
+
* Over-long by design: a settle whose notification was silent or folded
|
|
932
|
+
* into another turn stays evidence until the next continuation opens,
|
|
933
|
+
* which only keeps a boundary the way it was always kept;
|
|
934
|
+
* - a live task, which can report back without settling;
|
|
935
|
+
* - order: the init that armed the boundary came before the waiting
|
|
936
|
+
* message was pushed (an init arms it only with nothing queued), so that
|
|
937
|
+
* turn is not the message's own, and no later init has started another
|
|
938
|
+
* turn in its place (see `boundarySuperseded`).
|
|
939
|
+
*/
|
|
940
|
+
function selfOpenedEvidence() {
|
|
941
|
+
if (proc.boundaryCorroborated) return proc.boundaryReason;
|
|
942
|
+
if (proc.taskSettleSeq !== proc.settleSeqAtContinuationOpen) {
|
|
943
|
+
return 'a background task settled and no continuation has opened since';
|
|
944
|
+
}
|
|
945
|
+
if (proc.liveTasks.size > 0) return 'a live background task can report back';
|
|
946
|
+
if (!proc.boundarySuperseded) return 'its init came before the message was pushed and no turn has started since';
|
|
947
|
+
return '';
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* A self-opened boundary diverts the next turn's answer to a continuation
|
|
952
|
+
* row. One armed by a bare init stays armed until a turn opens, however
|
|
953
|
+
* long that takes, and the CLI inits for reasons of its own: read as
|
|
954
|
+
* self-opened, a settings re-init published the user's answer on a
|
|
955
|
+
* continuation and left the delivered row unattached, holding every later
|
|
956
|
+
* message out until the watchdog failed it (see the init handler).
|
|
957
|
+
*
|
|
958
|
+
* Every turn opens with an init, so what tells a stale boundary from a live
|
|
959
|
+
* one is what follows the push. Traffic with no init in between belongs to
|
|
960
|
+
* the turn the armed init opened: a continuation, as before. A SECOND init,
|
|
961
|
+
* after the push, starts a newer turn; the armed one never produced
|
|
962
|
+
* anything, and unless a task corroborates it the boundary is dropped here
|
|
963
|
+
* and the turn attaches to the waiting message, as if it had never been
|
|
964
|
+
* marked. Steered entries do not count: they settle by the fold reaper. A
|
|
965
|
+
* boundary marked by a replay is never dropped; if that one is false, the
|
|
966
|
+
* settle in reapOvertakenDeliveredEntries recovers.
|
|
967
|
+
*/
|
|
968
|
+
function disownUnprovenBoundary() {
|
|
969
|
+
if (proc.lastBoundary !== 'self-opened') return;
|
|
970
|
+
const waiting = proc.pendingDelivered.find((entry) => !entry.steered);
|
|
971
|
+
if (!waiting || selfOpenedEvidence()) return;
|
|
972
|
+
dbg(
|
|
973
|
+
'self-opened boundary has no corroboration; the turn goes to the waiting message',
|
|
974
|
+
shortMessageId(waiting.ctx.message),
|
|
975
|
+
`boundary=${proc.boundaryReason}`,
|
|
976
|
+
);
|
|
977
|
+
proc.lastBoundary = null;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function shortMessageId(message) {
|
|
981
|
+
return String(message?.id || '').slice(0, 8) || '(no id)';
|
|
873
982
|
}
|
|
874
983
|
|
|
875
984
|
/**
|
|
@@ -879,7 +988,7 @@ export function createClaudeSessionRunner({
|
|
|
879
988
|
* discards the turn's relay output (it still lands in the native
|
|
880
989
|
* transcript) rather than failing the process.
|
|
881
990
|
*/
|
|
882
|
-
function openContinuationContext() {
|
|
991
|
+
function openContinuationContext({ evidence = '' } = {}) {
|
|
883
992
|
const ctx = createContext('continuation', {
|
|
884
993
|
id: null,
|
|
885
994
|
conversationId: sdkSessionId,
|
|
@@ -887,6 +996,26 @@ export function createClaudeSessionRunner({
|
|
|
887
996
|
model: '',
|
|
888
997
|
});
|
|
889
998
|
ctx.registered = false;
|
|
999
|
+
// Delivered messages this turn opens over. Remembered on the entry: if
|
|
1000
|
+
// this turn turns out to have taken their prompt, nothing will ever open
|
|
1001
|
+
// for them (see reapOvertakenDeliveredEntries). Read before the
|
|
1002
|
+
// activation below spends the boundary.
|
|
1003
|
+
const overtaken = proc.pendingDelivered.filter((entry) => !entry.steered);
|
|
1004
|
+
if (overtaken.length) {
|
|
1005
|
+
const selfOpened = proc.lastBoundary === 'self-opened';
|
|
1006
|
+
dbg(
|
|
1007
|
+
'continuation opens while a delivered message waits unattached',
|
|
1008
|
+
overtaken.map((entry) => shortMessageId(entry.ctx.message)).join(','),
|
|
1009
|
+
`boundary=${selfOpened ? proc.boundaryReason : 'none'}`,
|
|
1010
|
+
`reason=${evidence || (selfOpened && selfOpenedEvidence()) || 'none'}`,
|
|
1011
|
+
);
|
|
1012
|
+
for (const entry of overtaken) {
|
|
1013
|
+
entry.overtakenBy = ctx;
|
|
1014
|
+
entry.overtakenSince = 0;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
// Whatever settled up to here has its continuation now.
|
|
1018
|
+
proc.settleSeqAtContinuationOpen = proc.taskSettleSeq;
|
|
890
1019
|
activateContext(ctx);
|
|
891
1020
|
(async () => {
|
|
892
1021
|
// One idempotency key for the whole loop: a retry whose predecessor was
|
|
@@ -1149,11 +1278,21 @@ export function createClaudeSessionRunner({
|
|
|
1149
1278
|
// A turn the CLI opened on its own (task-notification replay). The
|
|
1150
1279
|
// context opens lazily on its first real traffic so a bookkeeping
|
|
1151
1280
|
// replay that ends in a phantom result never creates a relay turn.
|
|
1152
|
-
|
|
1281
|
+
// A turn opener that matched no delivered message proves the turn is
|
|
1282
|
+
// the CLI's own, tagged or not: the adoption rules above rest on that.
|
|
1283
|
+
const originKind = String(sdkMessage?.origin?.kind || '').trim();
|
|
1284
|
+
markSelfOpenedBoundary(
|
|
1285
|
+
isTaskNotificationReplay(sdkMessage)
|
|
1286
|
+
? 'a task notification replay'
|
|
1287
|
+
: `an unmatched user replay (origin ${originKind || 'none'})`,
|
|
1288
|
+
{ corroborated: true },
|
|
1289
|
+
);
|
|
1290
|
+
turnStartedForWaitingDeliveries();
|
|
1153
1291
|
return null;
|
|
1154
1292
|
}
|
|
1155
1293
|
|
|
1156
1294
|
if (type === 'assistant' || type === 'stream_event' || type === 'user') {
|
|
1295
|
+
disownUnprovenBoundary();
|
|
1157
1296
|
if (proc.lastBoundary !== 'self-opened') {
|
|
1158
1297
|
// A background subagent's frames arrive at top level carrying
|
|
1159
1298
|
// `parent_tool_use_id` (same marker the adoption commit checks): they
|
|
@@ -1177,6 +1316,7 @@ export function createClaudeSessionRunner({
|
|
|
1177
1316
|
proc.lastBoundary = null;
|
|
1178
1317
|
return null;
|
|
1179
1318
|
}
|
|
1319
|
+
disownUnprovenBoundary();
|
|
1180
1320
|
if (proc.lastBoundary !== 'self-opened' && proc.pendingDelivered.length && !sdkMessage?.parent_tool_use_id) {
|
|
1181
1321
|
return attachDeliveredContext();
|
|
1182
1322
|
}
|
|
@@ -1971,11 +2111,21 @@ export function createClaudeSessionRunner({
|
|
|
1971
2111
|
if (type !== 'system') return;
|
|
1972
2112
|
const subtype = String(sdkMessage.subtype || '');
|
|
1973
2113
|
if (subtype === 'init') {
|
|
1974
|
-
// The
|
|
1975
|
-
//
|
|
1976
|
-
//
|
|
2114
|
+
// The CLI emits an init at the start of EVERY turn, after it has taken
|
|
2115
|
+
// the turn's input and before the API request (read in the CLI, 2.1.282):
|
|
2116
|
+
// a delivered message's own turn opens init → traffic, with no user
|
|
2117
|
+
// replay, exactly like a continuation the CLI opens by itself. The
|
|
2118
|
+
// FIRST init of a process is the spawn's, and a spawn is always driven
|
|
2119
|
+
// by a delivered message.
|
|
1977
2120
|
const firstInit = !proc.sawInit;
|
|
1978
2121
|
proc.sawInit = true;
|
|
2122
|
+
// A turn is starting while delivered messages wait, and it may be
|
|
2123
|
+
// theirs: they leave the overtaken settle for good. Whether a context is
|
|
2124
|
+
// still active does not matter. A continuation whose result arrived
|
|
2125
|
+
// while its row was still registering is active when the next turn's
|
|
2126
|
+
// init lands, and skipping that init settled the message with a pointer
|
|
2127
|
+
// and dropped its real answer.
|
|
2128
|
+
if (!firstInit && proc.pendingDelivered.length) turnStartedForWaitingDeliveries();
|
|
1979
2129
|
persistNativeSessionId(sdkSessionId, sdkMessage.session_id).catch(() => {});
|
|
1980
2130
|
// Tracked on the process too (not only through the persist round-trip):
|
|
1981
2131
|
// the workflow poller needs the native id to find the session dir even
|
|
@@ -2010,17 +2160,20 @@ export function createClaudeSessionRunner({
|
|
|
2010
2160
|
// same way.
|
|
2011
2161
|
if (settingsReinitsOwed > 0) settingsReinitsOwed -= 1;
|
|
2012
2162
|
else if (!settingsWindowOpen || proc.taskSettleSeq !== proc.settleSeqAtTurnEnd) {
|
|
2013
|
-
|
|
2163
|
+
markSelfOpenedBoundary('a bare init with nothing queued');
|
|
2014
2164
|
}
|
|
2015
2165
|
} else if (!proc.activeCtx && !firstInit) {
|
|
2016
|
-
// A LATER init, with a delivered row waiting
|
|
2017
|
-
//
|
|
2018
|
-
//
|
|
2019
|
-
//
|
|
2020
|
-
//
|
|
2021
|
-
//
|
|
2022
|
-
// turn
|
|
2023
|
-
// nothing later replays to correct it.
|
|
2166
|
+
// A LATER init, with a delivered row waiting: a turn is starting, and
|
|
2167
|
+
// the init cannot say whose. It may be the CLI opening a turn of its
|
|
2168
|
+
// own (the live-verified continuation shape is a bare init with no
|
|
2169
|
+
// user replay at all) or the waiting message's own turn, which opens
|
|
2170
|
+
// with an init just the same. For a compaction inside that turn the
|
|
2171
|
+
// flag errs toward the continuation: adopting the queued row for a
|
|
2172
|
+
// turn that was the CLI's own closes it with another turn's answer,
|
|
2173
|
+
// and in this shape nothing later replays to correct it. When the
|
|
2174
|
+
// turn was the message's own, its answer publishes on a continuation
|
|
2175
|
+
// row instead and the row is settled with a pointer to it (see
|
|
2176
|
+
// reapOvertakenDeliveredEntries).
|
|
2024
2177
|
//
|
|
2025
2178
|
// Structural, not a clock. Gating this on "a task settled recently"
|
|
2026
2179
|
// fails twice over: the grace (60 s) is shorter than a compaction
|
|
@@ -2036,6 +2189,9 @@ export function createClaudeSessionRunner({
|
|
|
2036
2189
|
// continuation). This suppresses adoption and nothing else; if the turn
|
|
2037
2190
|
// really is the delivered message's, it attaches exactly as before.
|
|
2038
2191
|
proc.continuationInitPending = true;
|
|
2192
|
+
// A boundary an earlier init armed announced a turn that never
|
|
2193
|
+
// produced anything: this init starts a newer one in its place.
|
|
2194
|
+
if (proc.lastBoundary === 'self-opened') proc.boundarySuperseded = true;
|
|
2039
2195
|
}
|
|
2040
2196
|
return;
|
|
2041
2197
|
}
|
|
@@ -2328,6 +2484,90 @@ export function createClaudeSessionRunner({
|
|
|
2328
2484
|
return 'swallow';
|
|
2329
2485
|
}
|
|
2330
2486
|
|
|
2487
|
+
// ---------------------------------------------------------------------------
|
|
2488
|
+
// A running turn that went quiet
|
|
2489
|
+
//
|
|
2490
|
+
// The other workers fail a turn whose runtime has been silent for too long
|
|
2491
|
+
// (shared/worker-runtime/turn-liveness.mjs). This one only WRITES IT DOWN.
|
|
2492
|
+
// What the CLI sends is known (SDK 0.3.283, measured 2026-09-28): a
|
|
2493
|
+
// `tool_progress` heartbeat every 30 s while a tool runs in silence, for the
|
|
2494
|
+
// Agent call as a whole when the tool runs inside a subagent; a
|
|
2495
|
+
// `thinking_tokens` or stream frame about every second while the model
|
|
2496
|
+
// works. What is not known is how long its silent stretches get in real
|
|
2497
|
+
// sessions: a compaction (133 s seen), the backoff of an API retry, a
|
|
2498
|
+
// request it is waiting on. Failing a turn here also means killing the CLI
|
|
2499
|
+
// and its background tasks. So the windows are generous, the known silent
|
|
2500
|
+
// stretches are holds, and a turn that outlasts them is logged with what
|
|
2501
|
+
// the CLI was doing — the measurements a failing watchdog would need.
|
|
2502
|
+
|
|
2503
|
+
const quietWindows = (() => {
|
|
2504
|
+
const windows = resolveTurnStallWindows({
|
|
2505
|
+
// The CLI gives up on a silent model stream by itself after 300 s and
|
|
2506
|
+
// says so (`api_retry`); a window of the same length would race it.
|
|
2507
|
+
modelMs: quietTurnWindowsMs?.model ?? process.env.OAR_TURN_STALL_MODEL_MS ?? 600_000,
|
|
2508
|
+
toolMs: quietTurnWindowsMs?.tool,
|
|
2509
|
+
});
|
|
2510
|
+
return { idle: 0, model: windows.model, tool: windows.tool };
|
|
2511
|
+
})();
|
|
2512
|
+
|
|
2513
|
+
function observeQuietTurn(sdkMessage) {
|
|
2514
|
+
const liveness = proc?.liveness;
|
|
2515
|
+
if (!liveness) return;
|
|
2516
|
+
const type = String(sdkMessage?.type || '');
|
|
2517
|
+
const agent = String(sdkMessage?.parent_tool_use_id || '').trim() || 'root';
|
|
2518
|
+
if (proc.quietLoggedAt) {
|
|
2519
|
+
dbg(`the CLI spoke again after ${Math.round(liveness.quietMs() / 1000)}s of silence`, type);
|
|
2520
|
+
proc.quietLoggedAt = 0;
|
|
2521
|
+
}
|
|
2522
|
+
liveness.traffic(type === 'system' ? `system/${sdkMessage?.subtype || ''}` : type);
|
|
2523
|
+
const blocks = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
|
|
2524
|
+
if (type === 'assistant') {
|
|
2525
|
+
for (const block of blocks) {
|
|
2526
|
+
if (block?.type === 'tool_use' && block.id) liveness.begin('tool', `${agent}:${block.id}`, block.name);
|
|
2527
|
+
}
|
|
2528
|
+
} else if (type === 'user') {
|
|
2529
|
+
for (const block of blocks) {
|
|
2530
|
+
if (block?.type !== 'tool_result' || !block.tool_use_id) continue;
|
|
2531
|
+
liveness.end('tool', `${agent}:${block.tool_use_id}`);
|
|
2532
|
+
// The result of an Agent call: whatever that agent had open ended with it.
|
|
2533
|
+
liveness.endAgent(String(block.tool_use_id));
|
|
2534
|
+
}
|
|
2535
|
+
} else if (type === 'stream_event') {
|
|
2536
|
+
const event = String(sdkMessage?.event?.type || '');
|
|
2537
|
+
if (event === 'message_start') liveness.begin('model', agent);
|
|
2538
|
+
else if (event === 'message_stop') liveness.end('model', agent);
|
|
2539
|
+
} else if (type === 'result') {
|
|
2540
|
+
liveness.reset();
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
/**
|
|
2545
|
+
* Log a running turn the CLI has said nothing in for longer than its phase
|
|
2546
|
+
* allows. Once per silence, and never while the silence has a known reason.
|
|
2547
|
+
*/
|
|
2548
|
+
function noteQuietTurn() {
|
|
2549
|
+
const liveness = proc?.liveness;
|
|
2550
|
+
const ctx = proc?.activeCtx;
|
|
2551
|
+
if (!liveness || !ctx || ctx.finalized || proc.closing || proc.quietLoggedAt) return;
|
|
2552
|
+
// A human is deciding, a compaction is running, the relay is slow to take
|
|
2553
|
+
// a publish (the stream is not read meanwhile), or the turn is being
|
|
2554
|
+
// stopped: silence with a reason.
|
|
2555
|
+
if (proc.pendingControlRequests > 0 || isCompacting() || proc.dispatching > 0 || ctx.interrupted) {
|
|
2556
|
+
liveness.traffic('held');
|
|
2557
|
+
return;
|
|
2558
|
+
}
|
|
2559
|
+
// Inside a turn the CLI is waiting for the model unless a tool is open.
|
|
2560
|
+
const phase = liveness.phase() === 'tool' ? 'tool' : 'model';
|
|
2561
|
+
const limitMs = quietWindows[phase];
|
|
2562
|
+
if (!(limitMs > 0) || liveness.quietMs() < limitMs) return;
|
|
2563
|
+
proc.quietLoggedAt = Date.now();
|
|
2564
|
+
dbg(
|
|
2565
|
+
`running turn quiet for ${Math.round(liveness.quietMs() / 1000)}s; left running (the quiet-turn watchdog only observes)`,
|
|
2566
|
+
ctx.message?.id || '(continuation)',
|
|
2567
|
+
liveness.describe(),
|
|
2568
|
+
);
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2331
2571
|
async function onSdkMessage(processRef, sdkMessage) {
|
|
2332
2572
|
// A superseded process (push-race respawn, mode-recycle timeout) may still
|
|
2333
2573
|
// be draining its stream; its late messages must not mutate the state of
|
|
@@ -2335,13 +2575,30 @@ export function createClaudeSessionRunner({
|
|
|
2335
2575
|
// cleanupProcess when the old stream ends.
|
|
2336
2576
|
if (!proc || proc !== processRef) return;
|
|
2337
2577
|
proc.lastEventAt = Date.now();
|
|
2578
|
+
observeQuietTurn(sdkMessage);
|
|
2579
|
+
// Counted for as long as this message is being handled: the stream is
|
|
2580
|
+
// not read meanwhile, so the silence is this worker's, not the CLI's.
|
|
2581
|
+
processRef.dispatching += 1;
|
|
2582
|
+
try {
|
|
2583
|
+
await handleSdkMessage(sdkMessage);
|
|
2584
|
+
} finally {
|
|
2585
|
+
processRef.dispatching -= 1;
|
|
2586
|
+
if (proc === processRef) proc.liveness.traffic('handled');
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
async function handleSdkMessage(sdkMessage) {
|
|
2591
|
+
const usageLimitReport = usageLimit.observe(sdkMessage);
|
|
2592
|
+
if (usageLimitReport) publisher.publishUsageLimitReport({ conversationId: sdkSessionId, sdkSessionId, report: usageLimitReport });
|
|
2338
2593
|
const suppressed = maybeSuppressStopFollowUp(sdkMessage);
|
|
2339
2594
|
if (suppressed === 'swallow') return;
|
|
2340
2595
|
observeProcessLevel(sdkMessage);
|
|
2341
2596
|
if (suppressed === 'observe-only') return;
|
|
2342
2597
|
// Its traffic opens a continuation of its own instead of attaching to the
|
|
2343
2598
|
// stopped steer waiting in pendingDelivered (which still settles stopped).
|
|
2344
|
-
if (suppressed === 'continuation')
|
|
2599
|
+
if (suppressed === 'continuation') {
|
|
2600
|
+
markSelfOpenedBoundary('the stop guard stood down for a task continuation', { corroborated: true });
|
|
2601
|
+
}
|
|
2345
2602
|
const ctx = resolveContext(sdkMessage);
|
|
2346
2603
|
if (proc.handoffSettling) {
|
|
2347
2604
|
// An absorbed-steering handoff just settled the previous context;
|
|
@@ -2353,6 +2610,8 @@ export function createClaudeSessionRunner({
|
|
|
2353
2610
|
if (!ctx) return;
|
|
2354
2611
|
const actions = ctx.normalizer.normalize(sdkMessage);
|
|
2355
2612
|
for (const action of actions) {
|
|
2613
|
+
// Decided here, while the reports the turn ran under are the latest.
|
|
2614
|
+
if (action.channel === 'result') action.payload.usageLimit = usageLimit.classifyResult(sdkMessage);
|
|
2356
2615
|
await dispatchToContext(ctx, action);
|
|
2357
2616
|
}
|
|
2358
2617
|
}
|
|
@@ -2530,6 +2789,112 @@ export function createClaudeSessionRunner({
|
|
|
2530
2789
|
}
|
|
2531
2790
|
}
|
|
2532
2791
|
|
|
2792
|
+
/**
|
|
2793
|
+
* A turn is starting while delivered messages wait. Whatever opened over
|
|
2794
|
+
* them before, they are no longer known to be left behind: this turn may be
|
|
2795
|
+
* theirs, and its first traffic can take longer than the settle window.
|
|
2796
|
+
*/
|
|
2797
|
+
function turnStartedForWaitingDeliveries() {
|
|
2798
|
+
for (const entry of proc.pendingDelivered) entry.overtakenBy = null;
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
/** The continuation ran to a clean end and its answer is on the relay. */
|
|
2802
|
+
function publishedItsAnswer(ctx) {
|
|
2803
|
+
return Boolean(ctx)
|
|
2804
|
+
&& ctx.finalized
|
|
2805
|
+
&& ctx.registered
|
|
2806
|
+
&& !ctx.discarded
|
|
2807
|
+
&& !ctx.interrupted
|
|
2808
|
+
&& Boolean(ctx.message?.id)
|
|
2809
|
+
&& Boolean(ctx.state.result)
|
|
2810
|
+
&& !ctx.state.result.isError;
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
/**
|
|
2814
|
+
* Settle delivered messages whose prompt a continuation's turn took. A
|
|
2815
|
+
* continuation opened over a waiting NON-steered entry either ran ahead of
|
|
2816
|
+
* the entry's own turn, which then follows at once, or WAS that turn: a
|
|
2817
|
+
* false boundary published the answer on the continuation row, or the CLI
|
|
2818
|
+
* folded the queued prompt into the turn it had already begun. In the
|
|
2819
|
+
* second case nothing attaches the entry anymore, and it held every later
|
|
2820
|
+
* message out of the worker until the watchdog failed it with a "resend"
|
|
2821
|
+
* error for a message that had been answered (seen live 2026-09-27).
|
|
2822
|
+
*
|
|
2823
|
+
* Told apart by silence: the continuation published its answer, no turn
|
|
2824
|
+
* started since (any init takes the entry out, see the init handler), and
|
|
2825
|
+
* the stream stayed quiet for the settle window, counted from whichever is
|
|
2826
|
+
* latest: the continuation's end, the entry's push, the last stream event.
|
|
2827
|
+
* Same gates as the fold reaper, so never during a compaction, an API
|
|
2828
|
+
* retry, an open control round-trip or a task continuation that is due. A
|
|
2829
|
+
* continuation that was stopped, failed or never reached the relay settles
|
|
2830
|
+
* nothing: the marker points at its reply, and the watchdog keeps those
|
|
2831
|
+
* rows.
|
|
2832
|
+
*/
|
|
2833
|
+
function reapOvertakenDeliveredEntries(now) {
|
|
2834
|
+
if (!proc || !proc.pendingDelivered.length) return;
|
|
2835
|
+
if (!(strandedSettleMs > 0)) return;
|
|
2836
|
+
if (proc.activeCtx || proc.pendingControlRequests > 0 || isCompacting() || isContinuationImminent()
|
|
2837
|
+
|| isApiRetryPending() || isStopFollowUpPending(now)) {
|
|
2838
|
+
for (const entry of proc.pendingDelivered) entry.overtakenSince = 0;
|
|
2839
|
+
return;
|
|
2840
|
+
}
|
|
2841
|
+
const answered = [];
|
|
2842
|
+
proc.pendingDelivered = proc.pendingDelivered.filter((entry) => {
|
|
2843
|
+
if (entry.steered || !publishedItsAnswer(entry.overtakenBy)) return true;
|
|
2844
|
+
if (!entry.overtakenSince) { entry.overtakenSince = now; return true; }
|
|
2845
|
+
// Any stream event restarts the wait: a CLI that still talks is not done.
|
|
2846
|
+
const quietSince = Math.max(entry.overtakenSince, entry.pushedAt || 0, proc.lastEventAt);
|
|
2847
|
+
if (now - quietSince < strandedSettleMs) return true;
|
|
2848
|
+
answered.push(entry);
|
|
2849
|
+
// Owned until the marker is committed (same claim as a folded steer).
|
|
2850
|
+
claimSettling(entry.ctx.message, 'answeredElsewhere');
|
|
2851
|
+
return false;
|
|
2852
|
+
});
|
|
2853
|
+
if (!answered.length) return;
|
|
2854
|
+
void (async () => {
|
|
2855
|
+
for (const entry of answered) {
|
|
2856
|
+
dbg(
|
|
2857
|
+
'delivered message was answered on a continuation row; settling it',
|
|
2858
|
+
shortMessageId(entry.ctx.message),
|
|
2859
|
+
`continuation=${shortMessageId(entry.overtakenBy.message)}`,
|
|
2860
|
+
);
|
|
2861
|
+
try {
|
|
2862
|
+
await settleOvertakenDeliveredEntry(entry);
|
|
2863
|
+
} catch (error) {
|
|
2864
|
+
dbg('overtaken-delivery settle failed', error?.message || String(error));
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
})();
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
async function settleOvertakenDeliveredEntry(entry) {
|
|
2871
|
+
const ctx = entry.ctx;
|
|
2872
|
+
if (ctx.finalized) {
|
|
2873
|
+
releaseSettling(ctx.message?.id);
|
|
2874
|
+
return;
|
|
2875
|
+
}
|
|
2876
|
+
ctx.finalized = true;
|
|
2877
|
+
const model = entry.overtakenBy.state.responseModel || proc?.model || null;
|
|
2878
|
+
try {
|
|
2879
|
+
// Marked consumed BEFORE the first publish attempt: should the worker
|
|
2880
|
+
// die while the marker is retried, the relay fails the row instead of
|
|
2881
|
+
// running its prompt again. Never rejects (best-effort).
|
|
2882
|
+
await markConsumed(ctx.message?.conversationId, [ctx.message]);
|
|
2883
|
+
// The row closes with a pointer to the answer, under a kind of its own:
|
|
2884
|
+
// a marker, not a reply. Not requeued (the prompt would run twice) and
|
|
2885
|
+
// not failed (it was answered).
|
|
2886
|
+
await publishSettleMarker(ctx.message, {
|
|
2887
|
+
text: DELIVERY_ANSWERED_ELSEWHERE_TEXT,
|
|
2888
|
+
model,
|
|
2889
|
+
kind: ANSWERED_ELSEWHERE_KIND,
|
|
2890
|
+
variant: 'answeredElsewhere',
|
|
2891
|
+
});
|
|
2892
|
+
} finally {
|
|
2893
|
+
// Spliced out of pendingDelivered already: nothing else resolves it.
|
|
2894
|
+
ctx.resolveDone?.(true);
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2533
2898
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2534
2899
|
|
|
2535
2900
|
/**
|
|
@@ -2563,7 +2928,14 @@ export function createClaudeSessionRunner({
|
|
|
2563
2928
|
const workflowRuns = publisher.drainWorkflowRuns();
|
|
2564
2929
|
try {
|
|
2565
2930
|
for (let attempt = 0; ; attempt += 1) {
|
|
2566
|
-
|
|
2931
|
+
// Never requeued, with or without a kind: the CLI has the prompt.
|
|
2932
|
+
const outcome = await publisher.publishResponse(message, {
|
|
2933
|
+
text,
|
|
2934
|
+
model,
|
|
2935
|
+
kind,
|
|
2936
|
+
workflowRuns,
|
|
2937
|
+
requeueOnFailure: false,
|
|
2938
|
+
});
|
|
2567
2939
|
reportFirstAttempt();
|
|
2568
2940
|
if (outcome !== 'failed') return;
|
|
2569
2941
|
if (attempt >= settleRetryDelaysMs.length) break;
|
|
@@ -2655,10 +3027,14 @@ export function createClaudeSessionRunner({
|
|
|
2655
3027
|
if (proc.stopFollowUp && !proc.suppressingTurn && !proc.pendingDelivered.some((entry) => entry.stoppedWithTurn)) {
|
|
2656
3028
|
proc.stopFollowUp = null;
|
|
2657
3029
|
}
|
|
3030
|
+
noteQuietTurn();
|
|
2658
3031
|
reapSilentProvisionalAdoption(now);
|
|
2659
3032
|
// Before the 5-minute watchdog: a folded steered entry settles as merged
|
|
2660
3033
|
// within the short fold grace instead of failing over.
|
|
2661
3034
|
reapFoldedSteeredEntries(now);
|
|
3035
|
+
// Also before the watchdog: a message a continuation answered settles as
|
|
3036
|
+
// answered instead of failing over minutes later.
|
|
3037
|
+
reapOvertakenDeliveredEntries(now);
|
|
2662
3038
|
reapStalePendingDelivered(now);
|
|
2663
3039
|
if (hasLiveWork()) {
|
|
2664
3040
|
// Track how long background tasks alone have held the process, for the
|
|
@@ -2765,6 +3141,7 @@ export function createClaudeSessionRunner({
|
|
|
2765
3141
|
await publisher.publishTurnException({
|
|
2766
3142
|
message: ctx.message,
|
|
2767
3143
|
errorText: String(streamError?.message || streamError || 'unknown error'),
|
|
3144
|
+
usageLimit: usageLimit.classifyException(streamError),
|
|
2768
3145
|
}).catch(() => {});
|
|
2769
3146
|
}
|
|
2770
3147
|
continue;
|
|
@@ -2855,8 +3232,23 @@ export function createClaudeSessionRunner({
|
|
|
2855
3232
|
activeCtx: null,
|
|
2856
3233
|
pendingDelivered: [],
|
|
2857
3234
|
lastBoundary: null,
|
|
3235
|
+
// What marked the boundary, and whether that alone proves the CLI
|
|
3236
|
+
// opened the turn; read only while lastBoundary is 'self-opened'.
|
|
3237
|
+
boundaryReason: '',
|
|
3238
|
+
boundaryCorroborated: false,
|
|
3239
|
+
// An init arrived after the boundary was armed, with a delivered
|
|
3240
|
+
// message waiting: a newer turn started in place of the announced one.
|
|
3241
|
+
boundarySuperseded: false,
|
|
3242
|
+
// taskSettleSeq when a continuation context last opened: a settle since
|
|
3243
|
+
// then still has its continuation coming.
|
|
3244
|
+
settleSeqAtContinuationOpen: 0,
|
|
2858
3245
|
pendingControlRequests: 0,
|
|
2859
3246
|
lastEventAt: Date.now(),
|
|
3247
|
+
// The quiet-turn log (see noteQuietTurn): what the CLI has in flight,
|
|
3248
|
+
// messages being handled right now, and when the silence was logged.
|
|
3249
|
+
liveness: createTurnLiveness({ windows: quietWindows }),
|
|
3250
|
+
dispatching: 0,
|
|
3251
|
+
quietLoggedAt: 0,
|
|
2860
3252
|
heldForTasksSince: 0,
|
|
2861
3253
|
taskSettledAt: 0,
|
|
2862
3254
|
// Bumped by every task-settle signal; the Stop guard compares it.
|
|
@@ -3356,7 +3748,7 @@ export function createClaudeSessionRunner({
|
|
|
3356
3748
|
settingsWindowOpen = false;
|
|
3357
3749
|
const errorText = String(error?.message || error || 'unknown error');
|
|
3358
3750
|
dbg('claude turn failed', message.id, errorText);
|
|
3359
|
-
await publisher.publishTurnException({ message, errorText });
|
|
3751
|
+
await publisher.publishTurnException({ message, errorText, usageLimit: usageLimit.classifyException(error) });
|
|
3360
3752
|
return true;
|
|
3361
3753
|
} finally {
|
|
3362
3754
|
endAdmission();
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
14
14
|
import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
|
|
15
15
|
import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
|
|
16
|
+
import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
|
|
16
17
|
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
17
18
|
import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
|
|
18
19
|
import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
|
|
@@ -20,6 +21,10 @@ import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
|
20
21
|
import { createClaudeSessionRunner } from './claude-session-process.mjs';
|
|
21
22
|
import { createRunnerLinkBridge, failSettlingRowsOnShutdown } from './claude-worker-link-wiring.mjs';
|
|
22
23
|
|
|
24
|
+
// Windows console launch: the launcher names the worker log, this copies
|
|
25
|
+
// stdout/stderr into it. A no-op wherever the launcher redirects instead.
|
|
26
|
+
installWorkerLogFile();
|
|
27
|
+
|
|
23
28
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
29
|
const HEARTBEAT_MS = 10_000;
|
|
25
30
|
|