clay-server 5.2.1-beta.1 → 5.2.1-beta.2

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/lib/sdk-bridge.js CHANGED
@@ -1413,7 +1413,7 @@ function createSDKBridge(opts) {
1413
1413
  guardedPush.exactProductDelivery = !!(beforePush && beforePush.exactProductDelivery);
1414
1414
  session._queryGeneration = (session._queryGeneration || 0) + 1;
1415
1415
  session._queryStarting = true;
1416
- var starting = startQueryInner(session, text, images, linuxUser, guardedPush, onAccepted, recoveryState);
1416
+ var starting = startQueryInner(session, text, images, linuxUser, guardedPush, onAccepted, recoveryState, payload);
1417
1417
  if (recoveryState) recoveryState.starting = starting;
1418
1418
  try {
1419
1419
  var accepted = await starting;
@@ -1494,7 +1494,7 @@ function createSDKBridge(opts) {
1494
1494
  };
1495
1495
  }
1496
1496
 
1497
- async function startQueryInner(session, text, images, linuxUser, beforePush, onAccepted, recoveryState) {
1497
+ async function startQueryInner(session, text, images, linuxUser, beforePush, onAccepted, recoveryState, admissionPayload) {
1498
1498
  var sessionRuntimeEnv = getRuntimeEnv(session);
1499
1499
  var resumedAtQueryStart = !!session.cliSessionId;
1500
1500
  async function ensureVendorReady(vendor) {
@@ -1939,6 +1939,10 @@ function createSDKBridge(opts) {
1939
1939
  if (waiting) session._providerRecoveryWaiting = queryGeneration;
1940
1940
  else delete session._providerRecoveryWaiting;
1941
1941
  },
1942
+ onRecoveryDiagnostic: function(evidence) {
1943
+ if (sm.sessions.get(session.localId) !== session || Number(session._sdkQueryGeneration || 0) !== queryGeneration) return;
1944
+ console.info('[sdk-bridge] Codex recovery admission ' + JSON.stringify({ sessionId: session.localId, queryGeneration: queryGeneration, evidence: evidence }));
1945
+ },
1942
1946
  onRecoveryWaiting: function(waiting) {
1943
1947
  if (!queryContextAvailable()) return;
1944
1948
  sendAndRecord(session, { type: 'informational', level: 'info', content: waiting
@@ -2050,7 +2054,16 @@ function createSDKBridge(opts) {
2050
2054
  session.queryInstance = null;
2051
2055
  session.messageQueue = null;
2052
2056
  session.abortController = null;
2053
- sendAndRecord(session, { type: "error", text: "Failed to start query: " + (e.message || e) });
2057
+ var startupError = { type: "error", text: "Failed to start query: " + (e.message || e) };
2058
+ if (e.code === 'CODEX_RECOVERY_UNAVAILABLE' || e.code === 'CODEX_RECOVERY_PROBE_UNAVAILABLE') {
2059
+ // Keep undelivered input with the persisted failure, outside the live
2060
+ // input queue. A subsequent explicit send must not replay this batch.
2061
+ startupError.recoveryAdmission = { state: 'not_sent', messages: [admissionPayload].concat(session.pendingPush || []).filter(Boolean).map(function(message) {
2062
+ return { text: message.text, images: message.images || null };
2063
+ }) };
2064
+ startupError.text += ' Messages received during this wait were also not sent; they remain retained with this conversation. Send the messages you want to retry explicitly.';
2065
+ }
2066
+ sendAndRecord(session, startupError);
2054
2067
  sendAndRecord(session, { type: "done", code: 1 });
2055
2068
  sm.broadcastSessionList();
2056
2069
  return false;
@@ -1480,6 +1480,7 @@ function createCodexAdapter(opts) {
1480
1480
  var _recoveryIdleShutdown = null;
1481
1481
  var _activeQueries = [];
1482
1482
  var recoveryAdmission = require("../codex-recovery-admission").attachRecoveryAdmission({
1483
+ timeoutMs: opts.recoveryAdmissionTimeoutMs,
1483
1484
  state: function() { return { epoch: _shutdownEpoch, shuttingDown: _shuttingDown, retiring: _recoveryIdleShutdown === _shutdownEpoch,
1484
1485
  initializing: _initializing, refCount: _refCount, server: _appServer, entries: _activeQueries.slice() }; },
1485
1486
  shutdownError: createShutdownError,
@@ -237,7 +237,9 @@ CodexAppServer.prototype._handleMessage = function(msg) {
237
237
  delete this.pendingRequests[msg.id];
238
238
  if (pending.timer) clearTimeout(pending.timer);
239
239
  if (msg.error) {
240
- pending.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
240
+ var rpcError = new Error(msg.error.message || JSON.stringify(msg.error));
241
+ rpcError.code = msg.error.code;
242
+ pending.reject(rpcError);
241
243
  } else {
242
244
  pending.resolve(msg.result);
243
245
  }
@@ -21,24 +21,50 @@ function terminalsAreIdle(result) {
21
21
  });
22
22
  }
23
23
 
24
+ function observe(checkCurrent, reason, method, rpcCode, threadId) {
25
+ if (typeof checkCurrent.observe === 'function') checkCurrent.observe(reason, method, rpcCode, threadId);
26
+ }
27
+
28
+ async function probe(server, method, params, timeout, checkCurrent) {
29
+ observe(checkCurrent, 'probe_started', method, null, params.threadId);
30
+ try {
31
+ var result = await server.send(method, params, timeout);
32
+ observe(checkCurrent, 'probe_returned', method, null, params.threadId);
33
+ return result;
34
+ } catch (error) {
35
+ observe(checkCurrent, 'probe_failed', method, error && Number.isInteger(error.code) ? error.code : null, params.threadId);
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ function probeFailure(error, method) {
41
+ if (error && (error.code === -32601 || error.code === -32602)) {
42
+ var unsupported = new Error('Codex cannot verify recovery safety: ' + method + ' is unavailable or incompatible. No message was sent to the runtime.');
43
+ unsupported.code = 'CODEX_RECOVERY_PROBE_UNAVAILABLE';
44
+ throw unsupported;
45
+ }
46
+ return false;
47
+ }
48
+
24
49
  async function retireIdleQueries(entries, server, checkCurrent) {
25
50
  checkCurrent();
26
51
  var candidates = [];
27
52
  for (var i = 0; i < entries.length; i++) {
28
53
  var handle = entries[i].handle;
29
54
  var snapshot = handle && handle.getIdleRecoverySnapshot && handle.getIdleRecoverySnapshot();
30
- if (!snapshot) return false;
55
+ if (!snapshot) { observe(checkCurrent, 'idle_input_snapshot_unavailable', null, null, handle && handle.getRecoveryThreadId && handle.getRecoveryThreadId()); return false; }
31
56
  candidates.push({ handle: handle, snapshot: snapshot });
32
57
  }
33
58
  var safe = await Promise.all(candidates.map(function(candidate) {
34
- return server.send("thread/backgroundTerminals/list", {
59
+ return probe(server, "thread/backgroundTerminals/list", {
35
60
  threadId: candidate.snapshot.threadId,
36
- }, 5000).then(terminalsAreIdle, function() { return false; });
61
+ }, 5000, checkCurrent).then(terminalsAreIdle, function(error) { return probeFailure(error, 'thread/backgroundTerminals/list'); });
37
62
  }));
38
63
  checkCurrent();
39
64
  for (var ci = 0; ci < candidates.length; ci++) {
40
65
  var current = candidates[ci].handle.getIdleRecoverySnapshot();
41
- if (!safe[ci] || !current || current.revision !== candidates[ci].snapshot.revision) return false;
66
+ if (!safe[ci]) { observe(checkCurrent, 'background_state_not_proven_idle', null, null, candidates[ci].snapshot.threadId); return false; }
67
+ if (!current || current.revision !== candidates[ci].snapshot.revision) { observe(checkCurrent, 'input_changed_during_probe', null, null, candidates[ci].snapshot.threadId); return false; }
42
68
  }
43
69
  // No awaits between validation and closing every input. A new send either
44
70
  // already invalidated its snapshot or sees a closed input and resumes anew.
@@ -58,33 +84,64 @@ async function loadedThreads(server, checkCurrent) {
58
84
  var deadline = Date.now() + 5000;
59
85
  for (var page = 0; page < 20; page++) {
60
86
  var remaining = deadline - Date.now();
61
- if (remaining <= 0) return null;
87
+ if (remaining <= 0) { observe(checkCurrent, 'loaded_list_deadline'); return null; }
62
88
  var result;
63
89
  try {
64
- result = await server.send('thread/loaded/list', { cursor: cursor, limit: 100 }, remaining);
90
+ result = await probe(server, 'thread/loaded/list', { cursor: cursor, limit: 100 }, remaining, checkCurrent);
65
91
  } catch (error) {
66
92
  checkCurrent();
67
93
  if (!server.started) throw error;
94
+ probeFailure(error, 'thread/loaded/list');
68
95
  return null;
69
96
  }
70
97
  checkCurrent();
71
- if (!result || !Array.isArray(result.data) || !result.data.every(function(id) { return typeof id === 'string'; })) return null;
98
+ if (!result || !Array.isArray(result.data) || !result.data.every(function(id) { return typeof id === 'string'; })) { observe(checkCurrent, 'loaded_list_invalid'); return null; }
72
99
  threads = threads.concat(result.data);
73
100
  if (!result.nextCursor) return threads;
74
- if (result.nextCursor === cursor) return null;
101
+ if (result.nextCursor === cursor) { observe(checkCurrent, 'loaded_list_cursor_repeated'); return null; }
75
102
  cursor = result.nextCursor;
76
103
  }
104
+ observe(checkCurrent, 'loaded_list_page_limit');
77
105
  return null;
78
106
  }
79
107
 
80
108
  async function threadIsAbsent(server, threadId, checkCurrent) {
81
109
  var threads = await loadedThreads(server, checkCurrent);
110
+ if (threads && threads.indexOf(threadId) !== -1) observe(checkCurrent, 'target_still_loaded', null, null, threadId);
82
111
  return !!threads && threads.indexOf(threadId) === -1;
83
112
  }
84
113
 
85
114
  async function runtimeCanStop(server, retiredThreads, checkCurrent) {
86
115
  var threads = await loadedThreads(server, checkCurrent);
87
- return !!threads && threads.every(function(threadId) { return retiredThreads.indexOf(threadId) !== -1; });
116
+ if (!threads) return false;
117
+ // The caller has no live handles and holds admission. Closed handles can
118
+ // leave loaded threads that unsubscribe cannot release; prove every such
119
+ // thread idle before stopping their shared process, including siblings.
120
+ for (var threadId of threads) {
121
+ if (retiredThreads.indexOf(threadId) === -1 && !await threadIsIdle(server, threadId, checkCurrent)) return false;
122
+ }
123
+ checkCurrent();
124
+ return true;
125
+ }
126
+
127
+ async function threadIsIdle(server, threadId, checkCurrent) {
128
+ var method = 'thread/read';
129
+ try {
130
+ var status = await probe(server, method, { threadId: threadId, includeTurns: false }, 5000, checkCurrent);
131
+ checkCurrent();
132
+ if (!status || !status.thread || status.thread.id !== threadId
133
+ || !status.thread.status || status.thread.status.type !== 'idle') {
134
+ observe(checkCurrent, status && status.thread && status.thread.id === threadId && status.thread.status && status.thread.status.type === 'active'
135
+ ? 'provider_reports_active' : 'provider_idle_not_proven', method, null, threadId);
136
+ return false;
137
+ }
138
+ method = 'thread/backgroundTerminals/list';
139
+ var terminals = await probe(server, method, { threadId: threadId }, 5000, checkCurrent);
140
+ checkCurrent();
141
+ var idle = terminalsAreIdle(terminals);
142
+ if (!idle) observe(checkCurrent, 'background_state_not_proven_idle', method, null, threadId);
143
+ return idle;
144
+ } catch (error) { checkCurrent(); if (!server.started) throw error; return probeFailure(error, method); }
88
145
  }
89
146
 
90
147
  async function releaseThread(entries, server, threadId, checkCurrent) {
@@ -99,18 +156,14 @@ async function releaseThread(entries, server, threadId, checkCurrent) {
99
156
  } else {
100
157
  // A closed SDK handle can leave a loaded provider thread behind. Only an
101
158
  // explicitly idle provider thread with verified terminal state may unload.
102
- try {
103
- var status = await server.send('thread/read', { threadId: threadId, includeTurns: false }, 5000);
104
- checkCurrent();
105
- if (!status || !status.thread || status.thread.id !== threadId
106
- || !status.thread.status || status.thread.status.type !== 'idle') return false;
107
- var terminals = await server.send('thread/backgroundTerminals/list', { threadId: threadId }, 5000);
108
- checkCurrent();
109
- if (!terminalsAreIdle(terminals)) return false;
110
- } catch (error) { checkCurrent(); if (!server.started) throw error; return false; }
159
+ if (!await threadIsIdle(server, threadId, checkCurrent)) return false;
160
+ }
161
+ try {
162
+ var result = await probe(server, 'thread/unsubscribe', { threadId: threadId }, 5000, checkCurrent);
163
+ var status = result && result.status;
164
+ observe(checkCurrent, ['unsubscribed', 'notSubscribed', 'notLoaded'].indexOf(status) !== -1 ? 'unsubscribe_' + status : 'unsubscribe_status_unknown', 'thread/unsubscribe', null, threadId);
111
165
  }
112
- try { await server.send('thread/unsubscribe', { threadId: threadId }, 5000); }
113
- catch (error) { checkCurrent(); if (!server.started) throw error; return false; }
166
+ catch (error) { checkCurrent(); if (!server.started) throw error; return probeFailure(error, 'thread/unsubscribe'); }
114
167
  return threadIsAbsent(server, threadId, checkCurrent);
115
168
  }
116
169
 
@@ -1,4 +1,5 @@
1
1
  var idleRecovery = require('./codex-idle-recovery');
2
+ var crypto = require('crypto');
2
3
 
3
4
  // Serialize decisions, not whole model turns. Waiting for a target must release
4
5
  // the gate so an unrelated Driver can still answer its Worker's permissions.
@@ -57,6 +58,8 @@ function attachRecoveryAdmission(ctx) {
57
58
  handoff = tail;
58
59
  tail.then(function() { if (handoff === tail) handoff = null; });
59
60
  try {
61
+ checkCurrent.phase('admission_gate');
62
+ if (prior) checkCurrent.observe('admission_gate_pending');
60
63
  await idleRecovery.wait(prior || Promise.resolve(), checkCurrent);
61
64
  await idleRecovery.wait(cleanup, checkCurrent);
62
65
  checkSharedExit();
@@ -69,8 +72,10 @@ function attachRecoveryAdmission(ctx) {
69
72
  }
70
73
 
71
74
  async function releaseTarget(threadId, checkCurrent) {
75
+ checkCurrent.phase('isolated_target');
72
76
  var prior = targets.get(threadId);
73
77
  if (!prior) return true;
78
+ checkCurrent.observe('isolated_target_reserved');
74
79
  if (cleanupFailures.has(prior)) throw cleanupFailures.get(prior);
75
80
  await prior.retireIdleForRecovery(checkCurrent);
76
81
  await cleanup;
@@ -80,8 +85,19 @@ function attachRecoveryAdmission(ctx) {
80
85
  }
81
86
 
82
87
  async function prepareShared(threadId, checkCurrent) {
88
+ checkCurrent.phase('shared_target');
83
89
  var state = ctx.state();
84
- if (state.initializing) return false;
90
+ if (state.initializing) { checkCurrent.observe('shared_runtime_initializing'); return false; }
91
+ // Independent conversations do not participate in target handoff. Probing
92
+ // every idle sibling first can consume the whole admission budget even
93
+ // when this target can be unloaded immediately.
94
+ if (state.server && state.entries.some(function(entry) {
95
+ return !entry.handle || entry.handle.getRecoveryThreadId() !== threadId;
96
+ })) {
97
+ if (await idleRecovery.releaseThread(state.entries, state.server, threadId, checkCurrent)) return true;
98
+ checkCurrent();
99
+ }
100
+ checkCurrent.phase('shared_idle_retirement');
85
101
  var retiredThreads = [];
86
102
  if (state.refCount > 0) {
87
103
  var snapshots = state.entries.map(function(entry) { return entry.handle && entry.handle.getIdleRecoverySnapshot(); });
@@ -91,17 +107,52 @@ function attachRecoveryAdmission(ctx) {
91
107
  }
92
108
  state = ctx.state();
93
109
  if (!state.server) return true;
110
+ checkCurrent.phase('shared_runtime_release');
94
111
  if (!state.refCount && await idleRecovery.runtimeCanStop(state.server, retiredThreads, checkCurrent)) {
95
112
  var proc = state.server.proc;
96
113
  var stopped = await ctx.stopIdle();
97
114
  if (stopped) { unconfirmedSharedProcess = proc || null; checkSharedExit(); }
98
115
  return stopped;
99
116
  }
117
+ checkCurrent.phase('shared_target');
100
118
  return idleRecovery.releaseThread(state.entries, state.server, threadId, checkCurrent);
101
119
  }
102
120
 
103
121
  async function waitUntilAvailable(queryOpts, action) {
104
- var checkCurrent = checker(queryOpts);
122
+ var checkLifecycle = checker(queryOpts);
123
+ var startedAt = Date.now();
124
+ var deadline = startedAt + (ctx.timeoutMs || 15000);
125
+ var admitted = false;
126
+ var observations = new Set();
127
+ var lastEvidence = null;
128
+ var ended = false;
129
+ var phase = 'admission_gate';
130
+ function targetKey(threadId) {
131
+ return crypto.createHash('sha256').update(String(threadId || queryOpts.resumeSessionId || '')).digest('hex').slice(0, 16);
132
+ }
133
+ checkCurrent.phase = function(value) { phase = value; };
134
+ function checkCurrent() {
135
+ checkLifecycle();
136
+ if (!admitted && Date.now() >= deadline) {
137
+ var error = new Error('Tool recovery could not verify that this conversation is safe to resume. No message was sent to the runtime. Review your retained messages and send again when the runtime is available.');
138
+ error.code = 'CODEX_RECOVERY_UNAVAILABLE';
139
+ throw error;
140
+ }
141
+ }
142
+ // Fixed evidence labels only: never log thread contents, RPC payloads,
143
+ // commands, credentials, or provider error strings. Bound polling noise.
144
+ checkCurrent.observe = function(reason, method, rpcCode, threadId) {
145
+ if (ended) return;
146
+ var evidence = { reason: reason, phase: phase, targetKey: targetKey(threadId), method: method || null, rpcCode: Number.isInteger(rpcCode) ? rpcCode : null };
147
+ lastEvidence = evidence;
148
+ var key = JSON.stringify(evidence);
149
+ if (observations.has(key) || observations.size >= 24) return;
150
+ observations.add(key);
151
+ if (typeof queryOpts.onRecoveryDiagnostic === 'function') {
152
+ try { queryOpts.onRecoveryDiagnostic(Object.assign({ elapsedMs: Date.now() - startedAt }, evidence)); } catch (error) {}
153
+ }
154
+ };
155
+ checkCurrent.admit = function() { checkCurrent(); admitted = true; };
105
156
  var waiting = false;
106
157
  if (typeof queryOpts.onRecoveryAdmission === 'function') queryOpts.onRecoveryAdmission(true);
107
158
  try {
@@ -119,6 +170,10 @@ function attachRecoveryAdmission(ctx) {
119
170
  await idleRecovery.wait(new Promise(function(resolve) { setTimeout(resolve, 50); }), checkCurrent);
120
171
  }
121
172
  } finally {
173
+ ended = true;
174
+ if (typeof queryOpts.onRecoveryDiagnostic === 'function') {
175
+ try { queryOpts.onRecoveryDiagnostic({ reason: admitted ? 'admission_granted' : 'admission_ended_without_grant', phase: phase, targetKey: targetKey(), elapsedMs: Date.now() - startedAt, lastEvidence: lastEvidence }); } catch (error) {}
176
+ }
122
177
  if (typeof queryOpts.onRecoveryAdmission === 'function') queryOpts.onRecoveryAdmission(false);
123
178
  }
124
179
  }
@@ -127,7 +182,9 @@ function attachRecoveryAdmission(ctx) {
127
182
  return waitUntilAvailable(queryOpts, async function(checkCurrent) {
128
183
  if (!await releaseTarget(queryOpts.resumeSessionId, checkCurrent)) return false;
129
184
  if (!await prepareShared(queryOpts.resumeSessionId, checkCurrent)) return false;
130
- checkCurrent();
185
+ // Once ownership is reserved, normal startup/cleanup owns cancellation.
186
+ // A deadline must not discard a handle being created after admission.
187
+ checkCurrent.admit();
131
188
  var runtime = ctx.createRuntime(queryOpts);
132
189
  runtimes.push(runtime);
133
190
  targets.set(queryOpts.resumeSessionId, runtime);
@@ -149,7 +206,11 @@ function attachRecoveryAdmission(ctx) {
149
206
  checkSharedExit: checkSharedExit,
150
207
  hasTarget: function(threadId) { return targets.has(threadId); },
151
208
  waitForTarget: function(queryOpts) {
152
- return waitUntilAvailable(queryOpts, function(checkCurrent) { return releaseTarget(queryOpts.resumeSessionId, checkCurrent); });
209
+ return waitUntilAvailable(queryOpts, async function(checkCurrent) {
210
+ var released = await releaseTarget(queryOpts.resumeSessionId, checkCurrent);
211
+ if (released) checkCurrent.admit();
212
+ return released;
213
+ });
153
214
  },
154
215
  shutdown: function() {
155
216
  return Promise.all(runtimes.slice().map(cleanupRuntime)).then(function(results) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "5.2.1-beta.1",
3
+ "version": "5.2.1-beta.2",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js"