@ctrl-spc/cs 0.7.14 → 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.
package/dist/presence.js CHANGED
@@ -1,17 +1,91 @@
1
1
  import { platform } from 'node:os';
2
- import { getClient } 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
- import { createListenerState, orchestratorTick, reapDeadWorkers, recoverStrandedWorkers, takeRunMessages, liveAgents, } from './orchestrator.js';
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 { claimDaemonLock, releaseDaemonLock } from './daemon-lock.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
+ let cloudState = 'connecting';
16
+ let suspended = false;
17
+ let presenceGeneration = 0;
18
+ export function presenceCloudState() { return cloudState; }
19
+ export async function suspendPresenceWork(options = {}) {
20
+ suspended = true;
21
+ await suspendOwnedWorkAdmission(options);
22
+ }
23
+ export function resumePresenceWork() { suspended = false; resumeOwnedWorkAdmission(); }
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
+ return {
28
+ allowed,
29
+ observeHarness: (...args) => {
30
+ if (clientSessionCurrent(client) && presence?.userId === accountId)
31
+ recordHarnessObservation(...args);
32
+ },
33
+ cloudAllowed: () => clientSessionCurrent(client) && presence?.userId === accountId,
34
+ beginClaim: () => {
35
+ if (!allowed())
36
+ throw new Error('This machine is not accepting new work.');
37
+ return beginOwnedClaim();
38
+ },
39
+ prepare: (runId) => {
40
+ if (presence?.userId !== accountId)
41
+ throw new Error('This execution belongs to the previous signed-in account.');
42
+ const reservation = reserveOwnedWork(null, null, runId);
43
+ return {
44
+ setAttempt: (attempt) => reservation.setReference({ surface: 'panel', ...attempt }),
45
+ deferFailure: reservation.deferFailure,
46
+ deferOutcome: reservation.deferOutcome,
47
+ acknowledgeOutcome: reservation.acknowledgeOutcome,
48
+ execution: {
49
+ id: reservation.id,
50
+ register: async (child, harness) => {
51
+ await reservation.register(child, harness);
52
+ },
53
+ interrupted: reservation.interrupted,
54
+ exited: reservation.exited,
55
+ complete: reservation.complete,
56
+ beforeSpawn: reservation.beforeSpawn,
57
+ waitForPromptAdmission: reservation.waitForPromptAdmission,
58
+ },
59
+ finish: reservation.finishPreparation,
60
+ };
61
+ },
62
+ claimAttempt: pendingPanelAttempt,
63
+ heldLocalOwner: ownedLocalPanelOwnerHeld,
64
+ heldAttempt: (attempt) => presence?.userId !== accountId || ownedPanelAttemptHeld(attempt),
65
+ stopHeldAttempt: async (attempt) => {
66
+ if (presence?.userId !== accountId)
67
+ throw new Error('This execution belongs to the previous signed-in account.');
68
+ await stopOwnedPanelAttempt(attempt);
69
+ },
70
+ beginRpc: (action, args) => beginRpcClaim('panel', action, args),
71
+ deferRpc: deferRpcClaim,
72
+ pendingRpcs: () => pendingRpcClaims('panel'),
73
+ recoveredClaims: recoveredPanelClaims,
74
+ recordRpc: (id, attempts, interrupted, result) => recordRpcClaims(id, attempts.map((attempt) => ({ surface: 'panel', ...attempt })), interrupted, result),
75
+ finishRpcs: finishRpcClaims,
76
+ pendingOutcomes: () => pendingTerminalOutcomes().flatMap(row => row.reference.surface === 'panel' ? [{ id: row.id, attempt: row.reference, outcome: row.outcome }] : []),
77
+ acknowledgeOutcome: acknowledgeTerminalOutcome,
78
+ failedPreparations: failedPanelPreparations,
79
+ acknowledgePreparationFailure,
80
+ receipts: () => interruptionReceipts('panel').flatMap((receipt) => receipt.reference.surface === 'panel'
81
+ ? [{ id: receipt.id, operationId: receipt.operationId, interruptedAt: receipt.interruptedAt,
82
+ pid: receipt.process?.pid ?? null, attempt: receipt.reference }] : []),
83
+ acknowledge: acknowledgeInterruption,
84
+ legacyRequired: legacyPanelSnapshotNeeded,
85
+ recordLegacy: (attempt) => recordExitedInterruption({ surface: 'panel', ...attempt }),
86
+ legacyComplete: completeLegacyPanelSnapshot,
87
+ };
88
+ }
15
89
  /** In-flight guard: startPresence yields to the event loop (network setSession)
16
90
  * before `presence` is assigned, so a plain `if (presence)` check lets two
17
91
  * concurrent callers both build interval pairs — the first pair then leaks and
@@ -57,29 +131,7 @@ export function isPresenceRunning() {
57
131
  * whole of the daemon's life.
58
132
  */
