@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.
Files changed (63) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +168 -0
  4. package/README.md +20 -4
  5. package/package.json +2 -1
  6. package/server/claude-worker/claude-session-process.mjs +118 -1
  7. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  8. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  9. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  10. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  11. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  12. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  13. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  15. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  16. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  17. package/server/db-schema.mjs +7 -0
  18. package/server/grok-worker/acp-client.mjs +68 -12
  19. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  20. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  21. package/server/public/app/api-client.js +17 -2
  22. package/server/public/app/bootstrap.js +3 -0
  23. package/server/public/app/composer-control-state.mjs +4 -3
  24. package/server/public/app/conversation-view.js +95 -4
  25. package/server/public/app/journal-view.js +6 -0
  26. package/server/public/app/markdown-strikethrough.mjs +31 -0
  27. package/server/public/app/relay-board-inline.mjs +70 -0
  28. package/server/public/app/relay-board-view.js +157 -61
  29. package/server/public/app/relay-error-ctas.mjs +12 -3
  30. package/server/public/app/socket-handlers.js +9 -0
  31. package/server/public/app/store.js +4 -1
  32. package/server/public/app/subagent-fold.mjs +61 -0
  33. package/server/public/app/usage-limit-ui.js +225 -0
  34. package/server/public/index.html +90 -1
  35. package/server/repositories/message-repository.mjs +10 -1
  36. package/server/repositories/question-repository.mjs +1 -0
  37. package/server/repositories/remote-relay-repository.mjs +30 -0
  38. package/server/routes/messages-routes.mjs +85 -14
  39. package/server/routes/relay-board-routes.mjs +56 -2
  40. package/server/routes/sessions-routes.mjs +36 -0
  41. package/server/server-runtime.mjs +33 -2
  42. package/server/services/preview-proxy-server.mjs +64 -36
  43. package/server/services/process-list-service.mjs +122 -0
  44. package/server/services/relay-console-log.mjs +199 -0
  45. package/server/services/remote-relay-caller-context.mjs +37 -17
  46. package/server/services/remote-relay-dispatcher.mjs +72 -8
  47. package/server/services/remote-relay-inbound.mjs +35 -0
  48. package/server/services/session-worker-process-service.mjs +85 -32
  49. package/server/services/session-worker-registry-service.mjs +1 -1
  50. package/server/services/session-worker-supervisor-service.mjs +33 -1
  51. package/server/services/usage-limit-pause-service.mjs +441 -0
  52. package/shared/ask-user-bridge.mjs +14 -39
  53. package/shared/claude-usage-limit.mjs +257 -0
  54. package/shared/control-poller.mjs +69 -20
  55. package/shared/failure-note-text.mjs +38 -0
  56. package/shared/question-wait.mjs +108 -0
  57. package/shared/remote-relay-contract.mjs +16 -2
  58. package/shared/steer-settle-failure.mjs +16 -0
  59. package/shared/steer-settle-markers.mjs +8 -0
  60. package/shared/worker-runtime/process-tree.mjs +483 -0
  61. package/shared/worker-runtime/relay-errors.mjs +31 -0
  62. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  63. 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
+ }