@awak-app/simy-cli 0.5.77 → 0.5.78
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 -2
- package/package.json +1 -1
- package/src/agent.js +35 -6
- package/src/cli-contract.js +1 -1
- package/src/desktop-access.js +1 -1
- package/src/desktop-force-quit.js +81 -0
- package/src/desktop-host.js +54 -19
- package/src/desktop-status.js +6 -0
- package/src/direct-executor.js +28 -2
- package/src/durable-local-task-worker.js +6 -2
- package/src/sqm/review-worker.js +29 -13
package/README.md
CHANGED
|
@@ -113,8 +113,11 @@ interrupting active tasks. The normal `simy` command remains available.
|
|
|
113
113
|
Signed patch/minor updates wait for idle and verify the new runtime before
|
|
114
114
|
handoff; failed startup restores the previous installed version. After a runtime
|
|
115
115
|
update, choose **Restart to update** to load the updated native interface. This
|
|
116
|
-
action
|
|
117
|
-
|
|
116
|
+
action asks to stop running tasks before restarting. **Stop task…** in a task
|
|
117
|
+
detail asks for confirmation and terminates its local Provider process, keeping
|
|
118
|
+
existing output and history. An explicit update can likewise stop active work
|
|
119
|
+
after confirmation; automatic updates still wait for idle. **⌘Q** exits SIMY and
|
|
120
|
+
its background services directly, including when work is running. The window reports App and runtime versions separately while they
|
|
118
121
|
differ, and normal app launches select the newer installed interface.
|
|
119
122
|
|
|
120
123
|
Older installers that predate native app relaunch support need a one-time
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
createConnectionStatusTracker,
|
|
76
76
|
desktopAuthStatus,
|
|
77
77
|
desktopTaskStatus,
|
|
78
|
+
canStopDesktopTask,
|
|
78
79
|
desktopTaskDetail,
|
|
79
80
|
} from "./desktop-status.js";
|
|
80
81
|
import { recoveryContractForPreflight } from "./orchestrator/recovery.js";
|
|
@@ -108,6 +109,7 @@ import {
|
|
|
108
109
|
directTaskSnapshot,
|
|
109
110
|
DirectTaskRegistry,
|
|
110
111
|
startDirectTask,
|
|
112
|
+
stopDirectTask,
|
|
111
113
|
} from "./direct-executor.js";
|
|
112
114
|
import {
|
|
113
115
|
classifyExecutionRequirement,
|
|
@@ -291,6 +293,7 @@ export async function startAgent({
|
|
|
291
293
|
let codingRunMonitorTimer = null;
|
|
292
294
|
let codingRunMonitorInFlight = null;
|
|
293
295
|
let shuttingDown = false;
|
|
296
|
+
let desktopWorkSuspended = false;
|
|
294
297
|
const notifyDesktopStatusChange = () => {
|
|
295
298
|
if (shuttingDown) return;
|
|
296
299
|
try { onDesktopStatusChange?.(); } catch { /* Desktop status polling remains available. */ }
|
|
@@ -312,10 +315,12 @@ export async function startAgent({
|
|
|
312
315
|
session?.account_id ?? null, session?.organization_id ?? null, session?.device_id ?? null, session?.token ?? null,
|
|
313
316
|
pendingOrganization?.id ?? null, isSessionValid(session, Date.now(), apiOrigin),
|
|
314
317
|
])).digest('hex');
|
|
315
|
-
const desktopWorkAllowed = () => !enforceDesktopAccess || desktopAccessReady(desktopAccess.snapshot());
|
|
318
|
+
const desktopWorkAllowed = () => !desktopWorkSuspended && (!enforceDesktopAccess || desktopAccessReady(desktopAccess.snapshot()));
|
|
316
319
|
const authorizeDesktopWork = async expectedScope => {
|
|
320
|
+
if (desktopWorkSuspended || shuttingDown) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
|
|
317
321
|
if (enforceDesktopAccess) {
|
|
318
322
|
requireDesktopAccess(await desktopAccess.refresh());
|
|
323
|
+
if (desktopWorkSuspended || shuttingDown) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
|
|
319
324
|
const scope = desktopScopeRevision();
|
|
320
325
|
if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
|
|
321
326
|
return scope;
|
|
@@ -2429,6 +2434,31 @@ export async function startAgent({
|
|
|
2429
2434
|
startHeartbeatTimer();
|
|
2430
2435
|
}
|
|
2431
2436
|
|
|
2437
|
+
const stopDesktopTask = async key => {
|
|
2438
|
+
if (typeof key !== "string" || key.length > 256) throw new Error("Select a running task.");
|
|
2439
|
+
if (key.startsWith("sqm:")) {
|
|
2440
|
+
if (!sqmLocalReviewWorker) throw new Error("This SQM task is no longer running.");
|
|
2441
|
+
return sqmLocalReviewWorker.stop(key);
|
|
2442
|
+
}
|
|
2443
|
+
const match = /^(0|1|2):(.+)$/.exec(key);
|
|
2444
|
+
if (!match) throw new Error("Select a running task.");
|
|
2445
|
+
const index = Number(match[1]);
|
|
2446
|
+
const task = [registry, directRegistry, localTaskRegistry][index].get(match[2]);
|
|
2447
|
+
if (!task) throw new Error("This task is no longer available.");
|
|
2448
|
+
if (!canStopDesktopTask(task, index)) return;
|
|
2449
|
+
await [stopLocalCodingRun, stopDirectTask, stopLocalTask][index](task);
|
|
2450
|
+
notifyDesktopStatusChange();
|
|
2451
|
+
};
|
|
2452
|
+
const stopDesktopTasks = async () => {
|
|
2453
|
+
desktopWorkSuspended = true;
|
|
2454
|
+
const stops = [sqmLocalReviewWorker?.close?.(), durableLocalTaskWorker?.close?.(),
|
|
2455
|
+
...[registry, directRegistry, localTaskRegistry].flatMap((source, index) => source.list()
|
|
2456
|
+
.filter(task => canStopDesktopTask(task, index))
|
|
2457
|
+
.map(task => [stopLocalCodingRun, stopDirectTask, stopLocalTask][index](task)))];
|
|
2458
|
+
const results = await Promise.allSettled(stops);
|
|
2459
|
+
const failure = results.find(result => result.status === "rejected");
|
|
2460
|
+
if (failure) throw failure.reason;
|
|
2461
|
+
};
|
|
2432
2462
|
let shutdownPromise = null;
|
|
2433
2463
|
const shutdown = () => {
|
|
2434
2464
|
if (shutdownPromise) return shutdownPromise;
|
|
@@ -2437,12 +2467,9 @@ export async function startAgent({
|
|
|
2437
2467
|
shutdownPromise = (async () => {
|
|
2438
2468
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
2439
2469
|
if (codingRunMonitorTimer) clearInterval(codingRunMonitorTimer);
|
|
2440
|
-
if (codingRunMonitorInFlight) await codingRunMonitorInFlight;
|
|
2441
2470
|
updateManager?.stop?.();
|
|
2442
|
-
await codexWorkPatternSync?.close?.()
|
|
2443
|
-
|
|
2444
|
-
await sqmLocalReviewWorker?.close?.();
|
|
2445
|
-
await durableLocalTaskWorker?.close?.();
|
|
2471
|
+
await Promise.all([stopDesktopTasks(), codexWorkPatternSync?.close?.(),
|
|
2472
|
+
codexWorkPatternSyncInFlight, codingRunMonitorInFlight]);
|
|
2446
2473
|
try {
|
|
2447
2474
|
await (dependencies.removeConnectionDiscovery || removeConnectionDiscovery)(
|
|
2448
2475
|
connectionDiscovery,
|
|
@@ -2577,6 +2604,8 @@ export async function startAgent({
|
|
|
2577
2604
|
return { code: ticket.code, expiresAt: ticket.expiresAt, userId: ticket.userId, orgSlug: ticket.orgSlug };
|
|
2578
2605
|
},
|
|
2579
2606
|
getDesktopStatus,
|
|
2607
|
+
stopDesktopTask,
|
|
2608
|
+
stopDesktopTasks,
|
|
2580
2609
|
getDesktopTask: async (key) => {
|
|
2581
2610
|
if (key?.startsWith("sqm:") || key?.startsWith("sqm-check:")) {
|
|
2582
2611
|
const scope = currentHistoryScope();
|
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.5.
|
|
16
|
+
export const CLI_VERSION = "0.5.78";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
package/src/desktop-access.js
CHANGED
|
@@ -8,7 +8,7 @@ export function validDesktopAccessPath(path, slug) {
|
|
|
8
8
|
}
|
|
9
9
|
export const desktopAccessReady = value => value?.state === 'ready';
|
|
10
10
|
const recoveryActions = new Set(['refresh', 'reconnect', 'login', 'authorization_open', 'organization_select', 'organization_cancel', 'organizations_refresh',
|
|
11
|
-
'check_update', 'update', 'quit', 'delivery_stop', 'delivery_pause', 'delivery_reject',
|
|
11
|
+
'check_update', 'update', 'quit', 'task_stop', 'delivery_stop', 'delivery_pause', 'delivery_reject',
|
|
12
12
|
'desktop_access_refresh', 'desktop_access_settings', 'desktop_access_members']);
|
|
13
13
|
export function desktopActionAllowed(access, command) {
|
|
14
14
|
return desktopAccessReady(access) || recoveryActions.has(command?.action) ||
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { windowsUserProcesses } from './windows-processes.js';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { parseLegacyAgentArguments } from './desktop-process-lock.js';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
// Fallback for an unresponsive host. The requesting native app must still own
|
|
11
|
+
// the exact host; snapshot/start-time checks exclude PID reuse and unrelated apps.
|
|
12
|
+
export async function stopOwnedDesktopHost(pid, { parentPid = process.ppid,
|
|
13
|
+
snapshot = processSnapshot, kill = process.kill.bind(process), includeRoot = true } = {}) {
|
|
14
|
+
if (!Number.isSafeInteger(pid) || pid <= 1) throw new Error('Invalid host');
|
|
15
|
+
const rows = await snapshot();
|
|
16
|
+
const host = rows.find(row => row.pid === pid);
|
|
17
|
+
if (!host) return;
|
|
18
|
+
if (host.eligible === false || host.ppid !== parentPid || !await isHostCommand(host.command)) throw new Error('Host ownership changed');
|
|
19
|
+
await stopProcessTree(host, rows, { snapshot, kill, includeRoot });
|
|
20
|
+
}
|
|
21
|
+
async function stopProcessTree(host, rows, { snapshot, kill, includeRoot = true }) {
|
|
22
|
+
const pid = host.pid;
|
|
23
|
+
const owned = new Set([pid]);
|
|
24
|
+
const depths = new Map([[pid, 0]]);
|
|
25
|
+
for (let changed = true; changed;) {
|
|
26
|
+
changed = false;
|
|
27
|
+
for (const row of rows) if (owned.has(row.ppid) && !owned.has(row.pid)) { owned.add(row.pid); depths.set(row.pid, depths.get(row.ppid) + 1); changed = true; }
|
|
28
|
+
}
|
|
29
|
+
// Descendants first, while the root still anchors ownership.
|
|
30
|
+
for (const row of rows.filter(row => owned.has(row.pid) && (includeRoot || row.pid !== pid)).sort((a,b) => depths.get(b.pid) - depths.get(a.pid))) {
|
|
31
|
+
const live = (await snapshot()).find(item => item.pid === row.pid);
|
|
32
|
+
if (!live || live.startedAt !== row.startedAt || live.ppid !== row.ppid || live.command !== row.command) continue;
|
|
33
|
+
try { kill(row.pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function isHostCommand(command) {
|
|
37
|
+
const match = String(command).replaceAll("\\", "/").match(/\s"?((?:\/|[A-Za-z]:\/).*?\/src\/desktop-host\.js)"?(?:\s|$)/);
|
|
38
|
+
if (!match) return false;
|
|
39
|
+
try { return JSON.parse(await readFile(join(dirname(dirname(match[1])), 'package.json'), 'utf8')).name === '@awak-app/simy-cli'; }
|
|
40
|
+
catch { return false; }
|
|
41
|
+
}
|
|
42
|
+
async function processSnapshot() {
|
|
43
|
+
if (process.platform === 'win32') {
|
|
44
|
+
const userPids = new Set((await windowsUserProcesses()).map(row => row.pid));
|
|
45
|
+
const { stdout } = await run(join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe'),
|
|
46
|
+
['-NoProfile','-NonInteractive','-Command', 'Get-CimInstance Win32_Process | Where-Object { $_.CreationDate } | Select-Object @{n="pid";e={$_.ProcessId}},@{n="ppid";e={$_.ParentProcessId}},@{n="startedAt";e={$_.CreationDate.ToUniversalTime().ToString("o")}},@{n="command";e={$_.CommandLine}} | ConvertTo-Json -Compress'],
|
|
47
|
+
{timeout:15000,windowsHide:true,maxBuffer:4*1024*1024});
|
|
48
|
+
const parsed = JSON.parse(stdout || '[]');
|
|
49
|
+
return (Array.isArray(parsed) ? parsed : [parsed]).map(row => ({...row, eligible:userPids.has(row.pid)}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { stdout } = await run('/bin/ps', ['-ww', '-axo', 'pid=,ppid=,uid=,lstart=,command='], { timeout: 3000, maxBuffer: 4*1024*1024 });
|
|
53
|
+
return stdout.split('\n').flatMap(line => {
|
|
54
|
+
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+((?:\S+\s+){4}\S+)\s+(.+)$/);
|
|
55
|
+
return m && Number(m[3]) === process.getuid() ? [{ pid:Number(m[1]), ppid:Number(m[2]), startedAt:m[4], command:m[5] }] : [];
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function stopSimyServices({ snapshot = processSnapshot, kill = process.kill.bind(process), currentPid = process.pid, home = process.env.SIMY_HOME } = {}) {
|
|
60
|
+
const rows = await snapshot();
|
|
61
|
+
// Custom SIMY_HOME is an isolated installation/test scope, never global quit.
|
|
62
|
+
let scopedOwner = null;
|
|
63
|
+
if (home && home !== join(homedir(), '.simy')) {
|
|
64
|
+
try { scopedOwner = JSON.parse(await readFile(join(home, 'desktop-app.lock/owner.json'), 'utf8')); }
|
|
65
|
+
catch { return; }
|
|
66
|
+
}
|
|
67
|
+
for (const row of rows) {
|
|
68
|
+
if (row.pid === currentPid || row.eligible === false || scopedOwner && (row.pid !== scopedOwner.pid || row.startedAt.replace(/\s+/g, " ") !== String(scopedOwner.startedAt).replace(/\s+/g, " "))) continue;
|
|
69
|
+
const host = await isHostCommand(row.command);
|
|
70
|
+
const legacy = parseLegacyAgentArguments(row.command);
|
|
71
|
+
let daemon = false;
|
|
72
|
+
if (legacy) {
|
|
73
|
+
try { daemon = JSON.parse(await readFile(join(dirname(dirname(legacy.entryPath)), 'package.json'), 'utf8')).name === '@awak-app/simy-cli'; } catch { }
|
|
74
|
+
}
|
|
75
|
+
if (host || daemon) await stopProcessTree(row, rows, { snapshot, kill });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (process.argv[1]?.replaceAll('\\', '/')?.endsWith('/desktop-force-quit.js')) {
|
|
79
|
+
const stop = process.argv[2] === '--all-services' ? stopSimyServices() : stopOwnedDesktopHost(Number(process.argv[2]));
|
|
80
|
+
stop.then(() => process.exit(0)).catch(() => process.exit(1));
|
|
81
|
+
}
|
package/src/desktop-host.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stopOwnedDesktopHost } from "./desktop-force-quit.js";
|
|
1
2
|
import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
|
|
2
3
|
import { createActionDeadlineService } from './session-supervision/action-deadline.js';
|
|
3
4
|
import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
|
|
@@ -55,11 +56,11 @@ function desktopActionError(action, error) {
|
|
|
55
56
|
if (["sqm_set_enabled", "sqm_set_engine_fallback", "sqm_retry_environment"].includes(action)) {
|
|
56
57
|
return "Could not save SQM settings. Check your SIMY connection and try again.";
|
|
57
58
|
}
|
|
58
|
-
return "Action could not finish. Refresh the status and try again
|
|
59
|
+
return "Action could not finish. Refresh the status and try again.";
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
|
|
62
|
-
dependencies = {}, exit = (code) =>
|
|
63
|
+
dependencies = {}, exit = (code) => process.exit(code) } = {}) {
|
|
63
64
|
const emit = (event) => output.write(`${JSON.stringify(event)}\n`);
|
|
64
65
|
// A heartbeat remains a complete native frame. Only its observation time is
|
|
65
66
|
// volatile; reuse the encoded body after comparing freshly authorized data.
|
|
@@ -103,9 +104,12 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
103
104
|
return currentAccess;
|
|
104
105
|
};
|
|
105
106
|
const accessSnapshot = async () => setAccessSnapshot((await agent?.getDesktopStatus?.())?.access);
|
|
107
|
+
let stoppingForUpdate = false;
|
|
106
108
|
const authorizeWork = async expectedScope => {
|
|
109
|
+
if (closed || stoppingForUpdate) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
|
|
107
110
|
if (agent?.requireDesktopAccess) await agent.requireDesktopAccess();
|
|
108
111
|
const access = await accessSnapshot();
|
|
112
|
+
if (closed || stoppingForUpdate) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
|
|
109
113
|
requireDesktopAccess(access);
|
|
110
114
|
const scope = accessScope(access);
|
|
111
115
|
if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
|
|
@@ -259,17 +263,16 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
259
263
|
const stopAgent = async () => {
|
|
260
264
|
clearInterval(timer);
|
|
261
265
|
discoveryStopped = true;
|
|
262
|
-
await stopDiscovery({ drain: true });
|
|
263
266
|
encodedStatus = null; lastStatus = null;
|
|
264
267
|
if (scheduledPublish) { clearTimeout(scheduledPublish.timer); scheduledPublish.resolve(); scheduledPublish = null; }
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
await
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (
|
|
268
|
+
// Start every cancellation before draining discovery/network work. A hung
|
|
269
|
+
// reader must never postpone cancellation of an owned Provider process.
|
|
270
|
+
const results = await Promise.allSettled([stopDiscovery({ drain: true }), publishing,
|
|
271
|
+
actionAssociations.close(), automaticContinuation?.close(), analysisManager?.close(),
|
|
272
|
+
managedSupervisor.close(), recordReader.close(), stopWorkflows(),
|
|
273
|
+
agent ? createAgentShutdown(agent)() : undefined]);
|
|
274
|
+
const failure = results.find(result => result.status === "rejected");
|
|
275
|
+
if (failure) throw failure.reason;
|
|
273
276
|
};
|
|
274
277
|
const close = () => shuttingDown ||= (async () => {
|
|
275
278
|
closed = true;
|
|
@@ -411,6 +414,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
411
414
|
// Saving/revoking opt-in must not queue behind provider or remote work.
|
|
412
415
|
let preferenceQueue = Promise.resolve();
|
|
413
416
|
let activeQueuedAction = null;
|
|
417
|
+
let controlQueue = Promise.resolve();
|
|
414
418
|
commands = createInterface({ input, crlfDelay: Infinity });
|
|
415
419
|
commands.on("line", (line) => {
|
|
416
420
|
if (closed) return;
|
|
@@ -425,6 +429,29 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
425
429
|
if (closed) return;
|
|
426
430
|
try {
|
|
427
431
|
const action = command.action;
|
|
432
|
+
if (action === "quit") {
|
|
433
|
+
if (command.force !== true) {
|
|
434
|
+
const status = await agent.getDesktopStatus();
|
|
435
|
+
if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0) throw new Error("Tasks are still running. Confirm before quitting.");
|
|
436
|
+
}
|
|
437
|
+
// Cancel first, then acknowledge. The native app waits for the host
|
|
438
|
+
// exit; this deadline also covers a stuck remote flush/discovery.
|
|
439
|
+
const deadline = setTimeout(async () => {
|
|
440
|
+
emit({ type: "result", id: command.id, ok: true });
|
|
441
|
+
try { await (dependencies.stopOwnedHost || stopOwnedDesktopHost)(process.pid, { parentPid: process.ppid, includeRoot: false }); }
|
|
442
|
+
catch { /* Native ownership-checked fallback remains available. */ }
|
|
443
|
+
finally { exit(0); }
|
|
444
|
+
}, dependencies.quitTimeoutMs ?? 8000);
|
|
445
|
+
try { await close(); emit({ type: "result", id: command.id, ok: true }); }
|
|
446
|
+
finally { clearTimeout(deadline); exit(0); }
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (action === "task_stop") {
|
|
450
|
+
if (command.force !== true) throw new Error("Confirm before stopping this task.");
|
|
451
|
+
await agent.stopDesktopTask(command.taskKey);
|
|
452
|
+
emit({ type: "result", id: command.id, ok: true });
|
|
453
|
+
void publish(); return;
|
|
454
|
+
}
|
|
428
455
|
const access = await accessSnapshot();
|
|
429
456
|
const commandScope = accessScope(access);
|
|
430
457
|
if (!desktopActionAllowed(access, command)) throw Object.assign(new Error('Desktop access is required.'), { code: 'desktop_access_required' });
|
|
@@ -487,8 +514,17 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
487
514
|
}
|
|
488
515
|
} else if (action === "check_update") await updater.checkNow();
|
|
489
516
|
else if (action === "update") {
|
|
517
|
+
if (command.force === true) {
|
|
518
|
+
stoppingForUpdate = true; discoveryStopped = true;
|
|
519
|
+
void stopDiscovery();
|
|
520
|
+
// Abort dispatch immediately; existing Delivery Loop records retain
|
|
521
|
+
// their cancelled checkpoint so another host cannot silently resume.
|
|
522
|
+
await Promise.all([agent.stopDesktopTasks(), managedSupervisor.close(),
|
|
523
|
+
automaticContinuation?.close(), analysisManager?.close()]);
|
|
524
|
+
}
|
|
490
525
|
if (updater.snapshot().state === "waiting_for_idle") await updater.maybeApply();
|
|
491
526
|
else await updater.performAction();
|
|
527
|
+
if (command.force === true && ["failed", "error"].includes(updater.snapshot().state)) throw new Error("Update failed after stopping tasks.");
|
|
492
528
|
} else if (action === "codex_login" || action === "claude_login") {
|
|
493
529
|
const provider = action === "claude_login" ? "claude" : "codex";
|
|
494
530
|
const login = dependencies[`${provider}Login`];
|
|
@@ -588,24 +624,23 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
588
624
|
session: command.session, taskId: command.taskId, questionId: command.questionId, answer: command.answer });
|
|
589
625
|
await refreshSessions(true);
|
|
590
626
|
}
|
|
591
|
-
else
|
|
592
|
-
const status = await agent.getDesktopStatus();
|
|
593
|
-
if ((status.tasks.activeCount + (status.sqmTasks?.activeCount || 0)) > 0 && command.force !== true) throw new Error("Tasks are still running. Confirm before quitting.");
|
|
594
|
-
emit({ type: "result", id: command.id, ok: true });
|
|
595
|
-
await close(); exit(0); return;
|
|
596
|
-
} else throw new Error("Unsupported desktop action.");
|
|
627
|
+
else throw new Error("Unsupported desktop action.");
|
|
597
628
|
emit({ type: "result", id: command.id, ok: true });
|
|
598
629
|
await publish();
|
|
599
630
|
} catch (error) {
|
|
600
|
-
emit({ type: "result", id: command.id, ok: false, message: desktopActionError(command.action, error) });
|
|
631
|
+
emit({ type: "result", id: command.id, ok: false, message: command.action === "update" && stoppingForUpdate ? "Could not finish the update after stopping tasks. Reopen SIMY and retry the update." : desktopActionError(command.action, error) });
|
|
601
632
|
}
|
|
602
633
|
};
|
|
603
|
-
if (
|
|
634
|
+
if (command.action === "quit") {
|
|
635
|
+
void execute();
|
|
636
|
+
} else if (["login", "organization_select", "organization_cancel", "authorization_open"].includes(command.action)) {
|
|
604
637
|
authorizationQueue = authorizationQueue.then(execute).catch(() => {});
|
|
605
638
|
} else if (command.action === "my_ai_session") {
|
|
606
639
|
myAiSessionQueue = myAiSessionQueue.then(execute).catch(() => {});
|
|
607
640
|
} else if (command.action === "session_set_auto_continue") {
|
|
608
641
|
preferenceQueue = preferenceQueue.then(execute).catch(() => {});
|
|
642
|
+
} else if (["task_stop", "delivery_stop"].includes(command.action) || command.action === "update" && command.force === true) {
|
|
643
|
+
controlQueue = controlQueue.then(execute).catch(() => {});
|
|
609
644
|
} else {
|
|
610
645
|
queue = queue.then(async () => {
|
|
611
646
|
activeQueuedAction = command.action;
|
package/src/desktop-status.js
CHANGED
|
@@ -217,6 +217,11 @@ export function isUnauthorizedError(error) {
|
|
|
217
217
|
);
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
export function canStopDesktopTask(task, registryIndex) {
|
|
221
|
+
const state = task?.status || task?.snapshot?.state || task?.state || task?.control_state || task?.controlState || "unknown";
|
|
222
|
+
return registryIndex === 0 ? !INACTIVE_STATES.has(state) : ["queued", "running", "stopping"].includes(state);
|
|
223
|
+
}
|
|
224
|
+
|
|
220
225
|
function taskStatusItem(task, registryIndex) {
|
|
221
226
|
const state = bounded(
|
|
222
227
|
String(
|
|
@@ -237,6 +242,7 @@ function taskStatusItem(task, registryIndex) {
|
|
|
237
242
|
id: bounded(String(task?.id || `unknown-${registryIndex}`), MAX_LABEL_LENGTH),
|
|
238
243
|
label: bounded(String(label), MAX_LABEL_LENGTH),
|
|
239
244
|
state,
|
|
245
|
+
canStop: canStopDesktopTask(task, registryIndex),
|
|
240
246
|
updatedAt:
|
|
241
247
|
Date.parse(task?.updated_at || task?.snapshot?.updated_at || task?.startedAt || 0) || 0,
|
|
242
248
|
};
|
package/src/direct-executor.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { signalExecutorProcess } from "./runner.js";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
|
|
3
4
|
import { resolveDesktopExecutorCommand } from "./desktop-executor.js";
|
|
@@ -68,6 +69,7 @@ export async function startDirectTask(
|
|
|
68
69
|
permissionMode: task.permission_mode,
|
|
69
70
|
environment,
|
|
70
71
|
});
|
|
72
|
+
if (task.stop_requested || task.status !== "queued") return task;
|
|
71
73
|
task.status = "running";
|
|
72
74
|
task.invocation_count = 1;
|
|
73
75
|
task.updated_at = new Date().toISOString();
|
|
@@ -120,8 +122,8 @@ export async function startDirectTask(
|
|
|
120
122
|
error ||= terminalError;
|
|
121
123
|
task.child = null;
|
|
122
124
|
task.token_usage = { records: tokenRecords };
|
|
123
|
-
task.status = error || exitCode !== 0 ? "failed" : "succeeded";
|
|
124
|
-
task.error = error || (exitCode !== 0 ? `Provider exited with code ${exitCode}.` : null);
|
|
125
|
+
task.status = task.stop_requested ? "stopped" : error || exitCode !== 0 ? "failed" : "succeeded";
|
|
126
|
+
task.error = task.stop_requested ? null : error || (exitCode !== 0 ? `Provider exited with code ${exitCode}.` : null);
|
|
125
127
|
task.result = task.output.at(-1) || rawStdout.join("").trim() || null;
|
|
126
128
|
task.updated_at = new Date().toISOString();
|
|
127
129
|
resolve(task);
|
|
@@ -149,3 +151,27 @@ export function directTaskSnapshot(task) {
|
|
|
149
151
|
updated_at: task.updated_at,
|
|
150
152
|
};
|
|
151
153
|
}
|
|
154
|
+
|
|
155
|
+
// Only the child captured by this task may be signalled, including its owned tools.
|
|
156
|
+
export function stopDirectTask(task) {
|
|
157
|
+
if (!task) return Promise.resolve(task);
|
|
158
|
+
return task.stopPromise ||= stopOwnedDirectTask(task);
|
|
159
|
+
}
|
|
160
|
+
async function stopOwnedDirectTask(task) {
|
|
161
|
+
if (!task || !["queued", "running", "stopping"].includes(task.status)) return task;
|
|
162
|
+
task.stop_requested = true;
|
|
163
|
+
task.status = "stopping";
|
|
164
|
+
const child = task.child;
|
|
165
|
+
if (!child) { task.status = "stopped"; task.updated_at = new Date().toISOString(); return task; }
|
|
166
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGKILL"]) {
|
|
167
|
+
if (task.child !== child) return task;
|
|
168
|
+
await new Promise(resolve => {
|
|
169
|
+
const done = () => { clearTimeout(timer); child.off("close", done); resolve(); };
|
|
170
|
+
const timer = setTimeout(done, signal === "SIGKILL" ? 750 : 1500);
|
|
171
|
+
child.once("close", done);
|
|
172
|
+
signalExecutorProcess(child, signal);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (task.child === child) throw new Error("The local task did not stop. Try quitting SIMY.");
|
|
176
|
+
return task;
|
|
177
|
+
}
|
|
@@ -1415,6 +1415,7 @@ export function createDurableLocalTaskWorker({
|
|
|
1415
1415
|
workerId,
|
|
1416
1416
|
leaseSeconds,
|
|
1417
1417
|
});
|
|
1418
|
+
if (closed) { releaseWork(); return false; }
|
|
1418
1419
|
const parentClaim = normalizeDurableLocalTaskExecutionClaimV2(
|
|
1419
1420
|
parentEnvelope,
|
|
1420
1421
|
{ workerId, leaseSeconds },
|
|
@@ -1470,6 +1471,7 @@ export function createDurableLocalTaskWorker({
|
|
|
1470
1471
|
leaseSeconds,
|
|
1471
1472
|
turnId: requestedTurnId,
|
|
1472
1473
|
});
|
|
1474
|
+
if (closed) { releaseWork(); return false; }
|
|
1473
1475
|
if (!claim) {
|
|
1474
1476
|
if (requestedTurnId) {
|
|
1475
1477
|
throw workerError(
|
|
@@ -1539,6 +1541,7 @@ export function createDurableLocalTaskWorker({
|
|
|
1539
1541
|
}
|
|
1540
1542
|
lastStepDisposition = null;
|
|
1541
1543
|
}
|
|
1544
|
+
if (closed) { releaseWork(); return false; }
|
|
1542
1545
|
active = { claim, task: null };
|
|
1543
1546
|
const executionPromise = executeClaim(claim, releaseWork)
|
|
1544
1547
|
.catch((error) => {
|
|
@@ -1580,9 +1583,10 @@ export function createDurableLocalTaskWorker({
|
|
|
1580
1583
|
},
|
|
1581
1584
|
async close() {
|
|
1582
1585
|
closed = true;
|
|
1586
|
+
// Cancel owned Provider work before waiting for any pending network operation.
|
|
1587
|
+
await Promise.all([active?.subtaskPool?.close?.().catch(() => undefined),
|
|
1588
|
+
active?.task ? stopTask(active.task).catch(() => undefined) : undefined]);
|
|
1583
1589
|
await pollPromise;
|
|
1584
|
-
await active?.subtaskPool?.close?.().catch(() => undefined);
|
|
1585
|
-
if (active?.task) await stopTask(active.task).catch(() => undefined);
|
|
1586
1590
|
await activeExecutionPromise;
|
|
1587
1591
|
},
|
|
1588
1592
|
};
|
package/src/sqm/review-worker.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { signalExecutorProcess } from "../runner.js";
|
|
1
2
|
import spawn from "cross-spawn";
|
|
2
3
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
@@ -44,7 +45,7 @@ export function createSqmLocalReviewApi({
|
|
|
44
45
|
if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl is required");
|
|
45
46
|
|
|
46
47
|
return {
|
|
47
|
-
async request(body) {
|
|
48
|
+
async request(body, { signal, timeoutMs = requestTimeoutMs } = {}) {
|
|
48
49
|
const connection = getConnection();
|
|
49
50
|
if (!connection?.apiOrigin || !connection?.token) {
|
|
50
51
|
throw new SqmLocalReviewError("The SIMY CLI session is not connected.", {
|
|
@@ -65,10 +66,11 @@ export function createSqmLocalReviewApi({
|
|
|
65
66
|
{ "Content-Type": "application/json" },
|
|
66
67
|
),
|
|
67
68
|
body: JSON.stringify(body),
|
|
68
|
-
signal: AbortSignal.timeout(
|
|
69
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
|
|
69
70
|
},
|
|
70
71
|
);
|
|
71
72
|
} catch (error) {
|
|
73
|
+
if (signal?.aborted) throw providerFailure("analysis_cancelled");
|
|
72
74
|
throw new SqmLocalReviewError(`SIMY Web could not be reached: ${errorMessage(error)}`, {
|
|
73
75
|
code: "sqm_local_review_gateway_unavailable",
|
|
74
76
|
});
|
|
@@ -111,6 +113,7 @@ export function createSqmLocalReviewWorker({
|
|
|
111
113
|
let activeJobId = null;
|
|
112
114
|
let pollPromise = null;
|
|
113
115
|
let closed = false;
|
|
116
|
+
let reviewController = null;
|
|
114
117
|
let issue = null, pausedEnvironment = null, pauseReason = null;
|
|
115
118
|
let issueContext = null;
|
|
116
119
|
const failureDetails = new WeakMap();
|
|
@@ -159,7 +162,9 @@ export function createSqmLocalReviewWorker({
|
|
|
159
162
|
let nextRequest = request;
|
|
160
163
|
for (let attempt = 0; ; attempt += 1) {
|
|
161
164
|
setStage(false);
|
|
165
|
+
if (reviewOptions.signal?.aborted) throw providerFailure("analysis_cancelled");
|
|
162
166
|
const review = await runReview(nextRequest, provider, reviewOptions);
|
|
167
|
+
if (reviewOptions.signal?.aborted) throw providerFailure("analysis_cancelled");
|
|
163
168
|
setStage(true);
|
|
164
169
|
try {
|
|
165
170
|
return { review, response: await submit(review) };
|
|
@@ -187,12 +192,14 @@ export function createSqmLocalReviewWorker({
|
|
|
187
192
|
heartbeatTimer = setInterval(heartbeat, heartbeatIntervalMs);
|
|
188
193
|
heartbeatTimer.unref?.();
|
|
189
194
|
let stage = "analysis";
|
|
190
|
-
const
|
|
191
|
-
|
|
195
|
+
const signal = reviewController?.signal;
|
|
196
|
+
const request = body => api.request(body, { signal });
|
|
197
|
+
const reviewOptions = { signal, providerReportingSupported: claim.capabilities?.reported_executor_provider === true,
|
|
198
|
+
beforeLeaseDispatch: () => request({ action: "heartbeat", ...leaseBody(claim, provider) }) };
|
|
192
199
|
const providerReport = (review, selectedProvider = provider) => claim.capabilities?.reported_executor_provider === true
|
|
193
200
|
? { reported_executor_provider: review.provider || selectedProvider } : {};
|
|
194
201
|
try {
|
|
195
|
-
const { review: analysis, response: advanced } = await validatedReview(claim.request, provider, "invalid_sqm_analysis_output", analysis =>
|
|
202
|
+
const { review: analysis, response: advanced } = await validatedReview(claim.request, provider, "invalid_sqm_analysis_output", analysis => request({
|
|
196
203
|
action: "advance",
|
|
197
204
|
...leaseBody(claim, provider),
|
|
198
205
|
analysis_output: analysis.output,
|
|
@@ -204,7 +211,7 @@ export function createSqmLocalReviewWorker({
|
|
|
204
211
|
if (item) item.provider = analysisProvider;
|
|
205
212
|
if (advanced.completed) { await updateTask(claim.job_id, "completed", historyScope); return advanced; }
|
|
206
213
|
stage = "adjudication";
|
|
207
|
-
const { review: adjudication, response: completed } = await validatedReview(advanced.request, analysisProvider, "invalid_sqm_adjudication_output", adjudication =>
|
|
214
|
+
const { review: adjudication, response: completed } = await validatedReview(advanced.request, analysisProvider, "invalid_sqm_adjudication_output", adjudication => request({
|
|
208
215
|
action: "complete",
|
|
209
216
|
...leaseBody(claim, provider),
|
|
210
217
|
analysis_output: analysis.output,
|
|
@@ -223,11 +230,11 @@ export function createSqmLocalReviewWorker({
|
|
|
223
230
|
if (!failureReported) {
|
|
224
231
|
try {
|
|
225
232
|
const report = await api.request({ action: "fail", ...leaseBody(claim, provider),
|
|
226
|
-
error_stage: failure.stage, error_code: failure.code, ...(failure.status ? { error_status: failure.status } : {}) });
|
|
233
|
+
error_stage: failure.stage, error_code: failure.code, ...(failure.status ? { error_status: failure.status } : {}) }, failure.code === "analysis_cancelled" ? { timeoutMs: 3000 } : undefined);
|
|
227
234
|
failureReported = report?.recorded === true;
|
|
228
235
|
} catch { /* Keep the original failure even when reporting fails. */ }
|
|
229
236
|
}
|
|
230
|
-
await updateTask(claim.job_id, "failed", historyScope, failure, failureReported);
|
|
237
|
+
await updateTask(claim.job_id, failure.code === "analysis_cancelled" ? "cancelled" : "failed", historyScope, failure, failureReported);
|
|
231
238
|
throw error;
|
|
232
239
|
} finally {
|
|
233
240
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
@@ -242,7 +249,7 @@ export function createSqmLocalReviewWorker({
|
|
|
242
249
|
return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, paused: pausedEnvironment !== null, pause_reason: pauseReason, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
|
|
243
250
|
...(pause ? { provider_pause: { code: pause.code, status: pause.status, retry_at: new Date(pause.retryAt).toISOString(), message: pause.message } } : {}) };
|
|
244
251
|
},
|
|
245
|
-
desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item })) }; },
|
|
252
|
+
desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item, canStop: item.id === activeJobId })) }; },
|
|
246
253
|
desktopTaskDetail(key) {
|
|
247
254
|
const item = visibleHistory().find(item => item.key === key);
|
|
248
255
|
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;
|
|
@@ -266,13 +273,14 @@ export function createSqmLocalReviewWorker({
|
|
|
266
273
|
const releaseWork = beginWork?.();
|
|
267
274
|
if (!releaseWork) return Promise.resolve(null);
|
|
268
275
|
const workspaceUrl = getWorkspaceUrl();
|
|
276
|
+
reviewController = new AbortController();
|
|
269
277
|
pollPromise = (async () => {
|
|
270
278
|
const claim = await api.request({
|
|
271
279
|
action: "claim",
|
|
272
280
|
executor_provider: provider,
|
|
273
281
|
worker_id: workerId,
|
|
274
282
|
lease_seconds: leaseSeconds,
|
|
275
|
-
});
|
|
283
|
+
}, { signal: reviewController.signal });
|
|
276
284
|
if (issue) { issue = null; onChange(); }
|
|
277
285
|
if (!claim?.claimed) {
|
|
278
286
|
// A successful empty claim proves SIMY access recovered, but it says
|
|
@@ -285,6 +293,7 @@ export function createSqmLocalReviewWorker({
|
|
|
285
293
|
return result;
|
|
286
294
|
})()
|
|
287
295
|
.catch(async (error) => {
|
|
296
|
+
if (error.code === "analysis_cancelled") return null;
|
|
288
297
|
pauseForFailure(error, provider, historyScope, recoveryKey);
|
|
289
298
|
if (error.jobId && /^[a-f0-9-]{36}$/i.test(error.jobId) && !history.some(item => item.id === error.jobId)) {
|
|
290
299
|
const scope = historyScope;
|
|
@@ -305,13 +314,20 @@ export function createSqmLocalReviewWorker({
|
|
|
305
314
|
})
|
|
306
315
|
.finally(() => {
|
|
307
316
|
pollPromise = null;
|
|
317
|
+
reviewController = null;
|
|
308
318
|
releaseWork();
|
|
309
319
|
onChange();
|
|
310
320
|
});
|
|
311
321
|
return pollPromise;
|
|
312
322
|
},
|
|
323
|
+
async stop(key) {
|
|
324
|
+
if (key !== `sqm:${activeJobId}` || !activeJobId) throw new Error("This SQM task is no longer running.");
|
|
325
|
+
reviewController?.abort();
|
|
326
|
+
await pollPromise;
|
|
327
|
+
},
|
|
313
328
|
async close() {
|
|
314
329
|
closed = true;
|
|
330
|
+
reviewController?.abort();
|
|
315
331
|
await pollPromise;
|
|
316
332
|
},
|
|
317
333
|
};
|
|
@@ -494,7 +510,7 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, cwd
|
|
|
494
510
|
let child;
|
|
495
511
|
try {
|
|
496
512
|
child = spawnImpl(command, args, {
|
|
497
|
-
env: safeReviewEnvironment(environment, provider), cwd, windowsHide: true, stdio: ["pipe", "pipe", "pipe"],
|
|
513
|
+
env: safeReviewEnvironment(environment, provider), cwd, windowsHide: true, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"],
|
|
498
514
|
});
|
|
499
515
|
} catch { reject(providerFailure("provider_spawn_failed", { model })); return; }
|
|
500
516
|
let stdout = "", stderrPending = "", outputBytes = 0;
|
|
@@ -503,9 +519,9 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, cwd
|
|
|
503
519
|
const halt = error => {
|
|
504
520
|
if (settled || terminalError) return;
|
|
505
521
|
terminalError = error;
|
|
506
|
-
child
|
|
522
|
+
signalExecutorProcess(child, "SIGTERM");
|
|
507
523
|
if (!settled) killTimer = setTimeout(() => {
|
|
508
|
-
child
|
|
524
|
+
signalExecutorProcess(child, "SIGKILL");
|
|
509
525
|
// Keep the original timeout/cancellation if the process API never
|
|
510
526
|
// acknowledges termination; do not replace it with an opaque error.
|
|
511
527
|
if (!settled) killTimer = setTimeout(() => finish(terminalError), terminationGraceMs);
|