59
133
  export function liveClient() {
60
- return presence?.client ?? null;
61
- }
62
- /** Whether the heartbeat's catch block should rebuild its client from disk.
63
- *
64
- * Exact, not heuristic: a rebuild only ever helps when `session.json` holds a
65
- * DIFFERENT refresh token than the one the live client was built from (a
66
- * concurrent `cs login`, or another install on this box signing in as someone
67
- * else). When it holds the SAME token, `getClient` -> `setSession` -> auth-js
68
- * will decode it, see the exact expiry it saw last tick, and call
69
- * `_callRefreshToken` again against a refresh token GoTrue has already
70
- * revoked, repeating the same 400 `refresh_token_not_found` that ran once per
71
- * heartbeat tick (every 10s) instead of once per auth-js's own 60s cooldown,
72
- * because a brand-new client has no memory of the failure. `onDisk` null/undefined
73
- * (`readSession()` returns null on a missing or corrupt file, and the field is
74
- * optional on `StoredSession` reads) means there is nothing new to try, so it
75
- * also answers false rather than forcing a rebuild against nothing.
76
- *
77
- * No SupabaseClient type appears here on purpose: the decision is a pure
78
- * string comparison, so a test can call it with two strings and nothing else. */
79
- export function shouldRebuildClient(builtFrom, onDisk) {
80
- if (onDisk == null)
81
- return false;
82
- return builtFrom !== onDisk;
134
+ return presence && clientSessionCurrent(presence.client) ? presence.client : null;
83
135
  }
84
136
  /** Register the one `SIGNED_OUT` handler presence relies on, and return its
85
137
  * subscription so the caller can unsubscribe it later. Extracted so
@@ -93,103 +145,134 @@ export function shouldRebuildClient(builtFrom, onDisk) {
93
145
  * a refresh fails non-retryably AND the access token has already expired.
94
146
  * Nothing on disk is touched here; `cs logout` owns deleting `session.json`. */
95
147
  export function bindSignedOutWatcher(client) {
148
+ const generation = readSessionRecord()?.generation;
96
149
  const { data } = client.auth.onAuthStateChange((event) => {
97
150
  if (event !== 'SIGNED_OUT')
98
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;
99
157
  console.error('Session expired and could not be refreshed. Run `cs login` to sign in again.');
100
- void stopPresence();
158
+ cloudState = 'sign-in-required';
159
+ void suspendOwnedWorkAdmission({ waitForClaims: false });
160
+ void stopToolsServer().catch((error) => console.warn(`Agent tools could not be closed: ${String(error)}`));
101
161
  });
102
162
  return data.subscription;
103
163
  }
