@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.
- package/dist/agents.js +175 -1
- package/dist/codex-home.js +14 -11
- package/dist/companion-ui.js +24 -4
- package/dist/companion.js +33 -25
- package/dist/config.js +147 -16
- package/dist/daemon-lifecycle.js +39 -12
- package/dist/daemon-processes.js +123 -19
- package/dist/failure-reason.js +76 -16
- package/dist/index.js +6 -3
- package/dist/login.js +6 -8
- package/dist/mcp.js +55 -56
- package/dist/orchestrator.js +322 -197
- package/dist/panel3/coordinator.js +3 -1
- package/dist/panel3/presence.js +1 -1
- package/dist/panel3/run.js +220 -84
- package/dist/panel3/spawn.js +26 -12
- package/dist/panel3/tools.js +5 -0
- package/dist/presence-heartbeat.js +3 -0
- package/dist/presence.js +122 -145
- package/dist/supabase.js +141 -39
- package/package.json +1 -1
package/dist/panel3/spawn.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
452
|
-
return failed(
|
|
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
|
package/dist/panel3/tools.js
CHANGED
|
@@ -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,
|
|
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
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
|
-
|
|
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
|
|
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
|
-
|
|
183
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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 (
|
|
242
|
-
cloudState = confirmedSessionRejection(
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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 { /*
|
|
746
|
+
catch { /* A lapsed heartbeat reads as offline. */ }
|
|
747
|
+
await disposeClient(p.client);
|
|
771
748
|
})();
|
|
772
749
|
try {
|
|
773
750
|
await stopping;
|