@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/README.md +50 -3
- package/dist/agents.js +175 -1
- package/dist/autostart.js +103 -122
- package/dist/codex-home.js +14 -11
- package/dist/companion-ui.js +54 -6
- package/dist/companion.js +86 -169
- package/dist/config.js +199 -17
- package/dist/daemon-lifecycle.js +575 -0
- package/dist/daemon-lock.js +149 -42
- package/dist/daemon-processes.js +860 -0
- package/dist/daemon.js +14 -46
- package/dist/darwin-coalition.js +340 -0
- package/dist/failure-reason.js +76 -16
- package/dist/index.js +75 -76
- package/dist/login.js +9 -9
- package/dist/mcp.js +55 -56
- package/dist/native/darwin-coalition +0 -0
- package/dist/native/darwin-coalition.build.json +1 -0
- package/dist/native/darwin-coalition.c +145 -0
- package/dist/orchestrator.js +892 -575
- package/dist/panel3/coordinator.js +3 -1
- package/dist/panel3/presence.js +1 -1
- package/dist/panel3/run.js +996 -558
- package/dist/panel3/spawn.js +85 -23
- package/dist/panel3/tools.js +5 -0
- package/dist/presence-heartbeat.js +3 -0
- package/dist/presence.js +271 -135
- package/dist/supabase.js +173 -37
- package/dist/win-shell.js +464 -1
- package/dist/windows-job.js +312 -0
- package/package.json +4 -3
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,
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 { /*
|
|
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
|
}
|