164
+ /** A new sign-in can succeed before recovery or local tools do. Retry that
165
+ * unfinished readiness on this same client; never rebuild a refresh writer just
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
+ }
172
+ async function restorePresenceWork(p) {
173
+ const { data, error } = await p.client.auth.getUser();
174
+ if (error)
175
+ throw error;
176
+ const currentUserId = data.user?.id;
177
+ if (!currentUserId)
178
+ throw new NotLoggedIn();
179
+ if (presence !== p)
180
+ throw new Error('Cloud recovery was cancelled by the service command.');
181
+ p.userId = currentUserId;
182
+ assertClientSession(p.client);
183
+ if (hasOwnedWorkContext())
184
+ setOwnedWorkAccount(currentUserId);
185
+ await reconcileMcpCleanup(p.client, p.userId);
186
+ await reconcileInterruptedWork(p.client, p.identity.id, observeForPresence(p));
187
+ if (presence !== p)
188
+ throw new Error('Cloud recovery was cancelled by the service command.');
189
+ if (!p.panel)
190
+ p.panel = startPanel(p.client, hasOwnedWorkContext() ? panelWorkLifecycle(p.userId, p.client) : undefined);
191
+ try {
192
+ await p.panel.ready;
193
+ }
194
+ catch (error) {
195
+ await p.panel.stop();
196
+ p.panel = null;
197
+ throw error;
198
+ }
199
+ await startToolsServer({ client: p.client, userId: p.userId, machineId: p.identity.id });
200
+ if (presence !== p)
201
+ throw new Error('Cloud recovery was cancelled by the service command.');
202
+ p.readinessPending = false;
203
+ cloudState = 'online';
204
+ if (!suspended)
205
+ resumeOwnedWorkAdmission();
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
+ }
104
231
  async function heartbeat(p) {
232
+ if (p.heartbeating)
233
+ return;
234
+ p.heartbeating = true;
105
235
  try {
106
- const { error } = await p.client
107
- .from('cliv2_agents')
108
- .upsert(buildPresenceHeartbeatPayload({
109
- userId: p.userId,
110
- machineId: p.identity.id,
111
- machineName: p.identity.name,
112
- agents: p.agents,
113
- platform: p.platform,
114
- }), { onConflict: 'user_id,machine_id' });
115
- if (error)
116
- throw error;
236
+ await heartbeatInner(p);
117
237
  }
118
- catch (err) {
119
- // A failure here used to rebuild the client from disk unconditionally, and
120
- // that was the production incident: `getClient` -> `setSession` decodes
121
- // whatever refresh token is on disk and hands it to auth-js, which refreshes
122
- // it UNCONDITIONALLY if the access token has expired. When the disk token is
123
- // the same dead one this client was already built from, that is a guaranteed
124
- // repeat of the exact 400 `refresh_token_not_found` that got us here, once
125
- // per 10s heartbeat tick instead of once per auth-js's own 60s cooldown (that
126
- // cooldown lives on the client instance, and a fresh client every tick never
127
- // accumulates it). 7,028 refresh requests in one window, read off this
128
- // machine's own logs. `shouldRebuildClient` below is the guard: only rebuild
129
- // when `session.json` disagrees with the token this client holds.
130
- console.warn(`heartbeat failed, will retry: ${err.message}`);
131
- try {
132
- const onDisk = readSession()?.refresh_token;
133
- if (shouldRebuildClient(p.builtFromRefreshToken, onDisk)) {
134
- // KEEPING THE REBUILD, NOT DELETING IT: every client here is built with
135
- // `persistSession: false`, which selects auth-js's in-memory storage
136
- // adapter, so `getUser()` reads that client's own memory and never reads
137
- // `session.json`. `getClient()` -> `readSession()` -> `setSession()` is the
138
- // ONLY path in this process from disk to a live client. Deleting the
139
- // rebuild (proposed twice already in earlier drafts of this fix) would make
140
- // the account-reconciliation below unreachable and a concurrent `cs login`
141
- // unrecoverable without restarting the daemon.
142
- const rebuilt = await getClient();
143
- p.authSubscription.unsubscribe();
144
- p.authSubscription = bindSignedOutWatcher(rebuilt);
145
- p.client = rebuilt;
146
- // shouldRebuildClient already ruled out onDisk being null/undefined to
147
- // reach this branch; the `as string` reflects that guarantee rather
148
- // than reintroducing a null fallback that can't occur.
149
- p.builtFromRefreshToken = onDisk;
150
- // FIX 2: flow the rebuilt client (fresh/refreshed token) into the tools
151
- // session-lifecycle heartbeat too, so a wedged token can't leave the session
152
- // heartbeat 401ing forever (the CLI-v1 stale-token root cause). No-op unless
153
- // the tools server is running.
154
- setToolsClient(p.client);
155
- /* ═══ !Cleanup PHASE 0 (I1) — RE-RESOLVE WHO WE ARE, NOT JUST THE TOKEN.
156
- THE WEDGE THIS FIXES, read off this machine's own 6.1 MB log: an
157
- unbroken wall of
158
-
159
- heartbeat failed, will retry: new row violates row-level security
160
- policy for table "cliv2_agents"
161
-
162
- and presence stayed dead until the daemon was killed by hand.
163
-
164
- `p.userId` is resolved ONCE, at startPresence, and the payload sends it
165
- EXPLICITLY while the table's policy is `with check (auth.uid() =
166
- user_id)`. The recovery above rebuilt the CLIENT from disk but left
167
- `p.userId` alone — so the moment `session.json` came to hold a different
168
- account (a second install on this box signing in; a demo lane; a
169
- re-login as someone else), every heartbeat sent one user's id under
170
- another user's token and the database correctly refused it. Rebuilding
171
- the client changed nothing, because the disk session was not the stale
172
- half. It could never recover on its own: the retry re-sent the same
173
- disagreement, several times a minute, forever.
174
-
175
- Evidence it really happened here rather than in theory: `cliv2_agents`
176
- holds TWO rows for this machine_id under two different user_ids, one of
177
- them stuck at the epoch.
178
-
179
- THE TOKEN IS THE AUTHORITY. `auth.uid()` is what the database will
180
- believe whatever this process thinks, so the id is taken FROM the
181
- rebuilt client rather than held against it. Warned rather than silent:
182
- a daemon that starts heartbeating as a different user has had the
183
- machine change owner underneath it, and that is worth a line. */
184
- const { data } = await p.client.auth.getUser();
185
- const currentUserId = data.user?.id;
186
- if (currentUserId && currentUserId !== p.userId) {
187
- console.warn('the signed-in account changed underneath this daemon — heartbeating as the account now on disk');
188
- p.userId = currentUserId;
189
- }
238
+ finally {
239
+ p.heartbeating = false;
240
+ }
241
+ }
242
+ async function heartbeatInner(p) {
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;
190
254
  }
