@ctrl-spc/cs 0.7.15 → 0.7.16

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.
@@ -1,3 +1,5 @@
1
+ import { nativeFailureKind, failureMessage } from '../failure-reason.js';
2
+ import { CodexHomeFailure } from '../codex-home.js';
1
3
  /**
2
4
  * ═══ AGENT PANEL v3: running one headless agent and reading what it said. ═══
3
5
  *
@@ -181,6 +183,7 @@ export function agentArgs(level, toolsUrl, agent = harness(), platform = process
181
183
  return [
182
184
  // `-p` with the prompt on stdin. See the header.
183
185
  '-p',
186
+ '--output-format', 'json',
184
187
  '--mcp-config',
185
188
  JSON.stringify({ mcpServers: { [SERVER]: { type: 'http', url: toolsUrl } } }),
186
189
  '--strict-mcp-config',
@@ -297,6 +300,9 @@ function runKey(toolsUrl) {
297
300
  * present.
298
301
  */
299
302
  export function codexAnswer(stdout, exitCode = 0, stderr = '') {
303
+ const failureKind = nativeFailureKind(stdout, stderr);
304
+ if (failureKind !== 'unknown')
305
+ return { ok: false, failureKind, retryable: failureKind === 'transient', reason: failureMessage('codex', failureKind) };
300
306
  let text = null;
301
307
  let failure = null;
302
308
  for (const line of stdout.split('\n')) {
@@ -341,16 +347,26 @@ export function codexAnswer(stdout, exitCode = 0, stderr = '') {
341
347
  }
342
348
  return { ok: true, text: text.trim() };
343
349
  }
344
- /** Claude prints provider/model rejections on stdout, even with a nonzero exit. */
350
+ /** The native result envelope distinguishes a provider failure from answer prose. */
345
351
  export function claudeAnswer(stdout, exitCode = 0, stderr = '') {
352
+ const failureKind = nativeFailureKind(stdout, stderr);
353
+ if (failureKind !== 'unknown')
354
+ return { ok: false, failureKind, retryable: failureKind === 'transient', reason: failureMessage('claude', failureKind) };
346
355
  if (/^\[claude-code:unrecognized_model\]/m.test(stdout + '\n' + stderr)) {
347
- return { ok: false, retryable: false, reason: 'Claude rejected the selected model. Choose an available model and retry.' };
356
+ return { ok: false, failureKind: 'invalid-model', retryable: false, reason: 'Claude rejected the selected model. Choose an available model and retry.' };
348
357
  }
349
358
  if (exitCode !== 0)
350
359
  return { ok: false, reason: `claude exited ${exitCode}${tail(stderr) || tail(stdout)}` };
351
360
  if (stdout.trim() === '')
352
361
  return { ok: false, reason: `claude exited 0 and said nothing${tail(stderr)}` };
353
- return { ok: true, text: stdout.trim() };
362
+ try {
363
+ const result = JSON.parse(stdout);
364
+ if (result?.type === 'result' && result.is_error === false && typeof result.result === 'string' && result.result.trim()) {
365
+ return { ok: true, text: result.result.trim() };
366
+ }
367
+ }
368
+ catch { /* A malformed native result is not a completed answer. */ }
369
+ return { ok: false, reason: 'Claude did not return a completed result.' };
354
370
  }
355
371
  /** Enough for any answer a person reads, and a ceiling so a runaway process
356
372
  * cannot exhaust this daemon's memory. */
@@ -410,10 +426,10 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
410
426
  return { pid: null, session: Promise.resolve(null),
411
427
  answered: Promise.resolve({ ok: false, reason: 'Work was interrupted by the service command.' }),
412
428
  interrupted: lifecycle.interrupted, completed: lifecycle.complete };
413
- const failed = (reason) => ({
429
+ const failed = (reason, failureKind = 'unknown') => ({
414
430
  pid: null,
415
431
  session: Promise.resolve(null),
416
- answered: Promise.resolve({ ok: false, reason }),
432
+ answered: Promise.resolve({ ok: false, reason, failureKind, retryable: false }),
417
433
  ...(lifecycle ? { interrupted: lifecycle.interrupted, completed: lifecycle.complete } : {}),
418
434
  });
419
435
  let agent;
@@ -434,7 +450,7 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
434
450
  prompt = `${modelChoiceRules(agent)}\n\n${prompt}`;
435
451
  const bin = agentPath(agent);
436
452
  if (!bin) {
437
- return failed(`${agent} is not installed on this machine`);
453
+ return failed(`${agent} is not installed on this machine`, 'missing-binary');
438
454
  }
439
455
  /* macOS uses the proven per-run home. Windows keeps its installed home so the
440
456
  Desktop runtime's ACL-bound sandbox helpers remain valid; `codexArgs`
@@ -448,10 +464,8 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
448
464
  // pass this and keep today's behaviour; see `codex-home.ts`.
449
465
  : ensureCodexRunHome({ url: toolsUrl }, null, runKey(toolsUrl), false)
450
466
  : null;
451
- if (agent === 'codex' && !windowsCodex && !home) {
452
- return failed('codex is not signed in on this machine, so a run cannot be given this product\'s tools '
453
- + 'and nothing else. Sign codex in on the machine running this, and start it again.');
454
- }
467
+ if (home instanceof CodexHomeFailure)
468
+ return failed(home.message, home.kind);
455
469
  const { args, shell } = windowsSafeSpawn(bin, ARGS);
456
470
  let child;
457
471
  try {
@@ -482,7 +496,7 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
482
496
  // `couldNotStart`.
483
497
  if (home && !ownerSession)
484
498
  removeCodexRunHome(home);
485
- return failed(couldNotStart(err, agent));
499
+ return failed(couldNotStart(err, agent), 'preparation-unavailable');
486
500
  }
487
501
  let resolveSession;
488
502
  let sessionSettled = false;
@@ -575,7 +589,7 @@ export function startAgent(prompt, level, toolsUrl, cwd, ownerSession, settings
575
589
  WITHOUT the message, which is where node puts the binary's absolute path.
576
590
  The same failure as the throw above, arriving asynchronously. */
577
591
  child.on('error', (err) => {
578
- finish({ ok: false, reason: couldNotStart(err, agent) });
592
+ finish({ ok: false, reason: couldNotStart(err, agent), failureKind: 'preparation-unavailable', retryable: false });
579
593
  });
580
594
  child.on('close', (code, signal) => {
581
595
  /* ═══ THREE OUTCOMES, AND ONLY ONE OF THEM IS AN ANSWER. ═══ A non-zero
@@ -1,3 +1,4 @@
1
+ import { clientSessionCurrent } from '../supabase.js';
1
2
  import { createEpicHandler, createSprintHandler, listStructureHandler, updateStructureHandler, listStructureArtifactsHandler, getStructureArtifactHandler, searchAgentCardsHandler, createStructureArtifactHandler, updateStructureArtifactHandler } from '../product-tools.js';
2
3
  import { listArtifactFoldersHandler, setArtifactFolderHandler, workItemDependencyHandler } from '../product-tools.js';
3
4
  /**
@@ -3472,6 +3473,10 @@ export async function startToolsServer(client, dispatch, recoverLanding) {
3472
3473
  res.writeHead(status, { 'Content-Type': 'text/plain' }).end(why);
3473
3474
  };
3474
3475
  async function handle(req, res) {
3476
+ if (!clientSessionCurrent(client)) {
3477
+ fail(res, 401, 'This connection no longer owns the local sign-in.');
3478
+ return;
3479
+ }
3475
3480
  const url = new URL(req.url ?? '/', 'http://127.0.0.1');
3476
3481
  const parts = url.pathname.startsWith('/mcp/')
3477
3482
  ? url.pathname.slice('/mcp/'.length).split('/').filter(Boolean)
@@ -12,5 +12,8 @@ export function buildPresenceHeartbeatPayload(input, seenAt = new Date()) {
12
12
  // A beating heart is not stopped: clears the stamp a clean shutdown left,
13
13
  // so a machine that comes back reads online again.
14
14
  stopped_at: null,
15
+ session_state: 'authenticated',
16
+ session_observed_at: seenAt.toISOString(),
17
+ harness_auth: input.harnessAuth ?? {},
15
18
  };
16
19
  }
package/dist/presence.js CHANGED
@@ -1,15 +1,15 @@
1
1
  import { platform } from 'node:os';
2
- import { getClient, disposeClient, confirmedSessionRejection, NotLoggedIn } from './supabase.js';
3
- import { getMachineIdentity, mcpToken, readSession, supersededMachineIds, clearSupersededMachineIds } from './config.js';
4
- import { detectAgents } from './agents.js';
2
+ import { getClient, disposeClient, confirmedSessionRejection, NotLoggedIn, clientSessionCurrent, assertClientSession } from './supabase.js';
3
+ import { getMachineIdentity, mcpToken, readSessionRecord, supersededMachineIds, clearSupersededMachineIds } from './config.js';
4
+ import { detectAgents, probeHarnessAuth, harnessAuthEvidence, recordHarnessObservation } from './agents.js';
5
5
  import { installSkills } from './skills.js';
6
- import { startToolsServer, stopToolsServer, toolsServerStatus, registerWithClaude, registerWithCodex, unregisterFromClaude, unregisterFromCodex, agentRegStatus, heartbeatOpenSessions, setToolsClient, } from './mcp.js';
6
+ import { startToolsServer, stopToolsServer, toolsServerStatus, registerWithClaude, registerWithCodex, unregisterFromClaude, unregisterFromCodex, agentRegStatus, heartbeatOpenSessions, reconcileMcpCleanup, } from './mcp.js';
7
7
  import { HEARTBEAT_INTERVAL_MS, COMMAND_POLL_INTERVAL_MS, ORCHESTRATOR_POLL_INTERVAL_MS } from './env.js';
8
8
  import { buildPresenceHeartbeatPayload } from './presence-heartbeat.js';
9
9
  import { createListenerState, orchestratorTick, reapDeadWorkers, recoverStrandedWorkers, reconcileInterruptedWork, refreshLegacyTodoHolds, takeRunMessages, liveAgents, } from './orchestrator.js';
10
10
  /* 18c Slice 8 — the on-disk half of the crash recovery beside it. */
11
11
  import { sweepStrandedCodexHomes } from './codex-home.js';
12
- import { hasOwnedWorkContext, ownedWorkAllowed, beginOwnedClaim, suspendOwnedWorkAdmission, resumeOwnedWorkAdmission, setOwnedWorkAccount, reserveOwnedWork, interruptionReceipts, acknowledgeInterruption, failedPanelPreparations, acknowledgePreparationFailure, legacyPanelSnapshotNeeded, completeLegacyPanelSnapshot, recordExitedInterruption, beginRpcClaim, deferRpcClaim, pendingRpcClaims, recordRpcClaims, finishRpcClaims, pendingPanelAttempt, ownedPanelAttemptHeld, ownedLocalPanelOwnerHeld, stopOwnedPanelAttempt } from './daemon-processes.js';
12
+ import { hasOwnedWorkContext, pendingTerminalOutcomes, acknowledgeTerminalOutcome, ownedWorkAllowed, beginOwnedClaim, suspendOwnedWorkAdmission, resumeOwnedWorkAdmission, setOwnedWorkAccount, reserveOwnedWork, interruptionReceipts, acknowledgeInterruption, failedPanelPreparations, acknowledgePreparationFailure, legacyPanelSnapshotNeeded, completeLegacyPanelSnapshot, recordExitedInterruption, beginRpcClaim, deferRpcClaim, pendingRpcClaims, recoveredPanelClaims, recordRpcClaims, finishRpcClaims, pendingPanelAttempt, ownedPanelAttemptHeld, ownedLocalPanelOwnerHeld, stopOwnedPanelAttempt } from './daemon-processes.js';
13
13
  import { startPanel } from './panel3/run.js';
14
14
  let presence = null;
15
15
  let cloudState = 'connecting';
@@ -21,11 +21,16 @@ export async function suspendPresenceWork(options = {}) {
21
21
  await suspendOwnedWorkAdmission(options);
22
22
  }
23
23
  export function resumePresenceWork() { suspended = false; resumeOwnedWorkAdmission(); }
24
- function mayClaim() { return !suspended && cloudState === 'online' && ownedWorkAllowed(); }
25
- function panelWorkLifecycle(accountId) {
26
- const allowed = () => mayClaim() && presence?.userId === accountId;
24
+ function mayClaim() { return !!presence && clientSessionCurrent(presence.client) && !suspended && cloudState === 'online' && ownedWorkAllowed(); }
25
+ function panelWorkLifecycle(accountId, client) {
26
+ const allowed = () => clientSessionCurrent(client) && mayClaim() && presence?.userId === accountId;
27
27
  return {
28
28
  allowed,
29
+ observeHarness: (...args) => {
30
+ if (clientSessionCurrent(client) && presence?.userId === accountId)
31
+ recordHarnessObservation(...args);
32
+ },
33
+ cloudAllowed: () => clientSessionCurrent(client) && presence?.userId === accountId,
29
34
  beginClaim: () => {
30
35
  if (!allowed())
31
36
  throw new Error('This machine is not accepting new work.');
@@ -38,6 +43,8 @@ function panelWorkLifecycle(accountId) {
38
43
  return {
39
44
  setAttempt: (attempt) => reservation.setReference({ surface: 'panel', ...attempt }),
40
45
  deferFailure: reservation.deferFailure,
46
+ deferOutcome: reservation.deferOutcome,
47
+ acknowledgeOutcome: reservation.acknowledgeOutcome,
41
48
  execution: {
42
49
  id: reservation.id,
43
50
  register: async (child, harness) => {
@@ -60,11 +67,14 @@ function panelWorkLifecycle(accountId) {
60
67
  throw new Error('This execution belongs to the previous signed-in account.');
61
68
  await stopOwnedPanelAttempt(attempt);
62
69
  },
63
- beginRpc: (action) => beginRpcClaim('panel', action),
70
+ beginRpc: (action, args) => beginRpcClaim('panel', action, args),
64
71
  deferRpc: deferRpcClaim,
65
72
  pendingRpcs: () => pendingRpcClaims('panel'),
66
- recordRpc: (id, attempts, interrupted) => recordRpcClaims(id, attempts.map((attempt) => ({ surface: 'panel', ...attempt })), interrupted),
73
+ recoveredClaims: recoveredPanelClaims,
74
+ recordRpc: (id, attempts, interrupted, result) => recordRpcClaims(id, attempts.map((attempt) => ({ surface: 'panel', ...attempt })), interrupted, result),
67
75
  finishRpcs: finishRpcClaims,
76
+ pendingOutcomes: () => pendingTerminalOutcomes().flatMap(row => row.reference.surface === 'panel' ? [{ id: row.id, attempt: row.reference, outcome: row.outcome }] : []),
77
+ acknowledgeOutcome: acknowledgeTerminalOutcome,
68
78
  failedPreparations: failedPanelPreparations,
69
79
  acknowledgePreparationFailure,
70
80
  receipts: () => interruptionReceipts('panel').flatMap((receipt) => receipt.reference.surface === 'panel'
@@ -121,29 +131,7 @@ export function isPresenceRunning() {
121
131
  * whole of the daemon's life.
122
132
  */
123
133
  export function liveClient() {
124
- return presence?.client ?? null;
125
- }
126
- /** Whether the heartbeat's catch block should rebuild its client from disk.
127
- *
128
- * Exact, not heuristic: a rebuild only ever helps when `session.json` holds a
129
- * DIFFERENT refresh token than the one the live client was built from (a
130
- * concurrent `cs login`, or another install on this box signing in as someone
131
- * else). When it holds the SAME token, `getClient` -> `setSession` -> auth-js
132
- * will decode it, see the exact expiry it saw last tick, and call
133
- * `_callRefreshToken` again against a refresh token GoTrue has already
134
- * revoked, repeating the same 400 `refresh_token_not_found` that ran once per
135
- * heartbeat tick (every 10s) instead of once per auth-js's own 60s cooldown,
136
- * because a brand-new client has no memory of the failure. `onDisk` null/undefined
137
- * (`readSession()` returns null on a missing or corrupt file, and the field is
138
- * optional on `StoredSession` reads) means there is nothing new to try, so it
139
- * also answers false rather than forcing a rebuild against nothing.
140
- *
141
- * No SupabaseClient type appears here on purpose: the decision is a pure
142
- * string comparison, so a test can call it with two strings and nothing else. */
143
- export function shouldRebuildClient(builtFrom, onDisk) {
144
- if (onDisk == null)
145
- return false;
146
- return builtFrom !== onDisk;
134
+ return presence && clientSessionCurrent(presence.client) ? presence.client : null;
147
135
  }
148
136
  /** Register the one `SIGNED_OUT` handler presence relies on, and return its
149
137
  * subscription so the caller can unsubscribe it later. Extracted so
@@ -157,12 +145,18 @@ export function shouldRebuildClient(builtFrom, onDisk) {
157
145
  * a refresh fails non-retryably AND the access token has already expired.
158
146
  * Nothing on disk is touched here; `cs logout` owns deleting `session.json`. */
159
147
  export function bindSignedOutWatcher(client) {
148
+ const generation = readSessionRecord()?.generation;
160
149
  const { data } = client.auth.onAuthStateChange((event) => {
161
150
  if (event !== 'SIGNED_OUT')
162
151
  return;
152
+ const record = readSessionRecord();
153
+ // A captured callback can arrive after unsubscribe or a replacement login.
154
+ if ((presence && presence.client !== client)
155
+ || (record?.state === 'signed-in' && record.generation !== generation))
156
+ return;
163
157
  console.error('Session expired and could not be refreshed. Run `cs login` to sign in again.');
164
158
  cloudState = 'sign-in-required';
165
- void suspendOwnedWorkAdmission();
159
+ void suspendOwnedWorkAdmission({ waitForClaims: false });
166
160
  void stopToolsServer().catch((error) => console.warn(`Agent tools could not be closed: ${String(error)}`));
167
161
  });
168
162
  return data.subscription;
@@ -170,6 +164,11 @@ export function bindSignedOutWatcher(client) {
170
164
  /** A new sign-in can succeed before recovery or local tools do. Retry that
171
165
  * unfinished readiness on this same client; never rebuild a refresh writer just
172
166
  * because a recovery RPC was temporarily unavailable. */
167
+ function observeForPresence(p) {
168
+ const client = p.client, accountId = p.userId;
169
+ return (...args) => { if (presence === p && p.userId === accountId && clientSessionCurrent(client))
170
+ recordHarnessObservation(...args); };
171
+ }
173
172
  async function restorePresenceWork(p) {
174
173
  const { data, error } = await p.client.auth.getUser();
175
174
  if (error)
@@ -179,19 +178,16 @@ async function restorePresenceWork(p) {
179
178
  throw new NotLoggedIn();
180
179
  if (presence !== p)
181
180
  throw new Error('Cloud recovery was cancelled by the service command.');
182
- if (currentUserId !== p.userId) {
183
- console.warn('the signed-in account changed underneath this daemon — heartbeating as the account now on disk');
184
- await p.panel?.stop();
185
- p.userId = currentUserId;
186
- p.panel = null;
187
- }
181
+ p.userId = currentUserId;
182
+ assertClientSession(p.client);
188
183
  if (hasOwnedWorkContext())
189
184
  setOwnedWorkAccount(currentUserId);
190
- await reconcileInterruptedWork(p.client, p.identity.id);
185
+ await reconcileMcpCleanup(p.client, p.userId);
186
+ await reconcileInterruptedWork(p.client, p.identity.id, observeForPresence(p));
191
187
  if (presence !== p)
192
188
  throw new Error('Cloud recovery was cancelled by the service command.');
193
189
  if (!p.panel)
194
- p.panel = startPanel(() => p.client, hasOwnedWorkContext() ? panelWorkLifecycle(p.userId) : undefined);
190
+ p.panel = startPanel(p.client, hasOwnedWorkContext() ? panelWorkLifecycle(p.userId, p.client) : undefined);
195
191
  try {
196
192
  await p.panel.ready;
197
193
  }
@@ -208,6 +204,30 @@ async function restorePresenceWork(p) {
208
204
  if (!suspended)
209
205
  resumeOwnedWorkAdmission();
210
206
  }
207
+ /** Close admission before acknowledging local replacement. Old executions retain
208
+ * their original client and journal; retiring the poller never kills children. */
209
+ export async function observePresenceSession() {
210
+ const p = presence;
211
+ if (!p || clientSessionCurrent(p.client))
212
+ return;
213
+ const client = p.client;
214
+ // A transient storage read can recover during suspension. Remember to reopen
215
+ // readiness even when the same client becomes current again after the await.
216
+ p.readinessPending = true;
217
+ await suspendOwnedWorkAdmission({ waitForClaims: false });
218
+ if (presence !== p || p.client !== client || clientSessionCurrent(client))
219
+ return;
220
+ const record = readSessionRecord();
221
+ cloudState = record?.state === 'signed-in' ? 'connecting' : 'sign-in-required';
222
+ if (!p.retiring) {
223
+ p.authSubscription.unsubscribe();
224
+ const panel = p.panel;
225
+ p.panel = null;
226
+ p.retiring = Promise.all([panel?.stop(), disposeClient(p.client)]).then(() => { });
227
+ void p.retiring.catch(() => { });
228
+ }
229
+ await stopToolsServer();
230
+ }
211
231
  async function heartbeat(p) {
212
232
  if (p.heartbeating)
213
233
  return;
@@ -220,103 +240,39 @@ async function heartbeat(p) {
220
240
  }
221
241
  }
222
242
  async function heartbeatInner(p) {
223
- if (cloudState === 'sign-in-required' && !shouldRebuildClient(p.builtFromRefreshToken, readSession()?.refresh_token))
224
- return;
225
243
  try {
244
+ await observePresenceSession();
245
+ const record = readSessionRecord();
246
+ if (record?.state !== 'signed-in')
247
+ return;
248
+ if (p.sessionGeneration !== record.generation || !clientSessionCurrent(p.client)) {
249
+ await p.retiring;
250
+ const rebuilt = await getClient();
251
+ if (presence !== p) {
252
+ await disposeClient(rebuilt);
253
+ return;
254
+ }
255
+ p.client = rebuilt;
256
+ p.sessionGeneration = record.generation;
257
+ p.authSubscription = bindSignedOutWatcher(rebuilt);
258
+ p.retiring = undefined;
259
+ p.readinessPending = true;
260
+ }
226
261
  if (p.readinessPending)
227
262
  await restorePresenceWork(p);
228
- const { error } = await p.client
229
- .from('cliv2_agents')
230
- .upsert(buildPresenceHeartbeatPayload({
231
- userId: p.userId,
232
- machineId: p.identity.id,
233
- machineName: p.identity.name,
234
- agents: p.agents,
235
- platform: p.platform,
236
- }), { onConflict: 'user_id,machine_id' });
263
+ p.agents = detectAgents();
264
+ await Promise.all(p.agents.map(agent => probeHarnessAuth(agent)));
265
+ const { error } = await p.client.from('cliv2_agents').upsert(buildPresenceHeartbeatPayload({ userId: p.userId, machineId: p.identity.id,
266
+ machineName: p.identity.name, agents: p.agents, platform: p.platform, harnessAuth: harnessAuthEvidence() }), { onConflict: 'user_id,machine_id' });
237
267
  if (error)
238
268
  throw error;
239
269
  cloudState = 'online';
240
270
  }
241
- catch (err) {
242
- cloudState = confirmedSessionRejection(err) ? 'sign-in-required' : 'offline';
243
- // A failure here used to rebuild the client from disk unconditionally, and
244
- // that was the production incident: `getClient` -> `setSession` decodes
245
- // whatever refresh token is on disk and hands it to auth-js, which refreshes
246
- // it UNCONDITIONALLY if the access token has expired. When the disk token is
247
- // the same dead one this client was already built from, that is a guaranteed
248
- // repeat of the exact 400 `refresh_token_not_found` that got us here, once
249
- // per 10s heartbeat tick instead of once per auth-js's own 60s cooldown (that
250
- // cooldown lives on the client instance, and a fresh client every tick never
251
- // accumulates it). 7,028 refresh requests in one window, read off this
252
- // machine's own logs. `shouldRebuildClient` below is the guard: only rebuild
253
- // when `session.json` disagrees with the token this client holds.
254
- console.warn(`heartbeat failed, will retry: ${err.message}`);
255
- try {
256
- const onDisk = readSession()?.refresh_token;
257
- if (shouldRebuildClient(p.builtFromRefreshToken, onDisk)) {
258
- // KEEPING THE REBUILD, NOT DELETING IT: every client here is built with
259
- // `persistSession: false`, which selects auth-js's in-memory storage
260
- // adapter, so `getUser()` reads that client's own memory and never reads
261
- // `session.json`. `getClient()` -> `readSession()` -> `setSession()` is the
262
- // ONLY path in this process from disk to a live client. Deleting the
263
- // rebuild (proposed twice already in earlier drafts of this fix) would make
264
- // the account-reconciliation below unreachable and a concurrent `cs login`
265
- // unrecoverable without restarting the daemon.
266
- await suspendOwnedWorkAdmission();
267
- p.readinessPending = true;
268
- p.authSubscription.unsubscribe();
269
- // Tool transports close over their startup client/account. Replacing
270
- // only the session heartbeat client would leave new work on old auth.
271
- await stopToolsServer();
272
- await disposeClient(p.client);
273
- const rebuilt = await getClient();
274
- p.authSubscription = bindSignedOutWatcher(rebuilt);
275
- p.client = rebuilt;
276
- // shouldRebuildClient already ruled out onDisk being null/undefined to
277
- // reach this branch; the `as string` reflects that guarantee rather
278
- // than reintroducing a null fallback that can't occur.
279
- p.builtFromRefreshToken = onDisk;
280
- // FIX 2: flow the rebuilt client (fresh/refreshed token) into the tools
281
- // session-lifecycle heartbeat too, so a wedged token can't leave the session
282
- // heartbeat 401ing forever (the CLI-v1 stale-token root cause). No-op unless
283
- // the tools server is running.
284
- setToolsClient(p.client);
285
- /* ═══ !Cleanup PHASE 0 (I1) — RE-RESOLVE WHO WE ARE, NOT JUST THE TOKEN.
286
- THE WEDGE THIS FIXES, read off this machine's own 6.1 MB log: an
287
- unbroken wall of
288
-
289
- heartbeat failed, will retry: new row violates row-level security
290
- policy for table "cliv2_agents"
291
-
292
- and presence stayed dead until the daemon was killed by hand.
293
-
294
- `p.userId` is resolved ONCE, at startPresence, and the payload sends it
295
- EXPLICITLY while the table's policy is `with check (auth.uid() =
296
- user_id)`. The recovery above rebuilt the CLIENT from disk but left
297
- `p.userId` alone — so the moment `session.json` came to hold a different
298
- account (a second install on this box signing in; a demo lane; a
299
- re-login as someone else), every heartbeat sent one user's id under
300
- another user's token and the database correctly refused it. Rebuilding
301
- the client changed nothing, because the disk session was not the stale
302
- half. It could never recover on its own: the retry re-sent the same
303
- disagreement, several times a minute, forever.
304
-
305
- Evidence it really happened here rather than in theory: `cliv2_agents`
306
- holds TWO rows for this machine_id under two different user_ids, one of
307
- them stuck at the epoch.
308
-
309
- THE TOKEN IS THE AUTHORITY. `auth.uid()` is what the database will
310
- believe whatever this process thinks, so the id is taken FROM the
311
- rebuilt client rather than held against it. Warned rather than silent:
312
- a daemon that starts heartbeating as a different user has had the
313
- machine change owner underneath it, and that is worth a line. */
314
- await restorePresenceWork(p);
315
- }
316
- }
317
- catch (error) {
318
- cloudState = error instanceof NotLoggedIn || confirmedSessionRejection(error) ? 'sign-in-required' : 'offline';
319
- }
271
+ catch (error) {
272
+ cloudState = error instanceof NotLoggedIn || confirmedSessionRejection(error) ? 'sign-in-required' : 'offline';
273
+ await suspendOwnedWorkAdmission({ waitForClaims: false });
274
+ p.readinessPending = true;
275
+ console.warn(`Cloud readiness could not be restored: ${error.message}`);
320
276
  }
321
277
  // Re-attempt agent registration each heartbeat for any detected agent still
322
278
  // idle or failed, while the tools server is up. Registration otherwise fires
@@ -380,8 +336,10 @@ async function pollCommands(p) {
380
336
  if (p.pollingCommands)
381
337
  return;
382
338
  p.pollingCommands = true;
339
+ const client = p.client;
340
+ const panel = p.panel;
383
341
  try {
384
- const { data, error } = await p.client
342
+ const { data, error } = await client
385
343
  .from('cliv2_commands')
386
344
  .update({ status: 'ack', acked_at: new Date().toISOString() })
387
345
  .eq('machine_id', p.identity.id)
@@ -392,7 +350,7 @@ async function pollCommands(p) {
392
350
  throw error;
393
351
  for (const cmd of data ?? [])
394
352
  console.log(`Acked ${cmd.command} (${cmd.id})`);
395
- const { data: restarts, error: restartError } = await p.client
353
+ const { data: restarts, error: restartError } = await client
396
354
  .from('cliv2_commands')
397
355
  .update({ status: 'processing' })
398
356
  .eq('machine_id', p.identity.id)
@@ -408,15 +366,16 @@ async function pollCommands(p) {
408
366
  if (Date.now() - Date.parse(command.created_at) > 30_000) {
409
367
  throw new Error('This restart request expired. Try again while the machine is online.');
410
368
  }
411
- if (!p.panel)
369
+ assertClientSession(client);
370
+ if (!panel)
412
371
  throw new Error('The worker has not started. Open Companion on this machine and reconnect.');
413
- await p.panel.restart();
372
+ await panel.restart();
414
373
  }
415
374
  catch (error) {
416
375
  status = 'failed';
417
376
  result = error instanceof Error ? error.message : String(error);
418
377
  }
419
- const { error: saved } = await p.client.from('cliv2_commands')
378
+ const { error: saved } = await client.from('cliv2_commands')
420
379
  .update({ status, result, acked_at: new Date().toISOString() }).eq('id', command.id);
421
380
  if (saved)
422
381
  throw saved;
@@ -445,6 +404,7 @@ async function pollCommands(p) {
445
404
  async function pollOrchestrator(p) {
446
405
  if (!mayClaim())
447
406
  return;
407
+ const client = p.client, accountId = p.userId;
448
408
  /* !Cleanup Phase 0 (I5c) — WHICH CTRL+SPC SERVER THE WORKER MAY REACH.
449
409
  Read here, per tick, for the same reason `liveAgents()` is: the tools server
450
410
  can come up after the first tick, and a snapshot taken at `startPresence`
@@ -454,6 +414,10 @@ async function pollOrchestrator(p) {
454
414
  pointed at a different account was found registered and listening. */
455
415
  const server = toolsServerStatus();
456
416
  await orchestratorTick({
417
+ observeHarness: (...args) => {
418
+ if (clientSessionCurrent(client) && presence?.userId === accountId)
419
+ recordHarnessObservation(...args);
420
+ },
457
421
  client: p.client,
458
422
  userId: p.userId,
459
423
  machineId: p.identity.id,
@@ -470,6 +434,7 @@ export async function startPresence() {
470
434
  if (stopping)
471
435
  await stopping;
472
436
  if (presence) {
437
+ await heartbeat(presence);
473
438
  return { machineName: presence.identity.name, agents: presence.agents };
474
439
  }
475
440
  if (starting)
@@ -533,7 +498,7 @@ export async function startPresence() {
533
498
  cp: setInterval(() => { }, COMMAND_POLL_INTERVAL_MS),
534
499
  ot: setInterval(() => { }, ORCHESTRATOR_POLL_INTERVAL_MS),
535
500
  listener: createListenerState(),
536
- builtFromRefreshToken: readSession()?.refresh_token ?? null,
501
+ sessionGeneration: readSessionRecord().generation,
537
502
  authSubscription,
538
503
  };
539
504
  clearInterval(p.hb);
@@ -548,11 +513,12 @@ export async function startPresence() {
548
513
  self-heals, and the worker's unique index would refuse the retry. Scoped
549
514
  to this machine's own id: if this daemon is starting, nothing it started
550
515
  is still running. */
551
- await reconcileInterruptedWork(p.client, identity.id);
516
+ await reconcileMcpCleanup(p.client, p.userId);
517
+ await reconcileInterruptedWork(p.client, identity.id, observeForPresence(p));
552
518
  if (presence !== p)
553
519
  throw new Error('Cloud startup was cancelled by the service command.');
554
520
  if (!p.panel)
555
- p.panel = startPanel(() => p.client, hasOwnedWorkContext() ? panelWorkLifecycle(p.userId) : undefined);
521
+ p.panel = startPanel(p.client, hasOwnedWorkContext() ? panelWorkLifecycle(p.userId, p.client) : undefined);
556
522
  await p.panel.ready;
557
523
  if (presence !== p)
558
524
  throw new Error('Cloud startup was cancelled by the service command.');
@@ -619,10 +585,14 @@ export async function startPresence() {
619
585
  than starting an agent that could read none of the work. */
620
586
  if (!mayClaim())
621
587
  return;
622
- await reconcileInterruptedWork(p.client, p.identity.id);
588
+ await reconcileMcpCleanup(p.client, p.userId);
589
+ await reconcileInterruptedWork(p.client, p.identity.id, observeForPresence(p));
623
590
  await refreshLegacyTodoHolds(p.client, p.identity.id);
624
591
  const server = toolsServerStatus();
625
- void takeRunMessages(p.client, p.userId, p.identity.id, liveAgents(), p.listener, console.log, console.warn, server.running ? { port: server.port, token: mcpToken() } : null);
592
+ void takeRunMessages(p.client, p.userId, p.identity.id, liveAgents(), p.listener, console.log, console.warn, server.running ? { port: server.port, token: mcpToken() } : null, undefined, undefined, (...args) => {
593
+ if (clientSessionCurrent(p.client) && presence?.userId === p.userId)
594
+ recordHarnessObservation(...args);
595
+ });
626
596
  })().catch((error) => { cloudState = confirmedSessionRejection(error) ? 'sign-in-required' : 'offline'; console.warn(`Cloud work is paused: ${String(error)}`); });
627
597
  /* !Cleanup Phase 6b (I43) — THE LIVENESS WATCH, on the heartbeat rather
628
598
  than the orchestrator tick.
@@ -669,7 +639,6 @@ export async function startPresence() {
669
639
  stale-token root cause, reintroduced by ordering alone.
670
640
  `p.client` rather than `client`, because the whole point is to pick up a
671
641
  rebuild the local const cannot see. */
672
- setToolsClient(p.client);
673
642
  /* The instructions belong to the release, so they are rewritten beside
674
643
  registration on every start: the pair is "make this machine's agents
675
644
  ready", and a machine registered against current tools while reading a
@@ -687,6 +656,14 @@ export async function startPresence() {
687
656
  }
688
657
  // Both cs open and cs start answer cards through this one lifecycle.
689
658
  // Read the owner's client on every use, including after refresh or logout.
659
+ if (presence !== p)
660
+ throw new Error('Cloud startup was cancelled by the service command.');
661
+ assertClientSession(p.client);
662
+ // A failed earlier startup may have closed admission before retiring its
663
+ // presence. A healthy replacement must reopen it, unless recovery or an
664
+ // explicit service stop still owns that decision.
665
+ if (!p.readinessPending && !suspended)
666
+ resumeOwnedWorkAdmission();
690
667
  return { machineName: identity.name, agents };
691
668
  })();
692
669
  try {
@@ -747,7 +724,6 @@ export async function stopPresence({ unregister = false } = {}) {
747
724
  session, one that is signed in and healthy. */
748
725
  p.authSubscription.unsubscribe();
749
726
  await p.panel?.stop();
750
- await disposeClient(p.client);
751
727
  // On logout (unregister), scrub the ctrl-spc entry from each detected agent's
752
728
  // config so a logged-out machine leaves no dead server that would read "failed
753
729
  // to connect" on the next agent run. Fire-and-forget best-effort — never blocks
@@ -767,7 +743,8 @@ export async function stopPresence({ unregister = false } = {}) {
767
743
  .update({ stopped_at: new Date().toISOString() })
768
744
  .eq('machine_id', p.identity.id);
769
745
  }
770
- catch { /* ignore — a lapsed heartbeat reads as offline anyway */ }
746
+ catch { /* A lapsed heartbeat reads as offline. */ }
747
+ await disposeClient(p.client);
771
748
  })();
772
749
  try {
773
750
  await stopping;