@awak-app/simy-cli 0.6.8 → 0.6.9
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 +40 -10
- package/src/sqm/review-worker.js +22 -4
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.9";
|
|
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;
|
|
@@ -54,7 +54,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
54
54
|
for (const [key, entry] of entries) {
|
|
55
55
|
if (entry.result?.state === 'analyzing') entry.result = { state: 'queued' };
|
|
56
56
|
}
|
|
57
|
-
let serial = Promise.resolve(), draining = null, closed = false, wakeDrain = null, wakeEpoch = 0;
|
|
57
|
+
let serial = Promise.resolve(), draining = null, closed = false, suspended = false, wakeDrain = null, wakeEpoch = 0;
|
|
58
58
|
const jobs = new Map();
|
|
59
59
|
const wake = () => { wakeEpoch++; wakeDrain?.(); };
|
|
60
60
|
const exclusive = fn => {
|
|
@@ -147,7 +147,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
147
147
|
}
|
|
148
148
|
await exclusive(async () => {
|
|
149
149
|
const current = candidates.get(selected.key), entry = entries.get(selected.key);
|
|
150
|
-
if (closed) return;
|
|
150
|
+
if (closed || selected.cancelled) return;
|
|
151
151
|
if (!current || !eligible(current) || current.isRunning === true || continuationAnalysisRevision(current) !== selected.revision || entry?.revision !== selected.revision || entry !== selected.entry) {
|
|
152
152
|
// A stale snapshot can help display/reuse a finished boundary but is
|
|
153
153
|
// never the current recommendation or authority to dispatch work.
|
|
@@ -170,13 +170,13 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
170
170
|
};
|
|
171
171
|
const startDrain = () => {
|
|
172
172
|
wake();
|
|
173
|
-
if (closed || draining) return;
|
|
173
|
+
if (closed || suspended || draining) return;
|
|
174
174
|
let drainFailed = false;
|
|
175
175
|
draining = (async () => {
|
|
176
176
|
while (!closed || jobs.size) {
|
|
177
177
|
const observedWake = wakeEpoch;
|
|
178
178
|
const selected = await exclusive(async () => {
|
|
179
|
-
if (closed) return [];
|
|
179
|
+
if (closed || suspended) return [];
|
|
180
180
|
const batch = [];
|
|
181
181
|
// Explicit retries precede historical backfill without interrupting owned jobs.
|
|
182
182
|
const ordered = [...entries].sort((a, b) => Number(Boolean(b[1].requestId)) - Number(Boolean(a[1].requestId)));
|
|
@@ -207,13 +207,13 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
207
207
|
}
|
|
208
208
|
})().catch(() => { drainFailed = true; }).finally(() => {
|
|
209
209
|
draining = null;
|
|
210
|
-
if (!closed && !drainFailed && [...entries].some(([key, entry]) => runnable(key, entry))) startDrain();
|
|
210
|
+
if (!closed && !suspended && !drainFailed && [...entries].some(([key, entry]) => runnable(key, entry))) startDrain();
|
|
211
211
|
});
|
|
212
212
|
};
|
|
213
213
|
return {
|
|
214
214
|
async updateProviderStatuses(statuses = {}) {
|
|
215
215
|
await exclusive(async () => {
|
|
216
|
-
if (closed) return;
|
|
216
|
+
if (closed || suspended) return;
|
|
217
217
|
for (const provider of ['codex', 'claude']) {
|
|
218
218
|
const status = statuses[provider];
|
|
219
219
|
if (!status) continue;
|
|
@@ -229,7 +229,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
229
229
|
},
|
|
230
230
|
async update(sessions) {
|
|
231
231
|
await exclusive(async () => {
|
|
232
|
-
if (closed) return;
|
|
232
|
+
if (closed || suspended) return;
|
|
233
233
|
candidates.clear();
|
|
234
234
|
for (const candidate of sessions) candidates.set(keyOf(candidate), candidate);
|
|
235
235
|
const sorted = [...candidates.values()].sort((a, b) => activityOf(b) - activityOf(a) || keyOf(a).localeCompare(keyOf(b)));
|
|
@@ -296,7 +296,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
296
296
|
if (requestId !== undefined && (typeof requestId !== 'string' || !requestId || requestId.length > 128)) throw new Error('Invalid analysis request identity.');
|
|
297
297
|
if (!candidate || !eligible(candidate)) throw new Error('This conversation cannot be analyzed.');
|
|
298
298
|
await exclusive(async () => {
|
|
299
|
-
if (closed) throw new Error('Session analysis is unavailable.');
|
|
299
|
+
if (closed || suspended) throw new Error('Session analysis is unavailable.');
|
|
300
300
|
candidates.set(keyOf(candidate), candidate);
|
|
301
301
|
let dirty = enqueue(candidate, true, true);
|
|
302
302
|
const entry = entries.get(keyOf(candidate));
|
|
@@ -307,7 +307,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
307
307
|
},
|
|
308
308
|
async invalidate(candidate) {
|
|
309
309
|
await exclusive(async () => {
|
|
310
|
-
if (closed) throw new Error('Session analysis is unavailable.');
|
|
310
|
+
if (closed || suspended) throw new Error('Session analysis is unavailable.');
|
|
311
311
|
entries.delete(keyOf(candidate));
|
|
312
312
|
candidates.set(keyOf(candidate), candidate);
|
|
313
313
|
enqueue(candidate); await persist(); changed();
|
|
@@ -316,7 +316,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
316
316
|
startDrain();
|
|
317
317
|
},
|
|
318
318
|
requireRecommendation(candidate) {
|
|
319
|
-
if (closed) throw new Error('Session analysis is unavailable.');
|
|
319
|
+
if (closed || suspended) throw new Error('Session analysis is unavailable.');
|
|
320
320
|
const analysis = this.project(candidate).analysis;
|
|
321
321
|
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
322
|
return analysis;
|
|
@@ -329,6 +329,36 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
329
329
|
} while (draining || jobs.size);
|
|
330
330
|
await serial;
|
|
331
331
|
},
|
|
332
|
+
async suspend() {
|
|
333
|
+
// Gate new work before waiting on cache writes. Provider cleanup retains
|
|
334
|
+
// its slot, and cancelled results never publish after an organization switch.
|
|
335
|
+
suspended = true;
|
|
336
|
+
for (const job of jobs.values()) { job.cancelled = true; job.controller.abort(); }
|
|
337
|
+
wake();
|
|
338
|
+
try {
|
|
339
|
+
await exclusive(async () => {
|
|
340
|
+
if (closed) return;
|
|
341
|
+
candidates.clear();
|
|
342
|
+
let dirty = false;
|
|
343
|
+
for (const entry of entries.values()) {
|
|
344
|
+
if (!['queued', 'analyzing'].includes(entry.result?.state)) continue;
|
|
345
|
+
entry.result = { state: 'failed', reasonCode: 'analysis_cancelled',
|
|
346
|
+
reason: 'The analysis was stopped for an organization switch. Request analysis again to retry.' };
|
|
347
|
+
entry.completedAt = now();
|
|
348
|
+
dirty = true;
|
|
349
|
+
}
|
|
350
|
+
if (dirty) { await persist(); changed(); }
|
|
351
|
+
});
|
|
352
|
+
} finally { await this.whenIdle(); }
|
|
353
|
+
},
|
|
354
|
+
async resume({ shouldResume = () => true } = {}) {
|
|
355
|
+
await this.whenIdle();
|
|
356
|
+
await exclusive(() => {
|
|
357
|
+
if (!shouldResume()) return;
|
|
358
|
+
if (closed) throw new Error('Session analysis is unavailable.');
|
|
359
|
+
suspended = false;
|
|
360
|
+
});
|
|
361
|
+
},
|
|
332
362
|
async close() { closed = true; for (const job of jobs.values()) job.controller.abort(); wake(); await this.whenIdle(); },
|
|
333
363
|
};
|
|
334
364
|
}
|
package/src/sqm/review-worker.js
CHANGED
|
@@ -113,6 +113,7 @@ export function createSqmLocalReviewWorker({
|
|
|
113
113
|
let activeJobId = null;
|
|
114
114
|
let pollPromise = null;
|
|
115
115
|
let closed = false;
|
|
116
|
+
let suspended = false;
|
|
116
117
|
let reviewController = null;
|
|
117
118
|
let issue = null, pausedEnvironment = null, pauseReason = null;
|
|
118
119
|
let issueContext = null;
|
|
@@ -167,7 +168,9 @@ export function createSqmLocalReviewWorker({
|
|
|
167
168
|
if (reviewOptions.signal?.aborted) throw providerFailure("analysis_cancelled");
|
|
168
169
|
setStage(true);
|
|
169
170
|
try {
|
|
170
|
-
|
|
171
|
+
const response = await submit(review);
|
|
172
|
+
if (reviewOptions.signal?.aborted) throw providerFailure("analysis_cancelled");
|
|
173
|
+
return { review, response };
|
|
171
174
|
} catch (error) {
|
|
172
175
|
if (attempt >= 1 || error.status !== 422 || error.code !== code || !error.validationFeedback) throw error;
|
|
173
176
|
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 +249,7 @@ export function createSqmLocalReviewWorker({
|
|
|
246
249
|
return {
|
|
247
250
|
snapshot() {
|
|
248
251
|
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,
|
|
252
|
+
return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, suspended, paused: pausedEnvironment !== null, pause_reason: pauseReason, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
|
|
250
253
|
...(pause ? { provider_pause: { code: pause.code, status: pause.status, retry_at: new Date(pause.retryAt).toISOString(), message: pause.message } } : {}) };
|
|
251
254
|
},
|
|
252
255
|
desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item, canStop: item.id === activeJobId })) }; },
|
|
@@ -254,10 +257,10 @@ export function createSqmLocalReviewWorker({
|
|
|
254
257
|
const item = visibleHistory().find(item => item.key === key);
|
|
255
258
|
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
259
|
},
|
|
257
|
-
retry() { if (closed || pollPromise) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
|
|
260
|
+
retry() { if (closed || suspended || pollPromise) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
|
|
258
261
|
resetHistory() { if (!pollPromise) { history.splice(0); issue = null; pausedEnvironment = null; pauseReason = null; } },
|
|
259
262
|
poll() {
|
|
260
|
-
if (closed || pollPromise) return pollPromise || Promise.resolve(null);
|
|
263
|
+
if (closed || suspended || pollPromise) return pollPromise || Promise.resolve(null);
|
|
261
264
|
const environmentKey = getEnvironmentKey();
|
|
262
265
|
if (pausedEnvironment !== null && pausedEnvironment === environmentKey) return Promise.resolve(null);
|
|
263
266
|
if (pausedEnvironment !== null) { pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; }
|
|
@@ -325,6 +328,21 @@ export function createSqmLocalReviewWorker({
|
|
|
325
328
|
reviewController?.abort();
|
|
326
329
|
await pollPromise;
|
|
327
330
|
},
|
|
331
|
+
async suspend() {
|
|
332
|
+
suspended = true;
|
|
333
|
+
reviewController?.abort();
|
|
334
|
+
onChange();
|
|
335
|
+
// Keep the original connection until provider cleanup and the final
|
|
336
|
+
// cancellation report finish, including a claim that returns after abort.
|
|
337
|
+
await pollPromise;
|
|
338
|
+
},
|
|
339
|
+
async resume({ shouldResume = () => true } = {}) {
|
|
340
|
+
await pollPromise;
|
|
341
|
+
if (!shouldResume()) return;
|
|
342
|
+
if (closed) throw new Error("SQM analysis is unavailable.");
|
|
343
|
+
suspended = false;
|
|
344
|
+
onChange();
|
|
345
|
+
},
|
|
328
346
|
async close() {
|
|
329
347
|
closed = true;
|
|
330
348
|
reviewController?.abort();
|