255
+ p.client = rebuilt;
256
+ p.sessionGeneration = record.generation;
257
+ p.authSubscription = bindSignedOutWatcher(rebuilt);
258
+ p.retiring = undefined;
259
+ p.readinessPending = true;
191
260
  }
192
- catch { /* stay down until the next tick */ }
261
+ if (p.readinessPending)
262
+ await restorePresenceWork(p);
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' });
267
+ if (error)
268
+ throw error;
269
+ cloudState = 'online';
270
+ }
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}`);
193
276
  }
194
277
  // Re-attempt agent registration each heartbeat for any detected agent still
195
278
  // idle or failed, while the tools server is up. Registration otherwise fires
@@ -248,11 +331,15 @@ async function cleanupSupersededRows(p) {
248
331
  }
249
332
  }
250
333
  async function pollCommands(p) {
334
+ if (cloudState !== 'online')
335
+ return;
251
336
  if (p.pollingCommands)
252
337
  return;
253
338
  p.pollingCommands = true;
339
+ const client = p.client;
340
+ const panel = p.panel;
254
341
  try {
255
- const { data, error } = await p.client
342
+ const { data, error } = await client
256
343
  .from('cliv2_commands')
257
344
  .update({ status: 'ack', acked_at: new Date().toISOString() })
258
345
  .eq('machine_id', p.identity.id)
@@ -263,7 +350,7 @@ async function pollCommands(p) {
263
350
  throw error;
264
351
  for (const cmd of data ?? [])
265
352
  console.log(`Acked ${cmd.command} (${cmd.id})`);
266
- const { data: restarts, error: restartError } = await p.client
353
+ const { data: restarts, error: restartError } = await client
267
354
  .from('cliv2_commands')
268
355
  .update({ status: 'processing' })
269
356
  .eq('machine_id', p.identity.id)
@@ -279,15 +366,16 @@ async function pollCommands(p) {
279
366
  if (Date.now() - Date.parse(command.created_at) > 30_000) {
280
367
  throw new Error('This restart request expired. Try again while the machine is online.');
281
368
  }
282
- if (!p.panel)
369
+ assertClientSession(client);
370
+ if (!panel)
283
371
  throw new Error('The worker has not started. Open Companion on this machine and reconnect.');
284
- await p.panel.restart();
372
+ await panel.restart();
285
373
  }
286
374
  catch (error) {
287
375
  status = 'failed';
288
376
  result = error instanceof Error ? error.message : String(error);
289
377
  }
290
- const { error: saved } = await p.client.from('cliv2_commands')
378
+ const { error: saved } = await client.from('cliv2_commands')
291
379
  .update({ status, result, acked_at: new Date().toISOString() }).eq('id', command.id);
292
380
  if (saved)
293
381
  throw saved;
@@ -314,6 +402,9 @@ async function pollCommands(p) {
314
402
  * daemon. Same reasoning as the heartbeat's per-tick `detectAgents()`.
315
403
  */
316
404
  async function pollOrchestrator(p) {
405
+ if (!mayClaim())
406
+ return;
407
+ const client = p.client, accountId = p.userId;
317
408
  /* !Cleanup Phase 0 (I5c) — WHICH CTRL+SPC SERVER THE WORKER MAY REACH.
318
409
  Read here, per tick, for the same reason `liveAgents()` is: the tools server
319
410
  can come up after the first tick, and a snapshot taken at `startPresence`
@@ -323,6 +414,10 @@ async function pollOrchestrator(p) {
323
414
  pointed at a different account was found registered and listening. */
324
415
  const server = toolsServerStatus();
325
416
  await orchestratorTick({
417
+ observeHarness: (...args) => {
418
+ if (clientSessionCurrent(client) && presence?.userId === accountId)
419
+ recordHarnessObservation(...args);
420
+ },
326
421
  client: p.client,
327
422
  userId: p.userId,
328
423
  machineId: p.identity.id,
@@ -339,17 +434,21 @@ export async function startPresence() {
339
434
  if (stopping)
340
435
  await stopping;
341
436
  if (presence) {
437
+ await heartbeat(presence);
342
438
  return { machineName: presence.identity.name, agents: presence.agents };
343
439
  }
344
440
  if (starting)
345
441
  return starting;
442
+ const generation = presenceGeneration;
346
443
  starting = (async () => {
347
- const lock = claimDaemonLock();
348
- if (lock.held)
349
- throw new Error(`CTRL+SPC is already running on this computer (pid ${lock.pid}).`);
444
+ cloudState = 'connecting';
350
445
  const identity = getMachineIdentity();
351
446
  const agents = detectAgents();
352
447
  const client = await getClient();
448
+ if (generation !== presenceGeneration) {
449
+ await disposeClient(client);
450
+ throw new Error('Cloud startup was cancelled by the service command.');
451
+ }
353
452
  /* ═══ A DEAD SESSION STOPS THE LOOPS. IT DOES NOT SLOW THEM DOWN. ═══
354
453
  The refresh token can die while the daemon runs (a re-login elsewhere
355
454
  rotates it out from under this process), and until this existed nothing
@@ -374,10 +473,20 @@ export async function startPresence() {
374
473
  binding and the heartbeat's post-rebuild rebind are the same code and
375
474
  can never drift out of sync with each other. */
376
475
  const authSubscription = bindSignedOutWatcher(client);
377
- const { data } = await client.auth.getUser();
476
+ const { data, error: identityError } = await client.auth.getUser();
378
477
  const userId = data.user?.id;
379
- if (!userId)
380
- throw new Error('Signed-in user could not be resolved. Sign in again.');
478
+ if (identityError || !userId) {
479
+ authSubscription.unsubscribe();
480
+ await disposeClient(client);
481
+ throw identityError ?? new NotLoggedIn();
482
+ }
483
+ if (generation !== presenceGeneration) {
484
+ authSubscription.unsubscribe();
485
+ await disposeClient(client);
486
+ throw new Error('Cloud startup was cancelled by the service command.');
487
+ }
488
+ if (hasOwnedWorkContext())
489
+ setOwnedWorkAccount(userId);
381
490
  const p = {
382
491
  client,
383
492
  panel: null,
@@ -389,7 +498,7 @@ export async function startPresence() {
389
498
  cp: setInterval(() => { }, COMMAND_POLL_INTERVAL_MS),
390
499
  ot: setInterval(() => { }, ORCHESTRATOR_POLL_INTERVAL_MS),
391
500
  listener: createListenerState(),
392
- builtFromRefreshToken: readSession()?.refresh_token ?? null,
501
+ sessionGeneration: readSessionRecord().generation,
393
502
  authSubscription,
394
503
  };
395
504
  clearInterval(p.hb);
@@ -404,6 +513,15 @@ export async function startPresence() {
404
513
  self-heals, and the worker's unique index would refuse the retry. Scoped
405
514
  to this machine's own id: if this daemon is starting, nothing it started
406
515
  is still running. */
516
+ await reconcileMcpCleanup(p.client, p.userId);
517
+ await reconcileInterruptedWork(p.client, identity.id, observeForPresence(p));
518
+ if (presence !== p)
519
+ throw new Error('Cloud startup was cancelled by the service command.');
520
+ if (!p.panel)
521
+ p.panel = startPanel(p.client, hasOwnedWorkContext() ? panelWorkLifecycle(p.userId, p.client) : undefined);
522
+ await p.panel.ready;
523
+ if (presence !== p)
524
+ throw new Error('Cloud startup was cancelled by the service command.');
407
525
  await recoverStrandedWorkers(p.client, identity.id);
408
526
  /* 18c SLICE 8 — AND RECLAIM WHAT A PREVIOUS LIFE LEFT ON DISK. A codex worker
409
527
  runs against a per-run `$CODEX_HOME` seeded with a COPY OF THE USER'S
@@ -429,6 +547,8 @@ export async function startPresence() {
429
547
  argument, including why an age-based scope was measured and rejected, is
430
548
  in `sweepStrandedCodexHomes`. Do not re-widen this to the root. */
431
549
  sweepStrandedCodexHomes();
550
+ if (presence !== p)
551
+ throw new Error('Cloud startup was cancelled by the service command.');
432
552
  p.hb = setInterval(() => {
433
553
  /* ═══ 18k SLICE 7 — TAKE THE MESSAGES, AWAITED AFTER THE HEARTBEAT. ═══
434
554
 
@@ -463,9 +583,17 @@ export async function startPresence() {
463
583
  leave every answer for the rest of the session with no tools. Null
464
584
  while it is down, and `takeRunMessages` then answers nothing rather
465
585
  than starting an agent that could read none of the work. */
586
+ if (!mayClaim())
587
+ return;
588
+ await reconcileMcpCleanup(p.client, p.userId);
589
+ await reconcileInterruptedWork(p.client, p.identity.id, observeForPresence(p));
590
+ await refreshLegacyTodoHolds(p.client, p.identity.id);
466
591
  const server = toolsServerStatus();
467
- void takeRunMessages(p.client, p.userId, p.identity.id, liveAgents(), p.listener, console.log, console.warn, server.running ? { port: server.port, token: mcpToken() } : null);
468
- })();
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
+ });
596
+ })().catch((error) => { cloudState = confirmedSessionRejection(error) ? 'sign-in-required' : 'offline'; console.warn(`Cloud work is paused: ${String(error)}`); });
469
597
  /* !Cleanup Phase 6b (I43) — THE LIVENESS WATCH, on the heartbeat rather
470
598
  than the orchestrator tick.
471
599
 
@@ -477,7 +605,8 @@ export async function startPresence() {
477
605
  Scoped to this machine's own workers and gated on BOTH a stale
478
606
  `updated_at` and a genuinely dead process, so an agent inside a long
479
607
  tool call is never reaped for being quiet. */
480
- void reapDeadWorkers(p.client, p.identity.id, p.listener.livePids);
608
+ if (mayClaim())
609
+ void reapDeadWorkers(p.client, p.identity.id, p.listener.livePids);
481
610
  }, HEARTBEAT_INTERVAL_MS);
482
611
  p.cp = setInterval(() => void pollCommands(p), COMMAND_POLL_INTERVAL_MS);
483
612
  p.ot = setInterval(() => void pollOrchestrator(p), ORCHESTRATOR_POLL_INTERVAL_MS);
@@ -495,7 +624,7 @@ export async function startPresence() {
495
624
  // best-effort: a tools-server or registration failure must never break the
496
625
  // presence heartbeat above, so it's caught and warned like everything here.
497
626
  try {
498
- await startToolsServer({ client, userId, machineId: identity.id });
627
+ await startToolsServer({ client: p.client, userId: p.userId, machineId: identity.id });
499
628
  /* ═══ AND THE TOOLS SERVER IS POINTED AT WHATEVER CLIENT IS LIVE BY NOW,
500
629
  WHICH IS NOT NECESSARILY THE ONE IT WAS JUST STARTED WITH. ═══
501
630
  `startToolsServer` sets `toolsClient = deps.client`, and `deps.client`
@@ -510,7 +639,6 @@ export async function startPresence() {
510
639
  stale-token root cause, reintroduced by ordering alone.
511
640
  `p.client` rather than `client`, because the whole point is to pick up a
512
641
  rebuild the local const cannot see. */
513
- setToolsClient(p.client);
514
642
  /* The instructions belong to the release, so they are rewritten beside
515
643
  registration on every start: the pair is "make this machine's agents
516
644
  ready", and a machine registered against current tools while reading a
@@ -528,7 +656,14 @@ export async function startPresence() {
528
656
  }
529
657
  // Both cs open and cs start answer cards through this one lifecycle.
530
658
  // Read the owner's client on every use, including after refresh or logout.
531
- p.panel = startPanel(() => p.client);
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();
532
667
  return { machineName: identity.name, agents };
533
668
  })();
534
669
  try {
@@ -536,7 +671,7 @@ export async function startPresence() {
536
671
  }
537
672
  catch (error) {
538
673
  await stopPresence();
539
- releaseDaemonLock();
674
+ cloudState = error instanceof NotLoggedIn || confirmedSessionRejection(error) ? 'sign-in-required' : 'offline';
540
675
  throw error;
541
676
  }
542
677
  finally {
@@ -561,6 +696,7 @@ export async function startPresence() {
561
696
  export async function stopPresence({ unregister = false } = {}) {
562
697
  if (stopping)
563
698
  return stopping;
699
+ presenceGeneration++;
564
700
  const p = presence;
565
701
  if (!p)
566
702
  return;
@@ -607,13 +743,13 @@ export async function stopPresence({ unregister = false } = {}) {
607
743
  .update({ stopped_at: new Date().toISOString() })
608
744
  .eq('machine_id', p.identity.id);
609
745
  }
610
- catch { /* ignore — a lapsed heartbeat reads as offline anyway */ }
746
+ catch { /* A lapsed heartbeat reads as offline. */ }
747
+ await disposeClient(p.client);
611
748
  })();
612
749
  try {
613
750
  await stopping;
614
751
  }
615
752
  finally {
616
753
  stopping = null;
617
- releaseDaemonLock();
618
754
  }
619
755
  }