@awak-app/simy-cli 0.6.8 → 0.6.10
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 +5 -3
- package/package.json +1 -1
- package/src/agent.js +63 -16
- package/src/cli-contract.js +1 -1
- package/src/desktop-host.js +30 -6
- package/src/durable-local-task-worker.js +35 -9
- package/src/session-supervision/analysis-manager.js +76 -19
- package/src/session-supervision/codex-app-server.js +1 -0
- package/src/session-supervision/continuation-analysis.js +3 -3
- package/src/session-supervision/conversation-context.js +7 -0
- package/src/session-supervision/index.js +1 -0
- package/src/sqm/provider-diagnostics.js +1 -1
- package/src/sqm/review-worker.js +24 -5
package/README.md
CHANGED
|
@@ -60,9 +60,11 @@ that a task can execute successfully; permissions and service availability are
|
|
|
60
60
|
checked when work starts.
|
|
61
61
|
|
|
62
62
|
The sidebar's **Organization** selector is global across Sessions, Tasks and SQM
|
|
63
|
-
and shows active memberships of the connected SIMY account. Switching
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
and shows active memberships of the connected SIMY account. Switching is available
|
|
64
|
+
while work is running and opens browser authorization for the selected organization.
|
|
65
|
+
It cancels running and queued SIMY analyses before changing organizations; ordinary
|
|
66
|
+
implementation tasks keep their original organization. Use the same account in the
|
|
67
|
+
browser; cancel an unfinished switch from the sidebar.
|
|
66
68
|
|
|
67
69
|
Browser authorization links expire after **20 minutes**. The app shows **Connection
|
|
68
70
|
timed out** and **Reconnect** when an active request expires. Reconnect starts a
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -174,6 +174,8 @@ export async function startAgent({
|
|
|
174
174
|
deferStartupSynchronization = false,
|
|
175
175
|
enforceDesktopAccess = false,
|
|
176
176
|
onDesktopStatusChange = null,
|
|
177
|
+
suspendOrganizationAnalyses = async () => {},
|
|
178
|
+
resumeOrganizationAnalyses = async () => {},
|
|
177
179
|
dependencies = {},
|
|
178
180
|
quiet = false,
|
|
179
181
|
} = {}) {
|
|
@@ -230,6 +232,7 @@ export async function startAgent({
|
|
|
230
232
|
pendingOrganization = null;
|
|
231
233
|
desktopLoginPending = false;
|
|
232
234
|
loginUrl = null;
|
|
235
|
+
void resumeOrganizationWork().catch(() => {});
|
|
233
236
|
refreshAvailableCapabilities();
|
|
234
237
|
notifyDesktopStatusChange();
|
|
235
238
|
},
|
|
@@ -315,8 +318,9 @@ export async function startAgent({
|
|
|
315
318
|
session?.account_id ?? null, session?.organization_id ?? null, session?.device_id ?? null, session?.token ?? null,
|
|
316
319
|
pendingOrganization?.id ?? null, isSessionValid(session, Date.now(), apiOrigin),
|
|
317
320
|
])).digest('hex');
|
|
318
|
-
const desktopWorkAllowed = () => !desktopWorkSuspended && (!enforceDesktopAccess || desktopAccessReady(desktopAccess.snapshot()));
|
|
321
|
+
const desktopWorkAllowed = () => !desktopWorkSuspended && !pendingOrganization && (!enforceDesktopAccess || desktopAccessReady(desktopAccess.snapshot()));
|
|
319
322
|
const authorizeDesktopWork = async expectedScope => {
|
|
323
|
+
if (pendingOrganization) throw Object.assign(new Error("Organization switching is in progress."), { code: "desktop_access_required" });
|
|
320
324
|
if (desktopWorkSuspended || shuttingDown) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
|
|
321
325
|
if (enforceDesktopAccess) {
|
|
322
326
|
requireDesktopAccess(await desktopAccess.refresh());
|
|
@@ -325,6 +329,9 @@ export async function startAgent({
|
|
|
325
329
|
if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
|
|
326
330
|
return scope;
|
|
327
331
|
}
|
|
332
|
+
const scope = desktopScopeRevision();
|
|
333
|
+
if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
|
|
334
|
+
return scope;
|
|
328
335
|
};
|
|
329
336
|
refreshAvailableCapabilities();
|
|
330
337
|
let durableLocalTaskWorker = null;
|
|
@@ -482,9 +489,30 @@ export async function startAgent({
|
|
|
482
489
|
}
|
|
483
490
|
};
|
|
484
491
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
492
|
+
let organizationCancellation = Promise.resolve();
|
|
493
|
+
const suspendOrganizationWork = () => {
|
|
494
|
+
durableLocalTaskWorker?.pause?.();
|
|
495
|
+
const cancellation = Promise.all([
|
|
496
|
+
sqmLocalReviewWorker?.suspend?.(), suspendOrganizationAnalyses(),
|
|
497
|
+
]);
|
|
498
|
+
// Preserve failures for the authorization callback without an unhandled rejection.
|
|
499
|
+
cancellation.catch(() => {});
|
|
500
|
+
organizationCancellation = cancellation;
|
|
501
|
+
return cancellation;
|
|
502
|
+
};
|
|
503
|
+
const resumeOrganizationWork = async () => {
|
|
504
|
+
const nonce = authNonce;
|
|
505
|
+
await organizationCancellation;
|
|
506
|
+
const shouldResume = () => !pendingOrganization && nonce === authNonce && !shuttingDown;
|
|
507
|
+
if (!shouldResume()) return;
|
|
508
|
+
await sqmLocalReviewWorker?.resume?.({ shouldResume });
|
|
509
|
+
if (!shouldResume()) return;
|
|
510
|
+
await durableLocalTaskWorker?.resume?.();
|
|
511
|
+
if (!shouldResume()) return;
|
|
512
|
+
await resumeOrganizationAnalyses({ shouldResume });
|
|
513
|
+
};
|
|
514
|
+
const sameRunScope = (left, right) => ["account_id", "organization_id", "device_id"].every(field =>
|
|
515
|
+
(left?.[field] ?? null) === (right?.[field] ?? null));
|
|
488
516
|
const refreshOrganizations = async () => {
|
|
489
517
|
if (!isSessionValid(session, Date.now(), apiOrigin)) return;
|
|
490
518
|
if (organizationRefresh) return organizationRefresh;
|
|
@@ -531,9 +559,9 @@ export async function startAgent({
|
|
|
531
559
|
if (choice.desktopAccess && choice.desktopAccess !== "ready") throw new Error("This organization does not have SIMY Developer access.");
|
|
532
560
|
organizationSlug = choice.slug;
|
|
533
561
|
if (!organizationSlug) throw new Error('Refresh organizations before switching; its verified slug is unavailable.');
|
|
534
|
-
if (organizationBusy()) throw Object.assign(new Error("Wait for running tasks to finish before switching organizations."), { code: "desktop_organization_busy" });
|
|
535
562
|
authNonce = randomBytes(16).toString("base64url");
|
|
536
563
|
pendingOrganization = { id: organizationId, accountId: session.account_id };
|
|
564
|
+
suspendOrganizationWork();
|
|
537
565
|
loginUrl = null;
|
|
538
566
|
refreshAvailableCapabilities();
|
|
539
567
|
notifyDesktopStatusChange();
|
|
@@ -575,6 +603,7 @@ export async function startAgent({
|
|
|
575
603
|
const restoreRemoteRuns = async () => {
|
|
576
604
|
if (!desktopWorkAllowed()) return;
|
|
577
605
|
if (shuttingDown || !isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) return;
|
|
606
|
+
const requestedSession = session;
|
|
578
607
|
const snapshots = dependencies.listRemoteRuns
|
|
579
608
|
? await dependencies.listRemoteRuns({
|
|
580
609
|
apiOrigin,
|
|
@@ -586,15 +615,17 @@ export async function startAgent({
|
|
|
586
615
|
apiBaseUrl: session.api_base_url,
|
|
587
616
|
token: session.token,
|
|
588
617
|
});
|
|
618
|
+
if (session !== requestedSession || !desktopWorkAllowed()) return;
|
|
589
619
|
const remoteIds = new Set();
|
|
590
620
|
let restored = false;
|
|
591
621
|
for (const snapshot of snapshots) {
|
|
592
|
-
if (shuttingDown) return;
|
|
622
|
+
if (shuttingDown || session !== requestedSession || !desktopWorkAllowed()) return;
|
|
593
623
|
if (!snapshot?.id) continue;
|
|
594
624
|
remoteIds.add(snapshot.id);
|
|
595
625
|
if (registry.has(snapshot.id)) {
|
|
596
626
|
const localRun = registry.get(snapshot.id);
|
|
597
|
-
localRun.session
|
|
627
|
+
if (!sameRunScope(localRun.session, requestedSession)) continue;
|
|
628
|
+
localRun.session = requestedSession;
|
|
598
629
|
localRun.apiOrigin = apiOrigin;
|
|
599
630
|
// A newer ledger is not permission to replace an active local operation.
|
|
600
631
|
// Keep the owner, child, checkpoint and approval state until it settles.
|
|
@@ -644,16 +675,21 @@ export async function startAgent({
|
|
|
644
675
|
|
|
645
676
|
const restoreLocalCodingRuns = async () => {
|
|
646
677
|
if (!desktopWorkAllowed()) return;
|
|
678
|
+
const requestedSession = session;
|
|
647
679
|
const { snapshots, errors } = await loadCodingRunSnapshots(codingRunStateRoot);
|
|
680
|
+
if (session !== requestedSession || !desktopWorkAllowed()) return;
|
|
648
681
|
for (const error of errors) {
|
|
649
682
|
reportStartupConnectionError(new Error(`Coding run ${error.id}: ${error.message}`), quiet);
|
|
650
683
|
}
|
|
651
684
|
for (const snapshot of snapshots) {
|
|
685
|
+
const savedActor = snapshot?.metadata?.recovery_actor;
|
|
686
|
+
if (savedActor && ["account_id", "organization_id", "device_id"].some(field => savedActor[field] && savedActor[field] !== requestedSession?.[field])) continue;
|
|
652
687
|
const boundDevice = snapshot?.metadata?.execution_device_id;
|
|
653
688
|
if (boundDevice && boundDevice !== session?.device_id) continue;
|
|
654
689
|
if (registry.has(snapshot.id)) {
|
|
655
690
|
const existing = registry.get(snapshot.id);
|
|
656
|
-
existing.session
|
|
691
|
+
if (!sameRunScope(existing.session, requestedSession)) continue;
|
|
692
|
+
existing.session = requestedSession;
|
|
657
693
|
existing.apiOrigin = apiOrigin;
|
|
658
694
|
continue;
|
|
659
695
|
}
|
|
@@ -705,6 +741,7 @@ export async function startAgent({
|
|
|
705
741
|
connectionStatus.attempt();
|
|
706
742
|
notifyDesktopStatusChange();
|
|
707
743
|
let deviceHeartbeatSucceeded = false;
|
|
744
|
+
const requestedSession = session;
|
|
708
745
|
const advertisedProviders = JSON.stringify(providerStatuses);
|
|
709
746
|
heartbeatInFlight = Promise.resolve()
|
|
710
747
|
.then(async () => { if (enforceDesktopAccess) await desktopAccess.refresh(true); })
|
|
@@ -742,12 +779,12 @@ export async function startAgent({
|
|
|
742
779
|
const sqmResult = sqmEnabled && sqmLocalReviewWorker
|
|
743
780
|
? await sqmLocalReviewWorker.poll()
|
|
744
781
|
: null;
|
|
745
|
-
if (shuttingDown) return false;
|
|
782
|
+
if (shuttingDown || pendingOrganization || !desktopWorkAllowed() || session !== requestedSession) return false;
|
|
746
783
|
if (!sqmResult && durableLocalTaskWorker) await durableLocalTaskWorker.poll();
|
|
747
784
|
return true;
|
|
748
785
|
})
|
|
749
786
|
.catch((error) => {
|
|
750
|
-
if (!deviceHeartbeatSucceeded) {
|
|
787
|
+
if (!deviceHeartbeatSucceeded && session === requestedSession) {
|
|
751
788
|
const state = connectionStatus.failure(error);
|
|
752
789
|
notifyDesktopStatusChange();
|
|
753
790
|
if (state === "unauthorized") {
|
|
@@ -918,7 +955,7 @@ export async function startAgent({
|
|
|
918
955
|
}
|
|
919
956
|
};
|
|
920
957
|
return (dependencies.runSqmLocalReview || ((value, selected, options) => runStructuredProviderReview(selected, value, options)))(request, provider, {
|
|
921
|
-
beforeDispatch, readOnlyAnalysis: true, enableEngineFallback: settings.enable_engine_fallback,
|
|
958
|
+
signal: context.signal, beforeDispatch, readOnlyAnalysis: true, enableEngineFallback: settings.enable_engine_fallback,
|
|
922
959
|
allowedFallbackProviders: settings.allowed_fallback_providers, providerReportingSupported: context.providerReportingSupported === true,
|
|
923
960
|
});
|
|
924
961
|
},
|
|
@@ -1248,11 +1285,16 @@ export async function startAgent({
|
|
|
1248
1285
|
const nextOrganization = body.organization_id ?? body.org_id;
|
|
1249
1286
|
const nextAccount = body.account_id ?? body.auth_user_id;
|
|
1250
1287
|
const changingScope = session && (session.organization_id !== nextOrganization || session.account_id !== nextAccount);
|
|
1251
|
-
if (
|
|
1252
|
-
(pendingOrganization.id !== nextOrganization || pendingOrganization.accountId !== nextAccount))
|
|
1253
|
-
json(res, 409, { error: "Organization authorization does not match
|
|
1288
|
+
if (pendingOrganization &&
|
|
1289
|
+
(pendingOrganization.id !== nextOrganization || pendingOrganization.accountId !== nextAccount)) {
|
|
1290
|
+
json(res, 409, { error: "Organization authorization does not match." });
|
|
1254
1291
|
return;
|
|
1255
1292
|
}
|
|
1293
|
+
if (changingScope && !pendingOrganization) {
|
|
1294
|
+
pendingOrganization = { id: nextOrganization, accountId: nextAccount };
|
|
1295
|
+
suspendOrganizationWork();
|
|
1296
|
+
}
|
|
1297
|
+
await organizationCancellation;
|
|
1256
1298
|
const previousSession = session ?? reclaimableSession;
|
|
1257
1299
|
const authorizedSession = {
|
|
1258
1300
|
token: String(body.token || ""),
|
|
@@ -1310,6 +1352,10 @@ export async function startAgent({
|
|
|
1310
1352
|
json(res, 410, { error: "Connection expired. Reconnect from SIMY." }); return;
|
|
1311
1353
|
}
|
|
1312
1354
|
await writeSession(apiOrigin, authorizedSession, sessionRoot);
|
|
1355
|
+
if (authorizationWindow.snapshot().state === "timed_out" || requestNonce !== authNonce) {
|
|
1356
|
+
await writeSession(apiOrigin, session ?? {}, sessionRoot);
|
|
1357
|
+
json(res, 410, { error: "Connection expired. Reconnect from SIMY." }); return;
|
|
1358
|
+
}
|
|
1313
1359
|
session = authorizedSession;
|
|
1314
1360
|
reclaimableSession = null;
|
|
1315
1361
|
authorizationWindow.clear();
|
|
@@ -1327,6 +1373,7 @@ export async function startAgent({
|
|
|
1327
1373
|
startHeartbeatTimer();
|
|
1328
1374
|
loginUrl = null;
|
|
1329
1375
|
registry.emit("change", registry.list());
|
|
1376
|
+
await resumeOrganizationWork();
|
|
1330
1377
|
json(res, 200, {
|
|
1331
1378
|
ok: true,
|
|
1332
1379
|
expires_at: session.expires_at,
|
|
@@ -2517,7 +2564,7 @@ export async function startAgent({
|
|
|
2517
2564
|
connection: connectionStatus.snapshot({ authenticated: auth.state === "signed_in" }),
|
|
2518
2565
|
tasks: desktopTaskStatus([registry, directRegistry, localTaskRegistry]),
|
|
2519
2566
|
organizations: { state: organizationState, items: organizationItems, selectedId: session?.organization_id ?? null,
|
|
2520
|
-
switching: Boolean(pendingOrganization), canSwitch: auth.state === "signed_in"
|
|
2567
|
+
switching: Boolean(pendingOrganization), canSwitch: auth.state === "signed_in" },
|
|
2521
2568
|
sqm: { enabled: sqmEnabled, engineFallbackEnabled: sqmEngineSettings.enable_engine_fallback,
|
|
2522
2569
|
allowedFallbackProviders: sqmEngineSettings.allowed_fallback_providers,
|
|
2523
2570
|
paused: sqmLocalReviewWorker?.snapshot?.().paused === true, pauseReason: sqmLocalReviewWorker?.snapshot?.().pause_reason ?? null, canEnable: auth.state === "signed_in" && Boolean(session?.account_id && session?.organization_id) && !pendingOrganization && desktopWorkAllowed(),
|
|
@@ -2564,7 +2611,7 @@ export async function startAgent({
|
|
|
2564
2611
|
getDesktopAccess: () => ({ ...desktopAccess.snapshot(), revision: desktopScopeRevision() }),
|
|
2565
2612
|
requireDesktopAccess: authorizeDesktopWork,
|
|
2566
2613
|
refreshOrganizations,
|
|
2567
|
-
cancelOrganizationSelection() { authorizationWindow.clear(); expiredOrganizationId = null; expiredInteractiveAuthorization = false; authNonce = randomBytes(16).toString("base64url"); pendingOrganization = null; desktopLoginPending = false; loginUrl = null; refreshAvailableCapabilities(); notifyDesktopStatusChange(); },
|
|
2614
|
+
async cancelOrganizationSelection() { authorizationWindow.clear(); expiredOrganizationId = null; expiredInteractiveAuthorization = false; authNonce = randomBytes(16).toString("base64url"); pendingOrganization = null; desktopLoginPending = false; loginUrl = null; refreshAvailableCapabilities(); notifyDesktopStatusChange(); await resumeOrganizationWork(); },
|
|
2568
2615
|
async setSqmEnabled(enabled) {
|
|
2569
2616
|
if (enabled) await authorizeDesktopWork();
|
|
2570
2617
|
if (!isSessionValid(session, Date.now(), apiOrigin) || pendingOrganization) throw new Error("Sign in to an organization first.");
|
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.6.
|
|
16
|
+
export const CLI_VERSION = "0.6.10";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
package/src/desktop-host.js
CHANGED
|
@@ -115,11 +115,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
115
115
|
};
|
|
116
116
|
const accessSnapshot = async () => setAccessSnapshot((await agent?.getDesktopStatus?.())?.access);
|
|
117
117
|
let stoppingForUpdate = false;
|
|
118
|
+
let organizationAnalysesSuspended = false;
|
|
119
|
+
let organizationAnalysisDrain = Promise.resolve();
|
|
120
|
+
let organizationAnalysisEpoch = 0;
|
|
118
121
|
const authorizeWork = async expectedScope => {
|
|
119
|
-
if (closed || stoppingForUpdate) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
|
|
122
|
+
if (closed || stoppingForUpdate || organizationAnalysesSuspended) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
|
|
120
123
|
if (agent?.requireDesktopAccess) await agent.requireDesktopAccess();
|
|
121
124
|
const access = await accessSnapshot();
|
|
122
|
-
if (closed || stoppingForUpdate) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
|
|
125
|
+
if (closed || stoppingForUpdate || organizationAnalysesSuspended) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
|
|
123
126
|
requireDesktopAccess(access);
|
|
124
127
|
const scope = accessScope(access);
|
|
125
128
|
if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
|
|
@@ -196,6 +199,29 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
196
199
|
enroll: guardedEnroll, beforeEnroll: candidate => authorizeWork(selectedScopes.get(candidate)),
|
|
197
200
|
onChange: () => { if (!closed && !relay) refreshSessions(true); },
|
|
198
201
|
});
|
|
202
|
+
const suspendOrganizationAnalyses = () => {
|
|
203
|
+
organizationAnalysisEpoch++;
|
|
204
|
+
organizationAnalysesSuspended = true;
|
|
205
|
+
discoveryStopped = true;
|
|
206
|
+
organizationAnalysisDrain = Promise.all([
|
|
207
|
+
stopDiscovery({ drain: true }), automaticContinuation?.close(), analysisManager?.suspend?.(),
|
|
208
|
+
]);
|
|
209
|
+
return organizationAnalysisDrain;
|
|
210
|
+
};
|
|
211
|
+
const resumeOrganizationAnalyses = async ({ shouldResume = () => true } = {}) => {
|
|
212
|
+
const epoch = organizationAnalysisEpoch;
|
|
213
|
+
const current = () => epoch === organizationAnalysisEpoch && shouldResume() && !closed && !stoppingForUpdate;
|
|
214
|
+
await organizationAnalysisDrain;
|
|
215
|
+
if (!current()) return;
|
|
216
|
+
await accessSnapshot();
|
|
217
|
+
if (!current()) return;
|
|
218
|
+
await analysisManager?.resume?.({ shouldResume: current });
|
|
219
|
+
if (!current()) return;
|
|
220
|
+
automaticContinuation = makeAutomaticContinuation();
|
|
221
|
+
organizationAnalysesSuspended = false;
|
|
222
|
+
discoveryStopped = false;
|
|
223
|
+
refreshSessions(true);
|
|
224
|
+
};
|
|
199
225
|
let pluginSetup = { state: "unknown" };
|
|
200
226
|
let providerPlugins = { codex: { state: 'checking' }, claude: { state: 'checking' } };
|
|
201
227
|
let pluginInspection = null, lastPluginInspectionAt = -Infinity;
|
|
@@ -344,6 +370,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
344
370
|
agent = await (dependencies.startAgent || startAgent)({
|
|
345
371
|
daemon: true, webOrigin, sessionRoot: root, quiet: true, updateManager: updater,
|
|
346
372
|
deferStartupSynchronization: true, enforceDesktopAccess: true,
|
|
373
|
+
suspendOrganizationAnalyses, resumeOrganizationAnalyses,
|
|
347
374
|
onDesktopStatusChange: () => {
|
|
348
375
|
// Let an in-progress frame finish before publishing its new snapshot.
|
|
349
376
|
setImmediate(() => { void publish().catch(() => {}); });
|
|
@@ -551,12 +578,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
551
578
|
else if (action === "sqm_set_enabled") await agent.setSqmEnabled(command.enabled);
|
|
552
579
|
else if (action === "sqm_set_engine_fallback") await agent.setSqmEngineFallback({ enabled: command.enabled, allowedProviders: command.allowedProviders });
|
|
553
580
|
else if (action === "sqm_retry_environment") await agent.retrySqmEnvironment();
|
|
554
|
-
else if (action === "organization_cancel") agent.cancelOrganizationSelection();
|
|
581
|
+
else if (action === "organization_cancel") await agent.cancelOrganizationSelection();
|
|
555
582
|
else if (action === "login" || action === "organization_select" || action === "authorization_open") {
|
|
556
583
|
if (action === "organization_select" && (typeof command.organizationId !== "string" || !command.organizationId)) throw new Error("Select an organization first.");
|
|
557
|
-
if (action === "organization_select" && activeQueuedAction && !["refresh", "reconnect", "task_detail", "organizations_refresh", "check_update"].includes(activeQueuedAction)) {
|
|
558
|
-
throw Object.assign(new Error("Wait for the current action to finish before switching organizations."), { code: "desktop_organization_busy" });
|
|
559
|
-
}
|
|
560
584
|
const url = action === "authorization_open" ? (agent.getPendingAuthorizationUrl?.() ?? agent.retryAuthorization?.())
|
|
561
585
|
: await agent.requestDesktopLogin(action === "organization_select" ? command.organizationId : null);
|
|
562
586
|
if (action === 'authorization_open' && !url) throw new Error('No pending authorization.');
|
|
@@ -158,6 +158,17 @@ export function createDurableLocalTaskApi({
|
|
|
158
158
|
const stepContract = durableLocalTaskStepApiContract();
|
|
159
159
|
|
|
160
160
|
return {
|
|
161
|
+
// A claimed task owns its original identity through every heartbeat,
|
|
162
|
+
// artifact request and final status, even if the desktop changes accounts.
|
|
163
|
+
capture() {
|
|
164
|
+
const connection = { ...getConnection() };
|
|
165
|
+
return createDurableLocalTaskApi({
|
|
166
|
+
getConnection: () => connection,
|
|
167
|
+
fetchImpl,
|
|
168
|
+
requestTimeoutMs,
|
|
169
|
+
artifactUploadTimeoutMs,
|
|
170
|
+
});
|
|
171
|
+
},
|
|
161
172
|
async claimSubtaskParent({
|
|
162
173
|
workerId,
|
|
163
174
|
leaseSeconds = DEFAULT_LEASE_SECONDS,
|
|
@@ -633,11 +644,13 @@ export function createDurableLocalTaskWorker({
|
|
|
633
644
|
}
|
|
634
645
|
|
|
635
646
|
let closed = false;
|
|
647
|
+
let paused = false;
|
|
636
648
|
let pollPromise = null;
|
|
637
649
|
let active = null;
|
|
638
650
|
let activeExecutionPromise = null;
|
|
639
651
|
let lastStepDisposition = null;
|
|
640
652
|
let pendingStepTurnId = null;
|
|
653
|
+
let pendingStepContext = null;
|
|
641
654
|
const boundedTasks = new Map();
|
|
642
655
|
|
|
643
656
|
const executeBoundedRuntime = runBoundedSubtask ?? (async (runtime, { signal }) => {
|
|
@@ -700,7 +713,8 @@ export function createDurableLocalTaskWorker({
|
|
|
700
713
|
}
|
|
701
714
|
});
|
|
702
715
|
|
|
703
|
-
const executeStepClaim = async (claim, releaseWork) => {
|
|
716
|
+
const executeStepClaim = async (claim, releaseWork, context) => {
|
|
717
|
+
const { api } = context;
|
|
704
718
|
let task = null;
|
|
705
719
|
let heartbeatTimer = null;
|
|
706
720
|
let heartbeatInFlight = null;
|
|
@@ -1010,6 +1024,7 @@ export function createDurableLocalTaskWorker({
|
|
|
1010
1024
|
return;
|
|
1011
1025
|
}
|
|
1012
1026
|
pendingStepTurnId = normalized.turnId;
|
|
1027
|
+
pendingStepContext = context;
|
|
1013
1028
|
}
|
|
1014
1029
|
} catch (error) {
|
|
1015
1030
|
if (!leaseLost) {
|
|
@@ -1051,9 +1066,10 @@ export function createDurableLocalTaskWorker({
|
|
|
1051
1066
|
}
|
|
1052
1067
|
};
|
|
1053
1068
|
|
|
1054
|
-
const executeClaim = async (claim, releaseWork) => {
|
|
1069
|
+
const executeClaim = async (claim, releaseWork, context) => {
|
|
1070
|
+
const { api, attachmentStore, deviceId } = context;
|
|
1055
1071
|
if (isDurableLocalTaskStepClaim(claim)) {
|
|
1056
|
-
return executeStepClaim(claim, releaseWork);
|
|
1072
|
+
return executeStepClaim(claim, releaseWork, context);
|
|
1057
1073
|
}
|
|
1058
1074
|
let task = null;
|
|
1059
1075
|
let heartbeatTimer = null;
|
|
@@ -1094,7 +1110,6 @@ export function createDurableLocalTaskWorker({
|
|
|
1094
1110
|
"durable_local_task_attachments_unavailable",
|
|
1095
1111
|
);
|
|
1096
1112
|
}
|
|
1097
|
-
const deviceId = getDeviceId();
|
|
1098
1113
|
attachments = await attachmentStore.materialize({
|
|
1099
1114
|
refs: normalized.attachmentRefs,
|
|
1100
1115
|
deviceId,
|
|
@@ -1374,7 +1389,7 @@ export function createDurableLocalTaskWorker({
|
|
|
1374
1389
|
) {
|
|
1375
1390
|
await attachmentStore.cleanup({
|
|
1376
1391
|
refs: materializedAttachmentRefs,
|
|
1377
|
-
deviceId
|
|
1392
|
+
deviceId,
|
|
1378
1393
|
}).catch((error) => onError(recoveryError(
|
|
1379
1394
|
"SIMY could not remove the staged attachment copy.",
|
|
1380
1395
|
error,
|
|
@@ -1393,6 +1408,7 @@ export function createDurableLocalTaskWorker({
|
|
|
1393
1408
|
snapshot() {
|
|
1394
1409
|
return {
|
|
1395
1410
|
closed,
|
|
1411
|
+
paused,
|
|
1396
1412
|
polling: Boolean(pollPromise),
|
|
1397
1413
|
active_turn_id: active?.claim?.turn_id || null,
|
|
1398
1414
|
durable_step_disposition: lastStepDisposition,
|
|
@@ -1401,10 +1417,16 @@ export function createDurableLocalTaskWorker({
|
|
|
1401
1417
|
};
|
|
1402
1418
|
},
|
|
1403
1419
|
async poll() {
|
|
1404
|
-
if (closed || active || pollPromise) return false;
|
|
1420
|
+
if (closed || paused || active || pollPromise) return false;
|
|
1405
1421
|
const releaseWork = beginWork?.();
|
|
1406
1422
|
if (!releaseWork) return false;
|
|
1423
|
+
const context = pendingStepContext || {
|
|
1424
|
+
api: typeof api.capture === "function" ? api.capture() : api,
|
|
1425
|
+
deviceId: getDeviceId(),
|
|
1426
|
+
attachmentStore,
|
|
1427
|
+
};
|
|
1407
1428
|
pollPromise = (async () => {
|
|
1429
|
+
const { api, attachmentStore, deviceId } = context;
|
|
1408
1430
|
try {
|
|
1409
1431
|
const requestedTurnId = pendingStepTurnId;
|
|
1410
1432
|
if (
|
|
@@ -1491,7 +1513,7 @@ export function createDurableLocalTaskWorker({
|
|
|
1491
1513
|
workspaceRoot,
|
|
1492
1514
|
prepareWorkspace,
|
|
1493
1515
|
attachmentStore,
|
|
1494
|
-
deviceId
|
|
1516
|
+
deviceId,
|
|
1495
1517
|
onError,
|
|
1496
1518
|
});
|
|
1497
1519
|
claim = await claimControlledPlanWithReplay(api, {
|
|
@@ -1524,7 +1546,7 @@ export function createDurableLocalTaskWorker({
|
|
|
1524
1546
|
leaseSeconds,
|
|
1525
1547
|
});
|
|
1526
1548
|
lastStepDisposition = normalized.disposition;
|
|
1527
|
-
if (requestedTurnId) pendingStepTurnId = null;
|
|
1549
|
+
if (requestedTurnId) { pendingStepTurnId = null; pendingStepContext = null; }
|
|
1528
1550
|
if (normalized.disposition !== "execute") {
|
|
1529
1551
|
// Approval checkpoints and unknown Provider results are durable
|
|
1530
1552
|
// Backend states, not executable leases. Keep no process,
|
|
@@ -1543,7 +1565,7 @@ export function createDurableLocalTaskWorker({
|
|
|
1543
1565
|
}
|
|
1544
1566
|
if (closed) { releaseWork(); return false; }
|
|
1545
1567
|
active = { claim, task: null };
|
|
1546
|
-
const executionPromise = executeClaim(claim, releaseWork)
|
|
1568
|
+
const executionPromise = executeClaim(claim, releaseWork, context)
|
|
1547
1569
|
.catch((error) => {
|
|
1548
1570
|
onError(
|
|
1549
1571
|
recoveryError(
|
|
@@ -1577,6 +1599,10 @@ export function createDurableLocalTaskWorker({
|
|
|
1577
1599
|
})();
|
|
1578
1600
|
return pollPromise;
|
|
1579
1601
|
},
|
|
1602
|
+
// Pause claims without interrupting an ordinary implementation task.
|
|
1603
|
+
// Already claimed work and a durable plan handoff keep their old scope.
|
|
1604
|
+
pause() { paused = true; },
|
|
1605
|
+
resume() { if (!closed) paused = false; },
|
|
1580
1606
|
async waitForIdle() {
|
|
1581
1607
|
await pollPromise;
|
|
1582
1608
|
await activeExecutionPromise;
|
|
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { analyzeContinuation, CONTINUATION_ANALYSIS_VERSION } from './continuation-analysis.js';
|
|
5
|
+
import { providerGoalEvidence } from './conversation-context.js';
|
|
5
6
|
|
|
6
7
|
const BACKFILL_LIMIT = 10;
|
|
7
8
|
const MAX_CACHE_ENTRIES = 2000;
|
|
@@ -9,12 +10,17 @@ const AUTH_RECOVERY_COOLDOWN_MS = 60_000;
|
|
|
9
10
|
const RESULT_STATES = new Set(['continue', 'no_action', 'needs_user', 'waiting', 'uncertain']);
|
|
10
11
|
const keyOf = candidate => `${candidate.provider}:${candidate.session}`;
|
|
11
12
|
const activityOf = candidate => Number.isFinite(candidate.lastActivityAt) ? candidate.lastActivityAt : 0;
|
|
12
|
-
const
|
|
13
|
-
?
|
|
14
|
-
|
|
13
|
+
const turnOf = candidate => candidate.provider === 'codex' && typeof candidate.latestTurnId === 'string' && candidate.latestTurnId
|
|
14
|
+
? candidate.latestTurnId : null;
|
|
15
|
+
const goalOf = candidate => providerGoalEvidence(candidate.providerGoal);
|
|
16
|
+
const legacyRevision = candidate => createHash('sha256').update(JSON.stringify([
|
|
17
|
+
candidate.provider, candidate.session, candidate.cwd, activityOf(candidate), ...(candidate.isRunning === true ? ['running'] : []),
|
|
18
|
+
...(goalOf(candidate) === null ? [] : ['goal', [candidate.providerGoal.objective ?? null, candidate.providerGoal.status ?? null,
|
|
19
|
+
candidate.providerGoal.updatedAt ?? null, candidate.providerGoal.textTruncated === true]]),
|
|
20
|
+
])).digest('hex');
|
|
15
21
|
const goalRevisionOf = candidate => goalOf(candidate) === null ? null : createHash('sha256').update(JSON.stringify(goalOf(candidate))).digest('hex');
|
|
16
22
|
export const continuationAnalysisRevision = candidate => createHash('sha256')
|
|
17
|
-
.update(JSON.stringify([candidate.provider, candidate.session, candidate.cwd, activityOf(candidate),
|
|
23
|
+
.update(JSON.stringify([candidate.provider, candidate.session, candidate.cwd, turnOf(candidate) ? ['turn', turnOf(candidate)] : activityOf(candidate),
|
|
18
24
|
...(candidate.isRunning === true ? ['running'] : []), ...(goalOf(candidate) === null ? [] : ['goal', goalOf(candidate)])]))
|
|
19
25
|
.digest('hex');
|
|
20
26
|
const eligible = candidate => candidate.capabilities?.canAnalyze === true &&
|
|
@@ -50,11 +56,19 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
50
56
|
const known = new Map(document.known.slice(-MAX_CACHE_ENTRIES));
|
|
51
57
|
const entries = new Map(document.entries.slice(-MAX_CACHE_ENTRIES));
|
|
52
58
|
const candidates = new Map();
|
|
59
|
+
const observedCandidate = candidate => {
|
|
60
|
+
const previous = known.get(keyOf(candidate));
|
|
61
|
+
// A bounded discovery may skip this turn probe. Preserve its prior ID
|
|
62
|
+
// only while identity and activity still match; changed activity must be read.
|
|
63
|
+
return !turnOf(candidate) && candidate.provider === 'codex' && previous?.latestTurnId &&
|
|
64
|
+
previous.cwd === candidate.cwd && previous.activity === activityOf(candidate)
|
|
65
|
+
? { ...candidate, latestTurnId: previous.latestTurnId } : candidate;
|
|
66
|
+
};
|
|
53
67
|
const providerStatuses = new Map(), signedOutChecks = new Map();
|
|
54
68
|
for (const [key, entry] of entries) {
|
|
55
69
|
if (entry.result?.state === 'analyzing') entry.result = { state: 'queued' };
|
|
56
70
|
}
|
|
57
|
-
let serial = Promise.resolve(), draining = null, closed = false, wakeDrain = null, wakeEpoch = 0;
|
|
71
|
+
let serial = Promise.resolve(), draining = null, closed = false, suspended = false, wakeDrain = null, wakeEpoch = 0;
|
|
58
72
|
const jobs = new Map();
|
|
59
73
|
const wake = () => { wakeEpoch++; wakeDrain?.(); };
|
|
60
74
|
const exclusive = fn => {
|
|
@@ -147,7 +161,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
147
161
|
}
|
|
148
162
|
await exclusive(async () => {
|
|
149
163
|
const current = candidates.get(selected.key), entry = entries.get(selected.key);
|
|
150
|
-
if (closed) return;
|
|
164
|
+
if (closed || selected.cancelled) return;
|
|
151
165
|
if (!current || !eligible(current) || current.isRunning === true || continuationAnalysisRevision(current) !== selected.revision || entry?.revision !== selected.revision || entry !== selected.entry) {
|
|
152
166
|
// A stale snapshot can help display/reuse a finished boundary but is
|
|
153
167
|
// never the current recommendation or authority to dispatch work.
|
|
@@ -170,13 +184,13 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
170
184
|
};
|
|
171
185
|
const startDrain = () => {
|
|
172
186
|
wake();
|
|
173
|
-
if (closed || draining) return;
|
|
187
|
+
if (closed || suspended || draining) return;
|
|
174
188
|
let drainFailed = false;
|
|
175
189
|
draining = (async () => {
|
|
176
190
|
while (!closed || jobs.size) {
|
|
177
191
|
const observedWake = wakeEpoch;
|
|
178
192
|
const selected = await exclusive(async () => {
|
|
179
|
-
if (closed) return [];
|
|
193
|
+
if (closed || suspended) return [];
|
|
180
194
|
const batch = [];
|
|
181
195
|
// Explicit retries precede historical backfill without interrupting owned jobs.
|
|
182
196
|
const ordered = [...entries].sort((a, b) => Number(Boolean(b[1].requestId)) - Number(Boolean(a[1].requestId)));
|
|
@@ -207,13 +221,13 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
207
221
|
}
|
|
208
222
|
})().catch(() => { drainFailed = true; }).finally(() => {
|
|
209
223
|
draining = null;
|
|
210
|
-
if (!closed && !drainFailed && [...entries].some(([key, entry]) => runnable(key, entry))) startDrain();
|
|
224
|
+
if (!closed && !suspended && !drainFailed && [...entries].some(([key, entry]) => runnable(key, entry))) startDrain();
|
|
211
225
|
});
|
|
212
226
|
};
|
|
213
227
|
return {
|
|
214
228
|
async updateProviderStatuses(statuses = {}) {
|
|
215
229
|
await exclusive(async () => {
|
|
216
|
-
if (closed) return;
|
|
230
|
+
if (closed || suspended) return;
|
|
217
231
|
for (const provider of ['codex', 'claude']) {
|
|
218
232
|
const status = statuses[provider];
|
|
219
233
|
if (!status) continue;
|
|
@@ -229,9 +243,9 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
229
243
|
},
|
|
230
244
|
async update(sessions) {
|
|
231
245
|
await exclusive(async () => {
|
|
232
|
-
if (closed) return;
|
|
246
|
+
if (closed || suspended) return;
|
|
233
247
|
candidates.clear();
|
|
234
|
-
for (const candidate of sessions) candidates.set(keyOf(candidate), candidate);
|
|
248
|
+
for (const candidate of sessions) candidates.set(keyOf(candidate), observedCandidate(candidate));
|
|
235
249
|
const sorted = [...candidates.values()].sort((a, b) => activityOf(b) - activityOf(a) || keyOf(a).localeCompare(keyOf(b)));
|
|
236
250
|
const backfill = document.initialized ? new Set() : new Set(sorted.slice(0, BACKFILL_LIMIT).map(keyOf));
|
|
237
251
|
let dirty = !document.initialized;
|
|
@@ -242,10 +256,19 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
242
256
|
// Old cache formats did not observe goal/tracking metadata. Establish
|
|
243
257
|
// their baseline once, rather than mistaking the upgrade for activity.
|
|
244
258
|
const goalObserved = candidate.goalReadState !== 'unavailable';
|
|
245
|
-
const goalChanged = previous && Object.hasOwn(previous, 'goalRevision') && previous.goalObserved !== false && goalObserved && previous.goalRevision !== goalRevision;
|
|
259
|
+
const goalChanged = previous && previous.goalEvidenceVersion === 1 && Object.hasOwn(previous, 'goalRevision') && previous.goalObserved !== false && goalObserved && previous.goalRevision !== goalRevision;
|
|
246
260
|
const trackingEnabled = candidate.tracked === true && (!previous || Object.hasOwn(previous, 'tracked') && previous.tracked !== true);
|
|
247
|
-
|
|
261
|
+
// A known turn boundary is stronger than updated_at: tool/status
|
|
262
|
+
// events in that same turn must not enqueue or cancel paid inference.
|
|
263
|
+
const newActivity = document.initialized && (previous ?
|
|
264
|
+
(turnOf(candidate) && previous.latestTurnId ? turnOf(candidate) !== previous.latestTurnId : active > previous.activity) ||
|
|
265
|
+
(candidate.isRunning === true) !== (previous.isRunning === true) : active > document.baselineAt);
|
|
248
266
|
const entry = entries.get(key);
|
|
267
|
+
// Rebase unchanged old cache keys once without rerunning historical
|
|
268
|
+
// inference when semantic goal/turn keys become available.
|
|
269
|
+
if (entry && entry.revision !== revision && entry.cwd === candidate.cwd && entry.revision === legacyRevision(candidate)) {
|
|
270
|
+
entry.revision = revision; dirty = true;
|
|
271
|
+
}
|
|
249
272
|
// Migrate prior idle cache entries without treating historical sessions
|
|
250
273
|
// as fresh activity. Its old revision already proves this exact cwd.
|
|
251
274
|
if (entry && !entry.cwd && entry.revision === continuationAnalysisRevision({ ...candidate, isRunning: false })) {
|
|
@@ -261,9 +284,10 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
261
284
|
if (eligible(candidate) && (backfill.has(key) || newActivity || recheck || trackingEnabled || goalChanged || schemaRefresh)) {
|
|
262
285
|
dirty = enqueue(candidate, recheck || trackingEnabled || goalChanged || schemaRefresh, trackingEnabled || goalChanged || schemaRefresh) || dirty;
|
|
263
286
|
}
|
|
264
|
-
if (previous?.revision !== revision || previous?.goalObserved !== goalObserved || (previous?.tracked === true) !== (candidate.tracked === true) ||
|
|
287
|
+
if (previous?.revision !== revision || previous?.activity !== active || previous?.goalEvidenceVersion !== 1 || previous?.goalObserved !== goalObserved || (previous?.tracked === true) !== (candidate.tracked === true) ||
|
|
265
288
|
previous && (!Object.hasOwn(previous, 'goalRevision') || !Object.hasOwn(previous, 'tracked'))) {
|
|
266
|
-
known.delete(key); known.set(key, { revision, activity: active, isRunning: candidate.isRunning,
|
|
289
|
+
known.delete(key); known.set(key, { revision, cwd: candidate.cwd, activity: active, isRunning: candidate.isRunning, latestTurnId: turnOf(candidate),
|
|
290
|
+
tracked: candidate.tracked === true, goalRevision, goalObserved, goalEvidenceVersion: 1 }); dirty = true;
|
|
267
291
|
}
|
|
268
292
|
}
|
|
269
293
|
// Cancelling owned analysis is separate from stopping the original
|
|
@@ -280,6 +304,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
280
304
|
startDrain();
|
|
281
305
|
},
|
|
282
306
|
project(candidate) {
|
|
307
|
+
candidate = observedCandidate(candidate);
|
|
283
308
|
const revision = continuationAnalysisRevision(candidate), entry = entries.get(keyOf(candidate));
|
|
284
309
|
let analysis = entry?.revision === revision ? { ...entry.result, revision, requestId: entry.requestId } : { state: 'unanalysed', revision };
|
|
285
310
|
const previous = entry?.cwd === candidate.cwd ? entry.settledResult : null;
|
|
@@ -295,8 +320,9 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
295
320
|
async request(candidate, { requestId } = {}) {
|
|
296
321
|
if (requestId !== undefined && (typeof requestId !== 'string' || !requestId || requestId.length > 128)) throw new Error('Invalid analysis request identity.');
|
|
297
322
|
if (!candidate || !eligible(candidate)) throw new Error('This conversation cannot be analyzed.');
|
|
323
|
+
candidate = observedCandidate(candidate);
|
|
298
324
|
await exclusive(async () => {
|
|
299
|
-
if (closed) throw new Error('Session analysis is unavailable.');
|
|
325
|
+
if (closed || suspended) throw new Error('Session analysis is unavailable.');
|
|
300
326
|
candidates.set(keyOf(candidate), candidate);
|
|
301
327
|
let dirty = enqueue(candidate, true, true);
|
|
302
328
|
const entry = entries.get(keyOf(candidate));
|
|
@@ -306,8 +332,9 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
306
332
|
startDrain();
|
|
307
333
|
},
|
|
308
334
|
async invalidate(candidate) {
|
|
335
|
+
candidate = observedCandidate(candidate);
|
|
309
336
|
await exclusive(async () => {
|
|
310
|
-
if (closed) throw new Error('Session analysis is unavailable.');
|
|
337
|
+
if (closed || suspended) throw new Error('Session analysis is unavailable.');
|
|
311
338
|
entries.delete(keyOf(candidate));
|
|
312
339
|
candidates.set(keyOf(candidate), candidate);
|
|
313
340
|
enqueue(candidate); await persist(); changed();
|
|
@@ -316,7 +343,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
316
343
|
startDrain();
|
|
317
344
|
},
|
|
318
345
|
requireRecommendation(candidate) {
|
|
319
|
-
if (closed) throw new Error('Session analysis is unavailable.');
|
|
346
|
+
if (closed || suspended) throw new Error('Session analysis is unavailable.');
|
|
320
347
|
const analysis = this.project(candidate).analysis;
|
|
321
348
|
if (!eligible(candidate) || candidate.isRunning === true || analysis.awaitingTurn === true || analysis.state !== 'continue') throw new Error('Analyze this conversation before continuing; it needs a current recommendation.');
|
|
322
349
|
return analysis;
|
|
@@ -329,6 +356,36 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
329
356
|
} while (draining || jobs.size);
|
|
330
357
|
await serial;
|
|
331
358
|
},
|
|
359
|
+
async suspend() {
|
|
360
|
+
// Gate new work before waiting on cache writes. Provider cleanup retains
|
|
361
|
+
// its slot, and cancelled results never publish after an organization switch.
|
|
362
|
+
suspended = true;
|
|
363
|
+
for (const job of jobs.values()) { job.cancelled = true; job.controller.abort(); }
|
|
364
|
+
wake();
|
|
365
|
+
try {
|
|
366
|
+
await exclusive(async () => {
|
|
367
|
+
if (closed) return;
|
|
368
|
+
candidates.clear();
|
|
369
|
+
let dirty = false;
|
|
370
|
+
for (const entry of entries.values()) {
|
|
371
|
+
if (!['queued', 'analyzing'].includes(entry.result?.state)) continue;
|
|
372
|
+
entry.result = { state: 'failed', reasonCode: 'analysis_cancelled',
|
|
373
|
+
reason: 'The analysis was stopped for an organization switch. Request analysis again to retry.' };
|
|
374
|
+
entry.completedAt = now();
|
|
375
|
+
dirty = true;
|
|
376
|
+
}
|
|
377
|
+
if (dirty) { await persist(); changed(); }
|
|
378
|
+
});
|
|
379
|
+
} finally { await this.whenIdle(); }
|
|
380
|
+
},
|
|
381
|
+
async resume({ shouldResume = () => true } = {}) {
|
|
382
|
+
await this.whenIdle();
|
|
383
|
+
await exclusive(() => {
|
|
384
|
+
if (!shouldResume()) return;
|
|
385
|
+
if (closed) throw new Error('Session analysis is unavailable.');
|
|
386
|
+
suspended = false;
|
|
387
|
+
});
|
|
388
|
+
},
|
|
332
389
|
async close() { closed = true; for (const job of jobs.values()) job.controller.abort(); wake(); await this.whenIdle(); },
|
|
333
390
|
};
|
|
334
391
|
}
|
|
@@ -262,6 +262,7 @@ export async function readCodexObservation(client, session, { deadline = Date.no
|
|
|
262
262
|
const page = await client.request('thread/turns/list', { threadId: session, cursor: null, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' }, timeout());
|
|
263
263
|
if (Array.isArray(page?.data) && page.data.length === 1 && ['inProgress', 'completed', 'interrupted', 'failed'].includes(page.data[0]?.status)) {
|
|
264
264
|
observation.isRunning = page.data[0].status === 'inProgress';
|
|
265
|
+
if (typeof page.data[0].id === 'string' && page.data[0].id && page.data[0].id.length <= 160) observation.latestTurnId = page.data[0].id;
|
|
265
266
|
}
|
|
266
267
|
} catch (error) {
|
|
267
268
|
if (['timeout', 'closed', 'unavailable'].includes(error?.code)) return { goalReadState: 'unavailable', providerUnavailable: true };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readCodexConversation } from './codex-app-server.js';
|
|
2
2
|
import { readClaudeConversation } from './claude-sessions.js';
|
|
3
|
-
import { boundConversation, boundProviderGoal, sanitizedText } from './conversation-context.js';
|
|
3
|
+
import { boundConversation, boundProviderGoal, providerGoalEvidence, sanitizedText } from './conversation-context.js';
|
|
4
4
|
import { runStructuredProviderReview } from '../sqm/review-worker.js';
|
|
5
5
|
import { inspectProviderStatus } from '../desktop-status.js';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
@@ -152,7 +152,7 @@ export async function readContinuationContext(candidate, { signal, readContext,
|
|
|
152
152
|
const context = boundConversation(raw?.messages, { truncated: raw?.truncated === true, incomplete: raw?.incomplete === true });
|
|
153
153
|
const providerGoal = boundProviderGoal(raw?.providerGoal);
|
|
154
154
|
const contextDigest = createHash('sha256').update(JSON.stringify({ provider: candidate.provider, session: candidate.session, cwd: candidate.cwd, ...context,
|
|
155
|
-
...(providerGoal ? { providerGoal } : {}) })).digest('hex');
|
|
155
|
+
...(providerGoal ? { providerGoal: providerGoalEvidence(providerGoal) } : {}) })).digest('hex');
|
|
156
156
|
return { ...context, providerGoal, contextDigest, awaitingTurn: raw?.awaitingTurn === true, settledTurnId: typeof raw?.settledTurnId === 'string' ? raw.settledTurnId.slice(0, 160) : null };
|
|
157
157
|
}
|
|
158
158
|
|
|
@@ -185,7 +185,7 @@ export async function analyzeContinuation(candidate, {
|
|
|
185
185
|
basis: [{ role: 'goal', turnRef: 'provider-goal', snippet: context.providerGoal.objective.slice(0, 280) }],
|
|
186
186
|
};
|
|
187
187
|
if (previousAnalysis?.analysisVersion === CONTINUATION_ANALYSIS_VERSION && previousAnalysis?.contextDigest === context.contextDigest && STATES.includes(previousAnalysis.state)) {
|
|
188
|
-
return { ...
|
|
188
|
+
return { ...previousAnalysis, ...snapshot };
|
|
189
189
|
}
|
|
190
190
|
if (context.incomplete || !context.messages.length) return uncertain('The latest request or reply could not be read completely. Open the original conversation to review it, then retry.', 'context_incomplete');
|
|
191
191
|
let result;
|
|
@@ -21,6 +21,13 @@ export function textContent(content) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const GOAL_STATES = new Set(['active', 'paused', 'blocked', 'usageLimited', 'budgetLimited', 'complete']);
|
|
24
|
+
// Goal timestamps can advance with provider bookkeeping without changing the
|
|
25
|
+
// evidence relevant to triage. Keep timestamps for provenance, not cache keys.
|
|
26
|
+
export function providerGoalEvidence(goal) {
|
|
27
|
+
return goal && typeof goal === 'object' ? { objective: goal.objective ?? null, status: goal.status ?? null,
|
|
28
|
+
textTruncated: goal.textTruncated === true } : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
// Original provider metadata is evidence about the goal, never permission for
|
|
25
32
|
// this separate read-only classifier to execute work or change provider limits.
|
|
26
33
|
export function boundProviderGoal(goal) {
|
|
@@ -114,6 +114,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
114
114
|
return { provider: item.provider, session: item.session, cwd: item.cwd,
|
|
115
115
|
title: sessionTitle(item.title) || (item.provider === 'codex' ? codexTitles.get(item.session) || '' : ''), source: item.source,
|
|
116
116
|
...(typeof isRunning === 'boolean' ? { isRunning } : {}),
|
|
117
|
+
...(item.latestTurnId ? { latestTurnId: item.latestTurnId } : {}),
|
|
117
118
|
...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
|
|
118
119
|
...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
|
|
119
120
|
tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
|
|
@@ -11,7 +11,7 @@ export function safeProviderDiagnostic(value) {
|
|
|
11
11
|
if (SIGNALS.has(value.signal)) result.signal = value.signal;
|
|
12
12
|
if (Number.isInteger(value.outputBytes) && value.outputBytes >= 0 && value.outputBytes <= 2147483647) result.outputBytes = value.outputBytes;
|
|
13
13
|
if (value.retryAttempt === 0 || value.retryAttempt === 1) result.retryAttempt = value.retryAttempt;
|
|
14
|
-
if (typeof value.model === "string" && /^(?:gpt-6-(?:astra)|gpt-6\.1-sol|claude-(?:sonnet|opus|haiku)-[0-9]+(?:-[0-9]+){0,3})$/.test(value.model) && value.model.length <= 64) result.model = value.model;
|
|
14
|
+
if (typeof value.model === "string" && /^(?:gpt-6-(?:astra|sol)|gpt-6\.1-sol|claude-(?:sonnet|opus|haiku)-[0-9]+(?:-[0-9]+){0,3})$/.test(value.model) && value.model.length <= 64) result.model = value.model;
|
|
15
15
|
return result;
|
|
16
16
|
}
|
|
17
17
|
|
package/src/sqm/review-worker.js
CHANGED
|
@@ -20,7 +20,8 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 75_000;
|
|
|
20
20
|
const DEFAULT_EXECUTION_TIMEOUT_MS = 240_000;
|
|
21
21
|
// Keep analysis independent of interactive config. Availability varies by
|
|
22
22
|
// account and CLI; only an explicit model rejection may try the next model.
|
|
23
|
-
|
|
23
|
+
// Background SQM and conversation triage use Sol models to limit usage.
|
|
24
|
+
export const SQM_CODEX_MODELS = Object.freeze(["gpt-6.1-sol", "gpt-6-sol"]);
|
|
24
25
|
export const SQM_CODEX_MODEL = SQM_CODEX_MODELS[0];
|
|
25
26
|
|
|
26
27
|
export class SqmLocalReviewError extends Error {
|
|
@@ -113,6 +114,7 @@ export function createSqmLocalReviewWorker({
|
|
|
113
114
|
let activeJobId = null;
|
|
114
115
|
let pollPromise = null;
|
|
115
116
|
let closed = false;
|
|
117
|
+
let suspended = false;
|
|
116
118
|
let reviewController = null;
|
|
117
119
|
let issue = null, pausedEnvironment = null, pauseReason = null;
|
|
118
120
|
let issueContext = null;
|
|
@@ -167,7 +169,9 @@ export function createSqmLocalReviewWorker({
|
|
|
167
169
|
if (reviewOptions.signal?.aborted) throw providerFailure("analysis_cancelled");
|
|
168
170
|
setStage(true);
|
|
169
171
|
try {
|
|
170
|
-
|
|
172
|
+
const response = await submit(review);
|
|
173
|
+
if (reviewOptions.signal?.aborted) throw providerFailure("analysis_cancelled");
|
|
174
|
+
return { review, response };
|
|
171
175
|
} catch (error) {
|
|
172
176
|
if (attempt >= 1 || error.status !== 422 || error.code !== code || !error.validationFeedback) throw error;
|
|
173
177
|
nextRequest = { ...request, prompt: `${request.prompt}\n\nThe previous result failed validation. Return a corrected complete JSON object using only the supplied evidence. Fix these constraints:\n${error.validationFeedback}` };
|
|
@@ -246,7 +250,7 @@ export function createSqmLocalReviewWorker({
|
|
|
246
250
|
return {
|
|
247
251
|
snapshot() {
|
|
248
252
|
const pause = currentPause();
|
|
249
|
-
return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, paused: pausedEnvironment !== null, pause_reason: pauseReason, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
|
|
253
|
+
return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, suspended, paused: pausedEnvironment !== null, pause_reason: pauseReason, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
|
|
250
254
|
...(pause ? { provider_pause: { code: pause.code, status: pause.status, retry_at: new Date(pause.retryAt).toISOString(), message: pause.message } } : {}) };
|
|
251
255
|
},
|
|
252
256
|
desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item, canStop: item.id === activeJobId })) }; },
|
|
@@ -254,10 +258,10 @@ export function createSqmLocalReviewWorker({
|
|
|
254
258
|
const item = visibleHistory().find(item => item.key === key);
|
|
255
259
|
return item ? { key, instruction: `SQM local review · ${item.provider}`, result: item.state === "completed" ? "SQM review submitted." : "", error: item.error || "", links: item.webUrl ? [{ label: "Analysis details", url: item.webUrl }] : [] } : null;
|
|
256
260
|
},
|
|
257
|
-
retry() { if (closed || pollPromise) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
|
|
261
|
+
retry() { if (closed || suspended || pollPromise) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
|
|
258
262
|
resetHistory() { if (!pollPromise) { history.splice(0); issue = null; pausedEnvironment = null; pauseReason = null; } },
|
|
259
263
|
poll() {
|
|
260
|
-
if (closed || pollPromise) return pollPromise || Promise.resolve(null);
|
|
264
|
+
if (closed || suspended || pollPromise) return pollPromise || Promise.resolve(null);
|
|
261
265
|
const environmentKey = getEnvironmentKey();
|
|
262
266
|
if (pausedEnvironment !== null && pausedEnvironment === environmentKey) return Promise.resolve(null);
|
|
263
267
|
if (pausedEnvironment !== null) { pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; }
|
|
@@ -325,6 +329,21 @@ export function createSqmLocalReviewWorker({
|
|
|
325
329
|
reviewController?.abort();
|
|
326
330
|
await pollPromise;
|
|
327
331
|
},
|
|
332
|
+
async suspend() {
|
|
333
|
+
suspended = true;
|
|
334
|
+
reviewController?.abort();
|
|
335
|
+
onChange();
|
|
336
|
+
// Keep the original connection until provider cleanup and the final
|
|
337
|
+
// cancellation report finish, including a claim that returns after abort.
|
|
338
|
+
await pollPromise;
|
|
339
|
+
},
|
|
340
|
+
async resume({ shouldResume = () => true } = {}) {
|
|
341
|
+
await pollPromise;
|
|
342
|
+
if (!shouldResume()) return;
|
|
343
|
+
if (closed) throw new Error("SQM analysis is unavailable.");
|
|
344
|
+
suspended = false;
|
|
345
|
+
onChange();
|
|
346
|
+
},
|
|
328
347
|
async close() {
|
|
329
348
|
closed = true;
|
|
330
349
|
reviewController?.abort();
|