@oar-sh/oar 0.9.5 → 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 +168 -0
- package/README.md +20 -4
- package/package.json +2 -1
- package/server/claude-worker/claude-session-process.mjs +118 -1
- 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/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- 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-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 +95 -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/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 +90 -1
- 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 +85 -14
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/sessions-routes.mjs +36 -0
- 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 +37 -17
- package/server/services/remote-relay-dispatcher.mjs +72 -8
- package/server/services/remote-relay-inbound.mjs +35 -0
- 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 +16 -2
- package/shared/steer-settle-failure.mjs +16 -0
- package/shared/steer-settle-markers.mjs +8 -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
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// How long a turn may stay quiet, given what its runtime is doing.
|
|
2
|
+
//
|
|
3
|
+
// Every session worker used to fail a turn after 120 s without traffic from
|
|
4
|
+
// its runtime, whatever the runtime was busy with. Silence does not mean the
|
|
5
|
+
// same thing in every phase. Measured on the Copilot runtime 1.0.88
|
|
6
|
+
// (2026-09-27, scripted provider unless noted):
|
|
7
|
+
//
|
|
8
|
+
// - a tool that prints nothing (a 150 s shell command): not one event from
|
|
9
|
+
// `tool.execution_start` to its completion, on a perfectly healthy turn;
|
|
10
|
+
// - a model request (hosted, gpt-5.4-mini): a delta every few seconds while
|
|
11
|
+
// the model reasons (246 s at the highest effort, longest gap 8.8 s) or
|
|
12
|
+
// writes a large tool call (51 KB, longest gap 3.8 s) — and nothing at all
|
|
13
|
+
// while the provider's stream itself is silent, which the runtime waits out
|
|
14
|
+
// without a timeout of its own;
|
|
15
|
+
// - the runtime answers a ping within milliseconds in both cases, so a ping
|
|
16
|
+
// tells a busy runtime from a gone one, not a slow model from a stuck one.
|
|
17
|
+
//
|
|
18
|
+
// So the window depends on the phase, and the phase comes from the events the
|
|
19
|
+
// worker already receives: a tool that has started and not finished, a model
|
|
20
|
+
// request that has started and not finished, or neither. This module keeps
|
|
21
|
+
// that state and answers one question — has this turn been quiet for too
|
|
22
|
+
// long? — so the three workers share the rule instead of each carrying a copy
|
|
23
|
+
// of a fixed number. What they do about the answer (cancel the run, stop the
|
|
24
|
+
// runtime, publish the failure) stays theirs.
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `idle`: nothing is in flight, so whatever the runtime does next is its own
|
|
28
|
+
* bookkeeping and should take no time. `model`: far above any gap a healthy
|
|
29
|
+
* stream showed, because failing a turn that was still being written costs the
|
|
30
|
+
* user more than three extra minutes on one that was stuck. `tool`: a build or
|
|
31
|
+
* a test run may print nothing for a long time; the tool's own timeout and the
|
|
32
|
+
* relay's turn ceiling bound it as well.
|
|
33
|
+
*
|
|
34
|
+
* 0 means no limit for that phase; an `idle` of 0 turns the watchdog off.
|
|
35
|
+
*/
|
|
36
|
+
export const DEFAULT_TURN_STALL_WINDOWS_MS = Object.freeze({
|
|
37
|
+
idle: 120_000,
|
|
38
|
+
model: 300_000,
|
|
39
|
+
tool: 30 * 60_000,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const TURN_STALL_PHASES = Object.freeze(['idle', 'model', 'tool']);
|
|
43
|
+
|
|
44
|
+
const ENV_BY_PHASE = Object.freeze({
|
|
45
|
+
idle: 'OAR_TURN_STALL_IDLE_MS',
|
|
46
|
+
model: 'OAR_TURN_STALL_MODEL_MS',
|
|
47
|
+
tool: 'OAR_TURN_STALL_TOOL_MS',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function readMs(value) {
|
|
51
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
52
|
+
const parsed = Number(value);
|
|
53
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The windows a worker runs with: an explicit value wins, then the
|
|
58
|
+
* environment (`OAR_TURN_STALL_{IDLE,MODEL,TOOL}_MS`), then the default.
|
|
59
|
+
*
|
|
60
|
+
* `idleMs` is also what a worker's older single setting maps to. When it is
|
|
61
|
+
* the only thing set, the other two windows keep their proportion to it
|
|
62
|
+
* instead of their absolute defaults: whoever raised the one number to get
|
|
63
|
+
* through long silences must not end up with a model window below it, and a
|
|
64
|
+
* test that shortens it to milliseconds gets all three shortened.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveTurnStallWindows({ env = process.env, idleMs, modelMs, toolMs } = {}) {
|
|
67
|
+
const explicit = { idle: readMs(idleMs), model: readMs(modelMs), tool: readMs(toolMs) };
|
|
68
|
+
const idle = explicit.idle ?? readMs(env?.[ENV_BY_PHASE.idle]) ?? DEFAULT_TURN_STALL_WINDOWS_MS.idle;
|
|
69
|
+
const scale = idle / DEFAULT_TURN_STALL_WINDOWS_MS.idle;
|
|
70
|
+
const windows = { idle };
|
|
71
|
+
for (const phase of ['model', 'tool']) {
|
|
72
|
+
windows[phase] = explicit[phase]
|
|
73
|
+
?? readMs(env?.[ENV_BY_PHASE[phase]])
|
|
74
|
+
?? Math.round(DEFAULT_TURN_STALL_WINDOWS_MS[phase] * scale);
|
|
75
|
+
}
|
|
76
|
+
return windows;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const unlimited = (ms) => !(Number(ms) > 0);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The liveness state of one turn.
|
|
83
|
+
*
|
|
84
|
+
* `traffic()` is any sign of life from the runtime. `begin`/`end` bracket
|
|
85
|
+
* work in flight, by id, so parallel tool calls and subagents' model
|
|
86
|
+
* requests each count once. `check()` compares the silence with the window
|
|
87
|
+
* of the most tolerant phase that is open.
|
|
88
|
+
*/
|
|
89
|
+
export function createTurnLiveness({ windows = DEFAULT_TURN_STALL_WINDOWS_MS, now = Date.now } = {}) {
|
|
90
|
+
let lastTrafficAt = now();
|
|
91
|
+
let lastTrafficType = '';
|
|
92
|
+
const open = { model: new Map(), tool: new Map() };
|
|
93
|
+
|
|
94
|
+
function phase() {
|
|
95
|
+
let current = 'idle';
|
|
96
|
+
for (const candidate of ['model', 'tool']) {
|
|
97
|
+
if (!open[candidate].size) continue;
|
|
98
|
+
if (current === 'idle') { current = candidate; continue; }
|
|
99
|
+
const held = windows[current];
|
|
100
|
+
const next = windows[candidate];
|
|
101
|
+
if (unlimited(next) || (!unlimited(held) && next > held)) current = candidate;
|
|
102
|
+
}
|
|
103
|
+
return current;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
traffic(type = '') {
|
|
108
|
+
lastTrafficAt = now();
|
|
109
|
+
lastTrafficType = String(type || '');
|
|
110
|
+
},
|
|
111
|
+
begin(kind, id, label = '') {
|
|
112
|
+
const key = String(id || '').trim();
|
|
113
|
+
if (!open[kind] || !key) return;
|
|
114
|
+
open[kind].set(key, String(label || '').trim());
|
|
115
|
+
},
|
|
116
|
+
end(kind, id) {
|
|
117
|
+
open[kind]?.delete(String(id || '').trim());
|
|
118
|
+
},
|
|
119
|
+
/**
|
|
120
|
+
* Close what one agent had open: its model request (id = the agent) and
|
|
121
|
+
* its tools (ids `<agent>:<call>`). For a subagent that ended.
|
|
122
|
+
*/
|
|
123
|
+
endAgent(agent) {
|
|
124
|
+
const key = String(agent || '').trim();
|
|
125
|
+
if (!key) return;
|
|
126
|
+
open.model.delete(key);
|
|
127
|
+
for (const id of [...open.tool.keys()]) {
|
|
128
|
+
if (id.startsWith(`${key}:`)) open.tool.delete(id);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
/** Nothing is in flight any more (the turn's terminator, a new turn). */
|
|
132
|
+
reset() {
|
|
133
|
+
open.model.clear();
|
|
134
|
+
open.tool.clear();
|
|
135
|
+
lastTrafficAt = now();
|
|
136
|
+
lastTrafficType = '';
|
|
137
|
+
},
|
|
138
|
+
phase,
|
|
139
|
+
quietMs: () => Math.max(0, now() - lastTrafficAt),
|
|
140
|
+
/** The labels of the tools in flight, without repeats. */
|
|
141
|
+
toolsInFlight: () => [...new Set([...open.tool.values()].filter(Boolean))],
|
|
142
|
+
/**
|
|
143
|
+
* The tools one agent has in flight (ids `<agent>:<call>`): one label per
|
|
144
|
+
* call, repeats kept, '' for a call without a name. For a caller that
|
|
145
|
+
* has to know what the main agent waits for, whatever its subagents do.
|
|
146
|
+
*/
|
|
147
|
+
toolsOf(agent) {
|
|
148
|
+
const key = String(agent || '').trim();
|
|
149
|
+
if (!key) return [];
|
|
150
|
+
const labels = [];
|
|
151
|
+
for (const [id, label] of open.tool) {
|
|
152
|
+
if (id.startsWith(`${key}:`)) labels.push(label);
|
|
153
|
+
}
|
|
154
|
+
return labels;
|
|
155
|
+
},
|
|
156
|
+
/**
|
|
157
|
+
* `stalled` once the silence has reached the window of the current phase;
|
|
158
|
+
* otherwise `waitMs` is when to look again: when the window runs out, but
|
|
159
|
+
* no later than the next multiple of the idle window, which is where a
|
|
160
|
+
* worker with a probe asks its runtime whether it is still there.
|
|
161
|
+
*/
|
|
162
|
+
check({ quietMs: measuredQuietMs } = {}) {
|
|
163
|
+
const current = phase();
|
|
164
|
+
const limitMs = Number(windows[current]) || 0;
|
|
165
|
+
// A worker whose transport keeps its own activity clock passes the
|
|
166
|
+
// silence in; the phases are still this module's.
|
|
167
|
+
const quietMs = Number.isFinite(measuredQuietMs)
|
|
168
|
+
? Math.max(0, measuredQuietMs)
|
|
169
|
+
: Math.max(0, now() - lastTrafficAt);
|
|
170
|
+
const idleMs = unlimited(windows.idle) ? DEFAULT_TURN_STALL_WINDOWS_MS.idle : windows.idle;
|
|
171
|
+
const step = Math.max(1, idleMs - (quietMs % idleMs));
|
|
172
|
+
if (unlimited(limitMs)) return { phase: current, quietMs, limitMs: 0, stalled: false, waitMs: step };
|
|
173
|
+
if (quietMs >= limitMs) return { phase: current, quietMs, limitMs, stalled: true, waitMs: 0 };
|
|
174
|
+
return { phase: current, quietMs, limitMs, stalled: false, waitMs: Math.max(1, Math.min(limitMs - quietMs, step)) };
|
|
175
|
+
},
|
|
176
|
+
/** One line for the worker's log when a turn is failed or kept. */
|
|
177
|
+
describe() {
|
|
178
|
+
const tools = [...open.tool.values()].filter(Boolean);
|
|
179
|
+
return `phase=${phase()} quiet=${Math.round((now() - lastTrafficAt) / 1000)}s`
|
|
180
|
+
+ ` last=${lastTrafficType || 'none'} model=${open.model.size} tools=${open.tool.size}`
|
|
181
|
+
+ `${tools.length ? ` (${[...new Set(tools)].join(', ')})` : ''}`;
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Ask a runtime whether it is still there. `'alive'` when `probe` resolves in
|
|
188
|
+
* time, `'gone'` when it rejects or does not answer, `'none'` when the worker
|
|
189
|
+
* has no way to ask. Never rejects.
|
|
190
|
+
*/
|
|
191
|
+
export async function probeRuntime(probe, timeoutMs = 5_000) {
|
|
192
|
+
if (typeof probe !== 'function') return 'none';
|
|
193
|
+
let timer = null;
|
|
194
|
+
try {
|
|
195
|
+
return await Promise.race([
|
|
196
|
+
Promise.resolve().then(() => probe()).then((answer) => (answer === false ? 'gone' : 'alive'), () => 'gone'),
|
|
197
|
+
new Promise((resolve) => {
|
|
198
|
+
timer = setTimeout(() => resolve('gone'), timeoutMs);
|
|
199
|
+
timer.unref?.();
|
|
200
|
+
}),
|
|
201
|
+
]);
|
|
202
|
+
} finally {
|
|
203
|
+
if (timer) clearTimeout(timer);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const TURN_STALLED_ERROR_CODE = 'turn-stalled';
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The failure of a turn that was quiet for too long. `unresponsive` marks the
|
|
211
|
+
* other way a turn ends here: the window had not run out, but the runtime no
|
|
212
|
+
* longer answered when asked.
|
|
213
|
+
*/
|
|
214
|
+
export function createTurnStalledError({
|
|
215
|
+
agentLabel = 'agent', quietMs = 0, phase = 'idle', tools = [], unresponsive = false,
|
|
216
|
+
} = {}) {
|
|
217
|
+
const seconds = Math.round((Number(quietMs) || 0) / 1000);
|
|
218
|
+
const error = new Error(
|
|
219
|
+
`${String(agentLabel).toLowerCase()} turn stalled: no traffic for ${seconds}s`
|
|
220
|
+
+ ` (phase ${phase}${unresponsive ? ', runtime not answering' : ''})`,
|
|
221
|
+
);
|
|
222
|
+
error.code = TURN_STALLED_ERROR_CODE;
|
|
223
|
+
error.stall = {
|
|
224
|
+
agentLabel, quietSeconds: seconds, phase, tools: [...tools], unresponsive: unresponsive === true,
|
|
225
|
+
};
|
|
226
|
+
return error;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function isTurnStalledError(error) {
|
|
230
|
+
return error?.code === TURN_STALLED_ERROR_CODE && !!error?.stall;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* What the user is told. It names what the runtime was doing, because that
|
|
235
|
+
* is what tells a stuck model request from a tool that hung.
|
|
236
|
+
*/
|
|
237
|
+
export function describeTurnStall(error) {
|
|
238
|
+
const stall = error?.stall || {};
|
|
239
|
+
const label = String(stall.agentLabel || 'agent');
|
|
240
|
+
const tools = Array.isArray(stall.tools) ? stall.tools.filter(Boolean) : [];
|
|
241
|
+
const doing = stall.phase === 'tool'
|
|
242
|
+
? ` while a tool was running${tools.length ? ` (${tools.slice(0, 3).join(', ')})` : ''}`
|
|
243
|
+
: stall.phase === 'model' ? ' while a model request was running' : '';
|
|
244
|
+
if (stall.unresponsive) {
|
|
245
|
+
return `System note: the ${label} runtime stopped answering${doing}`
|
|
246
|
+
+ ` (nothing for ${stall.quietSeconds}s, no reply when asked), so the relay ended the turn.`;
|
|
247
|
+
}
|
|
248
|
+
return `System note: the ${label} runtime sent nothing for ${stall.quietSeconds}s${doing}, so the relay ended the turn.`;
|
|
249
|
+
}
|