@awak-app/simy-cli 0.7.10 → 0.7.12
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 +17 -1
- package/package.json +1 -1
- package/src/agent.js +47 -7
- package/src/auto-update.js +32 -9
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +108 -11
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +326 -0
- package/src/codex-plugins/simy/hooks/update-dispatch-identity.mjs +78 -0
- package/src/codex-plugins/simy/hooks/update-state-store.mjs +73 -0
- package/src/delivery-hooks/desktop.js +1 -1
- package/src/delivery-hooks/supervisor.js +39 -10
- package/src/desktop-host.js +90 -15
- package/src/desktop-status.js +1 -1
- package/src/direct-executor.js +104 -4
- package/src/local-task.js +14 -3
- package/src/provider-stream.js +3 -0
- package/src/runner.js +19 -4
- package/src/session-supervision/claude-desktop-lineage.js +19 -5
- package/src/session-supervision/claude-sessions.js +73 -15
- package/src/session-supervision/codex-app-server.js +28 -9
- package/src/session-supervision/index.js +25 -14
- package/src/session-supervision/pull-requests.js +52 -0
- package/src/session-supervision/work-state.js +84 -0
- package/src/sqm/claude-plugin.js +5 -0
- package/src/sqm/review-worker.js +15 -6
- package/src/update-detachment/claude-observation.js +33 -0
- package/src/update-detachment/codex-context.js +27 -0
- package/src/update-detachment/coordinator.js +117 -0
- package/src/update-detachment/dispatch-gate.js +1 -0
- package/src/update-detachment/ledger-adapter.js +62 -0
- package/src/update-detachment/ledger-reader.js +42 -0
- package/src/update-detachment/provenance.js +1 -0
- package/src/update-detachment/provider-adapters.js +33 -0
- package/src/update-detachment/reconcile.js +105 -0
- package/src/update-detachment/reconciliation-adapter.js +13 -0
- package/src/update-detachment/runtime.js +319 -0
- package/src/update-detachment/state-store.js +1 -0
- package/src/update-detachment/worker-entry.js +145 -0
- package/src/update-detachment/worker-ledger-runtime.js +34 -0
- package/src/update-detachment/worker-ledger.js +138 -0
- package/src/update-detachment/worker-storage.js +29 -0
- package/src/update-detachment/worker.js +167 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { captureDispatchProvenance } from '../update-detachment/provenance.js';
|
|
2
|
+
import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, rootFromDelivery, observeDispatchEvent } from '../update-detachment/dispatch-gate.js';
|
|
1
3
|
import { safeCheckpointDiagnostic } from './checkpoint-diagnostic.js';
|
|
2
4
|
import { recordHandoffStage } from '../codex-plugins/simy/hooks/consistency-handoff.mjs';
|
|
3
5
|
import { retainControllerEvidence } from '../codex-plugins/simy/hooks/purpose-evidence.mjs';
|
|
@@ -13,7 +15,7 @@ import { requireProviderLink } from '../provider-links.js';
|
|
|
13
15
|
import { spawn } from 'node:child_process';
|
|
14
16
|
import { randomUUID } from 'node:crypto';
|
|
15
17
|
import { dirname } from 'node:path';
|
|
16
|
-
import { canExecuteAutonomousExternal, automaticContinuationAllowed, settlePurposeVerification, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryProgressContext, deliveryStop, listRecords, readRecord, managedBudgetReason, notifyState, processIdentity, sharesTaskWorkspace, taskDirectory, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
18
|
+
import { hasPendingUserStop, recordPath, canExecuteAutonomousExternal, automaticContinuationAllowed, settlePurposeVerification, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryProgressContext, deliveryStop, listRecords, readRecord, managedBudgetReason, notifyState, processIdentity, sharesTaskWorkspace, taskDirectory, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
17
19
|
import { resolveBackendExecutable } from '../backend-executable.js';
|
|
18
20
|
import { executeCodexAppServer } from '../session-supervision/codex-app-server.js';
|
|
19
21
|
import { acquireWorkspaceLease, leaseOwnerDead } from '../session-supervision/workspace-lease.js';
|
|
@@ -50,6 +52,7 @@ async function assertActiveLease(root, provider, session, leaseId, signal, curre
|
|
|
50
52
|
if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
|
|
51
53
|
await requireProviderLink({ root, provider });
|
|
52
54
|
const current = await readRecord(root, provider, session);
|
|
55
|
+
if (hasPendingUserStop(root, current)) throw new Error('User stopped this provider task.');
|
|
53
56
|
if (signal?.aborted || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) {
|
|
54
57
|
throw new Error('Managed recovery lease changed before provider dispatch.');
|
|
55
58
|
}
|
|
@@ -83,12 +86,15 @@ export function parseAgentCheckpoint(output, expectedSession) {
|
|
|
83
86
|
// The provider CLI is a new process. It is used only for an exact Codex UUID
|
|
84
87
|
// enrolled for this recovery channel after a corroborated process exit.
|
|
85
88
|
export async function executeCodexResume(record, { signal, root, leaseId, executable = resolveBackendExecutable, currentTime = Date.now, onActivity = () => {}, beforeDispatch = async () => {}, leaseMonitored = false } = {}) {
|
|
89
|
+
const dispatchOwner = captureDispatchOwner(rootFromDelivery(root));
|
|
86
90
|
if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
|
|
91
|
+
const run = record.run;
|
|
92
|
+
const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: record.provider, providerSession: record.session, worktree: taskDirectory(record), taskId: run.managed.taskId, recordRef: recordPath(root, record.provider, record.session) });
|
|
93
|
+
assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
|
|
87
94
|
const binary = await executable('codex');
|
|
88
95
|
if (!binary) throw new Error('Codex executable unavailable.');
|
|
89
96
|
await assertActiveLease(root, record.provider, record.session, leaseId, signal, currentTime);
|
|
90
|
-
const
|
|
91
|
-
const context = { taskId: run.managed.taskId, session: record.session, sessionCwd: record.cwd, executionCwd: taskDirectory(record), objective: run.objective, nextStep: run.nextStep,
|
|
97
|
+
const context = { taskId: run.managed.taskId, session: record.session, sessionCwd: record.cwd, executionCwd: taskDirectory(record), objective: run.objective, nextStep: run.nextStep,
|
|
92
98
|
lastDependencyResolution: run.managed.lastDependencyResolution,
|
|
93
99
|
...(run.managed.upgradedFrom ? { adoptedLoop: run.managed.upgradedFrom, priorCheckpoint: run.checkpoint } : {}),
|
|
94
100
|
remainingWork: run.managed.remainingWork, acceptanceCriteria: run.managed.acceptanceCriteria, proposedAction: run.managed.proposedAction,
|
|
@@ -102,14 +108,15 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
|
|
|
102
108
|
if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
|
|
103
109
|
|
|
104
110
|
return new Promise((resolve, reject) => {
|
|
105
|
-
const child = spawn(binary, args, { cwd: record
|
|
106
|
-
env: { ...process.env, SIMY_HOME: dirname(root) } });
|
|
111
|
+
const child = dispatchWithFence(dispatchOwner, { taskId: run.managed?.taskId || record.session, attemptId: leaseId, provenance }, () => { if (hasPendingUserStop(root, record)) throw new Error('User stopped this provider task.'); return spawn(binary, args, { cwd: taskDirectory(record), stdio: ['ignore', 'pipe', 'pipe'], signal,
|
|
112
|
+
env: { ...process.env, SIMY_HOME: dirname(root) } }); });
|
|
107
113
|
let error = '', spawnError = null, pending = '', lastMessage = null;
|
|
108
114
|
const startedSessions = new Set();
|
|
109
115
|
const consumeLine = line => {
|
|
110
116
|
if (Buffer.byteLength(line) > MAX_OUTPUT) return;
|
|
111
117
|
try {
|
|
112
118
|
const event = JSON.parse(line);
|
|
119
|
+
observeDispatchEvent({ root: dispatchOwner.root, attemptId: leaseId, provider: record.provider, event });
|
|
113
120
|
if (event.type === 'thread.started' && typeof event.thread_id === 'string') startedSessions.add(event.thread_id);
|
|
114
121
|
if (event.type === 'item.completed' && event.item?.type === 'agent_message' && typeof event.item.text === 'string') lastMessage = event.item.text;
|
|
115
122
|
} catch { /* Other provider events are ignored. */ }
|
|
@@ -123,7 +130,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
|
|
|
123
130
|
};
|
|
124
131
|
signal?.addEventListener('abort', terminate, { once: true });
|
|
125
132
|
const stopWatch = leaseMonitored ? () => {} : watchLease(root, record.provider, record.session, current => {
|
|
126
|
-
if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) terminate();
|
|
133
|
+
if (hasPendingUserStop(root, current) || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) terminate();
|
|
127
134
|
}, terminate);
|
|
128
135
|
child.stdout.on('data', chunk => {
|
|
129
136
|
onActivity();
|
|
@@ -138,7 +145,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
|
|
|
138
145
|
cleanup(); if (pending) consumeLine(pending);
|
|
139
146
|
const output = [...startedSessions].map(thread_id => JSON.stringify({ type: 'thread.started', thread_id })).join('\n') + '\n' +
|
|
140
147
|
(lastMessage === null ? '' : JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: lastMessage } }));
|
|
141
|
-
if (spawnError) reject(spawnError); else resolve({ code, output, error });
|
|
148
|
+
if (spawnError) reject(spawnError); else resolve({ code, dispatchAttemptId: leaseId, output, error });
|
|
142
149
|
});
|
|
143
150
|
});
|
|
144
151
|
}
|
|
@@ -159,6 +166,13 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
159
166
|
record = await recoverOutcomeAuditLease(record, { root, inspectProcess });
|
|
160
167
|
let run = record.run;
|
|
161
168
|
if (!run?.managed) continue;
|
|
169
|
+
if (hasPendingUserStop(root, record)) {
|
|
170
|
+
await updateRecord(root, record.provider, record.session, current => {
|
|
171
|
+
if (hasPendingUserStop(root, current)) { current.run.state = 'stopped'; current.run.reason = 'User interrupted the provider.'; current.run.updatedAt = now; }
|
|
172
|
+
return current;
|
|
173
|
+
});
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
162
176
|
// The audit must retain a stable target until its result is settled, even
|
|
163
177
|
// when no token ceiling is configured. Its own timeout/owner recovery
|
|
164
178
|
// releases this lease; a worker never takes it over.
|
|
@@ -169,7 +183,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
169
183
|
// A live/unknown owner is never taken over merely because time passed.
|
|
170
184
|
if (!ownerDead && (owner || claimNow - run.managed.resumeLease?.claimedAt <= 5 * 60_000)) continue;
|
|
171
185
|
await updateRecord(root, record.provider, record.session, current => {
|
|
172
|
-
if (current?.run?.state !== 'auto_resuming' || current.run.managed.resumeLease?.id !== run.managed.resumeLease?.id) return current;
|
|
186
|
+
if (hasPendingUserStop(root, current) || current?.run?.state !== 'auto_resuming' || current.run.managed.resumeLease?.id !== run.managed.resumeLease?.id) return current;
|
|
173
187
|
current.run.state = 'recovery_required'; current.run.recoveryCause = 'interrupted_owner'; current.run.managed.autoRecovery = false;
|
|
174
188
|
recordRecoveryIncident(current.run, { code: 'interrupted_owner', subject: current.run.managed.executionAttempt?.id || '', now: claimNow });
|
|
175
189
|
recordRecoveryStage(current.run, 'interrupted', { now: claimNow, attemptId: current.run.managed.executionAttempt?.id });
|
|
@@ -268,7 +282,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
268
282
|
if (signal?.aborted) controller.abort();
|
|
269
283
|
else signal?.addEventListener('abort', abort, { once: true });
|
|
270
284
|
const stopWatch = watchLease(root, record.provider, record.session, current => {
|
|
271
|
-
if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId ||
|
|
285
|
+
if (hasPendingUserStop(root, current) || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId ||
|
|
272
286
|
managedBudgetReason(current.run, currentTime(), { checkProgress: false }) || !waitingForTool && currentTime() - lastActivityAt >= 5 * 60_000) controller.abort();
|
|
273
287
|
}, () => controller.abort());
|
|
274
288
|
try {
|
|
@@ -381,6 +395,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
381
395
|
if (result.verification) await settlePurposeVerification({ root, provider: record.provider, session: record.session, expectedLease: leaseId, review: result.verification, verifySnapshot: current => reviewWorkspaceIdentity(current, root, result.verification, { signal: controller.signal }), now: Date.now() });
|
|
382
396
|
else await checkpointDelivery({ root, provider: record.provider, session: record.session, cwd: record.cwd,
|
|
383
397
|
checkpoint: result.checkpoint || parseAgentCheckpoint(result.output, record.session), expectedLease: leaseId, toolEvidence: result.toolEvidence || [], sourceContext: result.sourceContext, controllerEvidence: result.controllerEvidence, now: Date.now() });
|
|
398
|
+
if (result.dispatchAttemptId) queueDispatchReceipt({ root: rootFromDelivery(root), attemptId: result.dispatchAttemptId, receiptId: `checkpoint:${leaseId}`, resultRef: `delivery-record:${record.provider}:${record.session}:${leaseId}` });
|
|
384
399
|
await updateRecord(root, record.provider, record.session, current => {
|
|
385
400
|
if (current?.run?.managed?.executionAttempt?.id !== leaseId) return current;
|
|
386
401
|
current.run.managed.executionAttempt.phase = 'checkpointed';
|
|
@@ -404,7 +419,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
404
419
|
});
|
|
405
420
|
} catch (error) { result = { ...result, code: -1, errorCode: 'checkpoint_rejected', diagnostic: safeCheckpointDiagnostic(error) }; }
|
|
406
421
|
}
|
|
407
|
-
await
|
|
422
|
+
await updateRecordAfterControl(root, record.provider, record.session, async current => {
|
|
408
423
|
const live = current?.run;
|
|
409
424
|
if (live?.managed?.resumeLease?.id !== leaseId) return current;
|
|
410
425
|
delete live.managed.resumeLease;
|
|
@@ -526,3 +541,17 @@ export function createManagedSupervisor(options) {
|
|
|
526
541
|
async close() { closed = true; controller.abort(); await Promise.allSettled([running, auditing]); },
|
|
527
542
|
};
|
|
528
543
|
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
// Stop/pause can wake the owned turn before its control transaction releases
|
|
547
|
+
// the record lock. Retry only acquisition conflicts; never steal a live lock.
|
|
548
|
+
async function updateRecordAfterControl(root, provider, session, mutate) {
|
|
549
|
+
const until = Date.now() + 5000;
|
|
550
|
+
while (true) {
|
|
551
|
+
try { return await updateRecord(root, provider, session, mutate); }
|
|
552
|
+
catch (error) {
|
|
553
|
+
if (error.message !== 'Delivery state is busy; retry the command.' || Date.now() >= until) throw error;
|
|
554
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
package/src/desktop-host.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { createUpdateRuntime } from './update-detachment/runtime.js';
|
|
2
|
+
import { createCoordinatorReconciler } from "./update-detachment/reconciliation-adapter.js";
|
|
3
|
+
import { createLedgerCoordinatorAdapters } from "./update-detachment/ledger-adapter.js";
|
|
4
|
+
import { createUpdateCoordinator } from "./update-detachment/coordinator.js";
|
|
5
|
+
import { sessionWorkState, compareSessionWork, reconcileFamilyCompletion, expireSessionWorkState } from './session-supervision/work-state.js';
|
|
6
|
+
import { createPullRequestObserver } from './session-supervision/pull-requests.js';
|
|
1
7
|
import { sessionDecisionPresentation } from './session-supervision/decision-presentation.js';
|
|
2
8
|
import { PROVIDER_LINKS, readProviderLinks, writeProviderLink, requireProviderLink } from "./provider-links.js";
|
|
3
9
|
import { randomUUID } from "node:crypto";
|
|
@@ -11,7 +17,7 @@ import { stopDesktopWorkForUpdate } from './desktop-update-stop.js';
|
|
|
11
17
|
import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
|
|
12
18
|
import { createActionDeadlineService } from './session-supervision/action-deadline.js';
|
|
13
19
|
import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
|
|
14
|
-
import { controlDelivery, verifyDelivery, createRecordReader, updateRecord, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
20
|
+
import { controlDelivery, verifyDelivery, createRecordReader, updateRecord, recordProviderArchive, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
15
21
|
import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
|
|
16
22
|
import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
|
|
17
23
|
import { createStopNotifier } from "./session-supervision/stop-notifications.js";
|
|
@@ -101,7 +107,7 @@ function desktopActionError(action, error) {
|
|
|
101
107
|
if (action === "session_deadline_decide") return error?.code === "conflict"
|
|
102
108
|
? "Deadline changed; refresh before deciding." : "The deadline is unavailable. Refresh this conversation before deciding.";
|
|
103
109
|
if (typeof action === "string" && action.startsWith("session_")) {
|
|
104
|
-
return action.startsWith("session_action_") ? associationActionError(error) : sessionActionError(error);
|
|
110
|
+
return action.startsWith("session_action_") ? associationActionError(error) : sessionActionError(error, action);
|
|
105
111
|
}
|
|
106
112
|
if (["organization_select", "organization_cancel", "organizations_refresh"].includes(action)) {
|
|
107
113
|
return error?.code === "desktop_organization_busy"
|
|
@@ -192,6 +198,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
192
198
|
};
|
|
193
199
|
let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false, scheduledPublish = null;
|
|
194
200
|
const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, list: async () => linkedSessions(await recordReader.list()), onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
201
|
+
const updateRuntime = createUpdateRuntime({ root, origin:webOrigin,
|
|
202
|
+
readContext:()=>agent?.getUpdateDetachmentContext?.(), listRecords:()=>recordReader.list(),
|
|
203
|
+
...(dependencies.observeUpdateProvider ? {observe:dependencies.observeUpdateProvider} : {}) });
|
|
204
|
+
const coordinator = createUpdateCoordinator({ ...(dependencies.updateDetachment || {
|
|
205
|
+
store:updateRuntime.store,gate:updateRuntime.gate,
|
|
206
|
+
boundary:dependencies.updateSafeBoundary || (async()=>{const holds=updater?.updateDrainState?.();if(holds && (holds.activeWork || holds.applyHolds))return {safe:false,liveExecutions:holds.activeWork,pendingWrites:holds.applyHolds,unsettledPermissions:0};return updateRuntime.boundary();}),
|
|
207
|
+
reconcile:dependencies.reconcileUpdateConnection || createCoordinatorReconciler(dependencies.updateReconciliation) || updateRuntime.reconcile,
|
|
208
|
+
}), preserveConnectedOnStartup:true, initialState:env.SIMY_UPDATE_TRANSFER_TOKEN?'detached':'connected',
|
|
209
|
+
onChange:()=>{if(agent && !closed && !relay)setImmediate(()=>void publish().catch(()=>{}));} });
|
|
195
210
|
let actionAssociations = makeActionAssociations();
|
|
196
211
|
const statisticsCache = new Map();
|
|
197
212
|
let activeStatisticsRead = null;
|
|
@@ -411,6 +426,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
411
426
|
});
|
|
412
427
|
unfinishedDesktopWork = false;
|
|
413
428
|
})();
|
|
429
|
+
const observePullRequests = dependencies.observePullRequests || createPullRequestObserver();
|
|
414
430
|
const refreshSessions = (force = false) => {
|
|
415
431
|
if (discoveryStopped && !closed && !relay) return sessionStatus;
|
|
416
432
|
if (closed || relay || !desktopAccessReady(currentAccess)) return { sessions: [], providers: {}, refreshedAt: null };
|
|
@@ -428,8 +444,12 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
428
444
|
sessionRefreshAgain = desktopAccessReady(latestAccess);
|
|
429
445
|
return;
|
|
430
446
|
}
|
|
431
|
-
const sessions = linkedSessions(value.sessions);
|
|
432
|
-
await
|
|
447
|
+
const sessions = await observePullRequests(linkedSessions(value.sessions), { signal: controller.signal });
|
|
448
|
+
const afterObservation = await accessSnapshot();
|
|
449
|
+
if (controller.signal.aborted || !desktopAccessReady(afterObservation) || accessScope(afterObservation) !== requestedScope) {
|
|
450
|
+
await analysisManager.update([]); sessionRefreshAgain = desktopAccessReady(afterObservation); return;
|
|
451
|
+
}
|
|
452
|
+
if (coordinator.acceptsNewWork()) await analysisManager.update(analysisSessions(sessions));
|
|
433
453
|
if (controller.signal.aborted) return;
|
|
434
454
|
sessionStatus = { ...value, sessions, initialLoading: false }; sessionRefreshedAt = Date.now(); })
|
|
435
455
|
.catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, initialLoading: false, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
|
|
@@ -612,6 +632,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
612
632
|
const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
|
|
613
633
|
if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
|
|
614
634
|
status.access = sampledAccess;
|
|
635
|
+
status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.());
|
|
636
|
+
if(!desktopAccessReady(sampledAccess))status.updateDetachment={...status.updateDetachment,tasks:[],actions:status.updateDetachment.actions.filter(action=>action==='cancel_detach_update')};
|
|
615
637
|
const requestedLinks = await decorateProviderSettings(status);
|
|
616
638
|
const sameScope = desktopAccessReady(currentAccess) &&
|
|
617
639
|
accessScope(lastStatus?.access) === accessScope(currentAccess);
|
|
@@ -626,7 +648,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
626
648
|
// Cached enrichment must follow the current connection preference even
|
|
627
649
|
// while a full projection is waiting on records or remote metadata.
|
|
628
650
|
status.deliveryHooks.runs = linkedSessions(status.deliveryHooks.runs || []);
|
|
629
|
-
status.sessionSupervision.sessions = linkedSessions(status.sessionSupervision.sessions || []);
|
|
651
|
+
status.sessionSupervision.sessions = linkedSessions(status.sessionSupervision.sessions || []).map(candidate => expireSessionWorkState(candidate)).sort(compareSessionWork);
|
|
630
652
|
status.sessionSupervision.providers ||= {};
|
|
631
653
|
for (const provider of PROVIDER_LINKS) {
|
|
632
654
|
if (!providerLinks[provider].connected) status.sessionSupervision.providers[provider] = { discovery: 'disconnected', reason: null };
|
|
@@ -660,6 +682,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
660
682
|
const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
|
|
661
683
|
if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
|
|
662
684
|
status.access = sampledAccess;
|
|
685
|
+
status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.());
|
|
686
|
+
if(!desktopAccessReady(sampledAccess))status.updateDetachment={...status.updateDetachment,tasks:[],actions:status.updateDetachment.actions.filter(action=>action==='cancel_detach_update')};
|
|
663
687
|
const requestedScope = accessScope(currentAccess);
|
|
664
688
|
const requestedLinks = await decorateProviderSettings(status);
|
|
665
689
|
const allRecords = await recordReader.list();
|
|
@@ -702,9 +726,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
702
726
|
status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
|
|
703
727
|
await actionAssociations.decorate(status.deliveryHooks, projection);
|
|
704
728
|
const discovery = refreshSessions();
|
|
705
|
-
automaticContinuation?.tick(discovery.sessions);
|
|
729
|
+
if (coordinator.acceptsNewWork()) automaticContinuation?.tick(discovery.sessions);
|
|
706
730
|
const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
|
|
707
731
|
await actionAssociations.decorateSessions?.(sessions, projection);
|
|
732
|
+
const stateRunsBySession = new Map((projection?.records || records).filter(record => record.run).map(record => [`${record.provider}:${record.session}`, record.run]));
|
|
708
733
|
const runsBySession = new Map((status.deliveryHooks.runs || []).map(run => [`${run.provider}:${run.session}`, run]));
|
|
709
734
|
for (const session of sessions) {
|
|
710
735
|
const key = `${session.provider}:${session.session}`;
|
|
@@ -727,8 +752,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
727
752
|
const run = runsBySession.get(`${session.provider}:${session.session}`);
|
|
728
753
|
if (run) run.actionDeadline = session.actionDeadline;
|
|
729
754
|
}
|
|
730
|
-
status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions: sessions.map(candidate => ({ ...candidate,
|
|
731
|
-
decisionRequest: sessionDecisionPresentation(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`))
|
|
755
|
+
status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions: reconcileFamilyCompletion(sessions.map(candidate => ({ ...candidate,
|
|
756
|
+
decisionRequest: sessionDecisionPresentation(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`)),
|
|
757
|
+
workState: sessionWorkState(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`) || stateRunsBySession.get(`${candidate.provider}:${candidate.session}`), { provider: discovery.providers?.[candidate.provider] }) }))).sort(compareSessionWork) };
|
|
732
758
|
const latestAccess = await accessSnapshot();
|
|
733
759
|
if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks ||
|
|
734
760
|
!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope ||
|
|
@@ -782,6 +808,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
782
808
|
})();
|
|
783
809
|
|
|
784
810
|
try {
|
|
811
|
+
await updateRuntime.initialize();
|
|
812
|
+
if (env.SIMY_UPDATE_TRANSFER_TOKEN) await updateRuntime.acceptHandoff(env.SIMY_UPDATE_TRANSFER_TOKEN);
|
|
813
|
+
else await coordinator.initialize();
|
|
785
814
|
analysisManager = await makeAnalysisManager();
|
|
786
815
|
automaticContinuation = makeAutomaticContinuation();
|
|
787
816
|
if (!candidate) {
|
|
@@ -794,6 +823,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
794
823
|
dependencies: { packageInfo: updatePackageRoot ? await readCliPackageInfo(updatePackageRoot) : packageInfo,
|
|
795
824
|
logger: message => diagnostics.write(`update: ${message}`) },
|
|
796
825
|
});
|
|
826
|
+
updater.setCoordinator?.(coordinator);
|
|
797
827
|
agent = await (dependencies.startAgent || startAgent)({
|
|
798
828
|
daemon: true, webOrigin, sessionRoot: root, quiet: true, updateManager: updater,
|
|
799
829
|
deferStartupSynchronization: true, enforceDesktopAccess: true,
|
|
@@ -803,9 +833,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
803
833
|
setImmediate(() => { void publish().catch(() => {}); });
|
|
804
834
|
},
|
|
805
835
|
});
|
|
836
|
+
if (env.SIMY_UPDATE_TRANSFER_TOKEN) await coordinator.reconnect();
|
|
806
837
|
stopWorkflows = (dependencies.startWorkflowUpdates || startWorkflowUpdates)({ enabled: env.SIMY_AUTO_UPDATE !== "0" });
|
|
807
838
|
updater.on?.("change", () => { void publish().catch(() => {}); });
|
|
808
|
-
updater.start({ registry: agent.registry, isDesktopIdle: () => !unfinishedDesktopWork,
|
|
839
|
+
updater.start({ registry: agent.registry, coordinator, isDesktopIdle: () => !unfinishedDesktopWork,
|
|
809
840
|
restartDaemon: async (targetVersion, installation) => {
|
|
810
841
|
if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
|
|
811
842
|
discoveryStopped = true;
|
|
@@ -817,12 +848,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
817
848
|
// The candidate also shares association records and their sync outbox.
|
|
818
849
|
// Drain the old writer before starting its replacement.
|
|
819
850
|
await actionAssociations.close();
|
|
851
|
+
const transferToken = coordinator.snapshot().state === 'detached' ? await updateRuntime.prepareHandoff() : null;
|
|
820
852
|
let child;
|
|
821
853
|
try { child = (dependencies.spawn || spawn)(installation.executablePath,
|
|
822
854
|
[join(dirname(installation.entryPath), "desktop-host.js")], {
|
|
823
855
|
stdio: ["pipe", "pipe", "pipe"],
|
|
824
|
-
env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
|
|
856
|
+
env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken, ...(transferToken ? {SIMY_UPDATE_TRANSFER_TOKEN:transferToken} : {}) },
|
|
825
857
|
}); } catch (error) {
|
|
858
|
+
if(transferToken)await updateRuntime.reclaimFailedHandoff();
|
|
826
859
|
actionAssociations = makeActionAssociations();
|
|
827
860
|
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true); throw error;
|
|
828
861
|
}
|
|
@@ -860,7 +893,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
860
893
|
let event; try { event = JSON.parse(line); } catch { return; }
|
|
861
894
|
if (accepted) { emit(event); return; }
|
|
862
895
|
if (event.type === "status") {
|
|
863
|
-
if (!validCandidateStatus(event.status, targetVersion, webOrigin)) {
|
|
896
|
+
if (!validCandidateStatus(event.status, targetVersion, webOrigin) || transferToken && (event.status.updateDetachment?.connectionState !== 'connected' || event.status.updateDetachment.operationId !== updateRuntime.snapshot(coordinator.snapshot()).operationId)) {
|
|
864
897
|
finish(reject, new Error("Updated desktop runtime identity mismatch."));
|
|
865
898
|
} else { accepted = true; finish(accept, event); }
|
|
866
899
|
}
|
|
@@ -870,8 +903,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
870
903
|
await stopAgent();
|
|
871
904
|
if (child.exitCode !== null || closed) throw new Error("Updated runtime stopped during handoff.");
|
|
872
905
|
emit(first);
|
|
906
|
+
return {connectionTransferred:Boolean(transferToken)};
|
|
873
907
|
} catch (error) {
|
|
874
908
|
lines.close(); await stopUpdateCandidate(child);
|
|
909
|
+
if(transferToken)await updateRuntime.reclaimFailedHandoff();
|
|
875
910
|
if (!closed && !relay) {
|
|
876
911
|
actionAssociations = makeActionAssociations();
|
|
877
912
|
analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true);
|
|
@@ -883,7 +918,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
883
918
|
await publish();
|
|
884
919
|
const maintenance = new Map();
|
|
885
920
|
const tick = worker => {
|
|
886
|
-
if (closed || relay || maintenance.has(worker)) return;
|
|
921
|
+
if (closed || relay || !coordinator.acceptsNewWork() || maintenance.has(worker)) return;
|
|
887
922
|
const pending = Promise.resolve().then(() => worker.tick()).then(() => { if (!closed && !relay) return publish(); }).catch(() => {}).finally(() => maintenance.delete(worker));
|
|
888
923
|
maintenance.set(worker, pending);
|
|
889
924
|
};
|
|
@@ -990,6 +1025,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
990
1025
|
if (!relay.stdin.destroyed && relay.exitCode === null) relay.stdin.write(`${JSON.stringify(command)}\n`);
|
|
991
1026
|
return;
|
|
992
1027
|
}
|
|
1028
|
+
if(command.action==='cancel_detach_update') {
|
|
1029
|
+
try {updateRuntime.validate(command,coordinator.snapshot(),updater?.snapshot?.());coordinator.cancel();emit({type:'result',id:command.id,ok:true});}
|
|
1030
|
+
catch {emit({type:'result',id:command.id,ok:false,message:'This update operation changed. Refresh its status.'});}
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
993
1033
|
// Read-only activity must never hold the ordered control queue. Share an
|
|
994
1034
|
// identical pending selection and cancel superseded selections immediately.
|
|
995
1035
|
let statisticsRequest, requestedStatisticsSelection;
|
|
@@ -1005,6 +1045,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1005
1045
|
if (closed) return;
|
|
1006
1046
|
try {
|
|
1007
1047
|
const action = command.action;
|
|
1048
|
+
if(['detach_update','update_reconnect'].includes(action)) {
|
|
1049
|
+
const access=await accessSnapshot(); requireDesktopAccess(access);
|
|
1050
|
+
updateRuntime.validate(command,coordinator.snapshot(),updater?.snapshot?.());
|
|
1051
|
+
if(action==='detach_update')await updater.maybeApply({detach:true});
|
|
1052
|
+
else await coordinator.reconnect();
|
|
1053
|
+
emit({type:'result',id:command.id,ok:coordinator.snapshot().state==='connected' && (action!=='detach_update' || updater.snapshot().state==='updated')});
|
|
1054
|
+
await publish();return;
|
|
1055
|
+
}
|
|
1008
1056
|
if (action === "quit") {
|
|
1009
1057
|
if (command.forUpdate === true) {
|
|
1010
1058
|
if (command.force !== true) throw new Error('Confirm before stopping unfinished tasks.');
|
|
@@ -1039,6 +1087,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1039
1087
|
if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 ||
|
|
1040
1088
|
hasUnfinishedSessions(await recordReader.list())) throw new Error('Confirm before stopping tasks to restart.');
|
|
1041
1089
|
}
|
|
1090
|
+
if (!coordinator.acceptsNewWork()) throw new Error('Reconcile the update connection before starting another update.');
|
|
1042
1091
|
await executeUpdate(action === 'prepare_update_restart');
|
|
1043
1092
|
emit({ type: 'result', id: command.id, ok: true });
|
|
1044
1093
|
void publish().catch(() => {}); return;
|
|
@@ -1046,8 +1095,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1046
1095
|
const access = await accessSnapshot();
|
|
1047
1096
|
const commandScope = accessScope(access);
|
|
1048
1097
|
if (!desktopActionAllowed(access, command)) throw Object.assign(new Error('Desktop access is required.'), { code: 'desktop_access_required' });
|
|
1049
|
-
|
|
1098
|
+
const recoveryControl = ['provider_disconnect', 'session_unmonitor'].includes(action) ||
|
|
1099
|
+
['session_set_auto_continue', 'session_set_email_reports'].includes(action) && command.enabled === false;
|
|
1100
|
+
if (['detach_update', 'update_reconnect'].includes(action)) requireDesktopAccess(access);
|
|
1101
|
+
else if (!recoveryControl && desktopAccessReady(access) && !desktopActionAllowed({ state: 'unavailable' }, command)) await authorizeWork();
|
|
1050
1102
|
|
|
1103
|
+
if (!coordinator.acceptsNewWork() && !recoveryControl && !["refresh", "reconnect", "task_detail", "check_update", "quit", "delivery_stop", "delivery_pause", "delivery_reject"].includes(action)) {
|
|
1104
|
+
throw new Error("The update connection requires recovery.");
|
|
1105
|
+
}
|
|
1051
1106
|
let commandProviderLink;
|
|
1052
1107
|
if (command.provider && !["provider_connect", "provider_disconnect", "delivery_stop", "delivery_pause", "delivery_reject", "session_untrack", "session_unmonitor"].includes(action) &&
|
|
1053
1108
|
!(action === "session_set_auto_continue" && command.enabled === false)) {
|
|
@@ -1128,7 +1183,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1128
1183
|
await agent.refreshDesktopStatus();
|
|
1129
1184
|
await refreshPlugins();
|
|
1130
1185
|
refreshSessions(true);
|
|
1131
|
-
if (desktopAccessReady(currentAccess)) void actionAssociations.tick().then(publish).catch(() => {});
|
|
1186
|
+
if (desktopAccessReady(currentAccess) && coordinator.acceptsNewWork()) void actionAssociations.tick().then(publish).catch(() => {});
|
|
1132
1187
|
}
|
|
1133
1188
|
else if (action === "organizations_refresh") await agent.refreshOrganizations();
|
|
1134
1189
|
else if (action === "sqm_set_enabled") await agent.setSqmEnabled(command.enabled);
|
|
@@ -1339,6 +1394,25 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1339
1394
|
await authorizeWork(commandScope);
|
|
1340
1395
|
const result = await deadlines.decide(context, { ...command, requestId: command.requestId || command.decisionId });
|
|
1341
1396
|
if (result.status === 'conflict') throw Object.assign(new Error('Deadline changed; refresh before deciding.'), { code: 'conflict' });
|
|
1397
|
+
} else if (action === "session_archive" || action === "session_restore") {
|
|
1398
|
+
const candidate = await selectedSession(command, { preferObserved: true });
|
|
1399
|
+
await authorizeWork(commandScope);
|
|
1400
|
+
if (!['local', 'provider'].includes(command.scope)) throw new Error('Choose local or provider archive explicitly.');
|
|
1401
|
+
const archived = action === 'session_archive';
|
|
1402
|
+
if (command.scope === 'provider') {
|
|
1403
|
+
if (command.provider !== 'codex') throw new Error('Archive or restore this conversation in Claude, then refresh SIMY.');
|
|
1404
|
+
const client = await (dependencies.openCodexAppServer || openCodexAppServer)();
|
|
1405
|
+
try { await client.request(archived ? 'thread/archive' : 'thread/unarchive', { threadId: command.session });
|
|
1406
|
+
if (archived) await recordProviderArchive(deliveryRoot, command.provider, command.session, true, Date.now());
|
|
1407
|
+
}
|
|
1408
|
+
finally { await client.close(); }
|
|
1409
|
+
} else {
|
|
1410
|
+
await updateRecord(deliveryRoot, command.provider, command.session, previous => ({
|
|
1411
|
+
...(previous || { schemaVersion: 1, provider: command.provider, session: command.session, cwd: candidate.cwd, observedAt: Date.now() }),
|
|
1412
|
+
listHidden: archived,
|
|
1413
|
+
}));
|
|
1414
|
+
}
|
|
1415
|
+
refreshSessions(true);
|
|
1342
1416
|
} else if (action === "session_track" || action === "session_untrack") {
|
|
1343
1417
|
const candidate = await selectedSession(command);
|
|
1344
1418
|
if (candidate.capabilities?.canMonitor !== true) throw new Error('Tracking is unavailable for this conversation.');
|
|
@@ -1439,7 +1513,7 @@ function associationActionError(error) {
|
|
|
1439
1513
|
return 'The Action service is unavailable. Your conversation continues with its existing settings.';
|
|
1440
1514
|
}
|
|
1441
1515
|
|
|
1442
|
-
function sessionActionError(error) {
|
|
1516
|
+
function sessionActionError(error, action) {
|
|
1443
1517
|
if (error instanceof AutoContinueUnavailableError) return error.message.replace(/[\r\n\x00-\x1f]/g, ' ').slice(0, 600);
|
|
1444
1518
|
const message = String(error?.message || "");
|
|
1445
1519
|
if (/already has a Delivery Loop|budgets cannot be reset/i.test(message)) return "This conversation already has a task. Open its existing supervision instead.";
|
|
@@ -1447,6 +1521,7 @@ function sessionActionError(error) {
|
|
|
1447
1521
|
if (/workspace changed|metadata.*unavailable|session.*changed/i.test(message)) return "The conversation identity or workspace changed. Refresh and select it again.";
|
|
1448
1522
|
if (/objective|acceptance|autonomy policy|budget/i.test(message)) return message.replace(/[\r\n\x00-\x1f]/g, " ").slice(0, 240);
|
|
1449
1523
|
if (/question changed|answer is empty|session understanding|context/i.test(message)) return message.replace(/[\r\n\x00-\x1f]/g, " ").slice(0, 240);
|
|
1524
|
+
if (action === "session_answer") return "Answer acceptance is unconfirmed. Check the original conversation before sending again.";
|
|
1450
1525
|
return "This conversation could not be enrolled. Refresh its provider status and try again.";
|
|
1451
1526
|
}
|
|
1452
1527
|
|
package/src/desktop-status.js
CHANGED
|
@@ -231,7 +231,7 @@ export function isUnauthorizedError(error) {
|
|
|
231
231
|
|
|
232
232
|
export function canStopDesktopTask(task, registryIndex) {
|
|
233
233
|
const state = task?.status || task?.snapshot?.state || task?.state || task?.control_state || task?.controlState || "unknown";
|
|
234
|
-
return registryIndex === 0 ? !INACTIVE_STATES.has(state) : ["queued", "running", "stopping"].includes(state);
|
|
234
|
+
return registryIndex === 0 ? !INACTIVE_STATES.has(state) : ["queued", "running", "stopping"].includes(state) || registryIndex === 1 && state === "recovery_required" && Boolean(task?.worker_handle);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
function taskStatusItem(task, registryIndex) {
|
package/src/direct-executor.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { atomicSave, digest } from './update-detachment/state-store.js';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { captureDispatchProvenance } from './update-detachment/provenance.js';
|
|
1
4
|
import { signalExecutorProcess } from "./runner.js";
|
|
5
|
+
import { createExecutionWorker } from "./update-detachment/worker.js";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, observeDispatchEvent, queueDispatchReceipt } from './update-detachment/dispatch-gate.js';
|
|
2
8
|
import { randomUUID } from "node:crypto";
|
|
3
9
|
import { withSceneReasoning } from "./codex-plugins/simy/hooks/scene-reasoning.mjs";
|
|
4
10
|
|
|
@@ -58,10 +64,15 @@ export function createDirectTask({
|
|
|
58
64
|
|
|
59
65
|
export async function startDirectTask(
|
|
60
66
|
task,
|
|
61
|
-
{ resolveCommand = resolveDesktopExecutorCommand, environment = process.env } = {},
|
|
67
|
+
{ resolveCommand = resolveDesktopExecutorCommand, environment = process.env, session, apiOrigin, sessionRoot, worker = null, workerBinding = null, deadlineAt = null, workerPolicy = null } = {},
|
|
62
68
|
) {
|
|
63
69
|
if (!task || task.status !== "queued") throw new Error("Direct task must be queued.");
|
|
64
70
|
if (task.invocation_count >= 1) throw new Error("Direct tasks allow only one Provider invocation.");
|
|
71
|
+
const dispatchOwner = captureDispatchOwner(workerBinding?.root || sessionRoot);
|
|
72
|
+
const dispatchAttemptId = `${task.id}:provider:1`;
|
|
73
|
+
const recordRef = join(dispatchOwner.root, 'direct-tasks', `${digest(task.id)}.json`);
|
|
74
|
+
const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: task.backend, worktree: task.repository_path, accountSession: session, origin: apiOrigin, taskId: task.id, recordRef });
|
|
75
|
+
assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
|
|
65
76
|
const command = await resolveCommand({
|
|
66
77
|
backend: task.backend,
|
|
67
78
|
instruction: withSceneReasoning("Complete only the requested bounded direct task in this invocation. Respect the assigned permission mode; do not create an automatic retry loop or expand the task scope.", task.instruction),
|
|
@@ -75,17 +86,44 @@ export async function startDirectTask(
|
|
|
75
86
|
task.invocation_count = 1;
|
|
76
87
|
task.updated_at = new Date().toISOString();
|
|
77
88
|
|
|
89
|
+
if (worker || (resolveCommand === resolveDesktopExecutorCommand && process.platform !== "win32")) {
|
|
90
|
+
const owner = worker || createExecutionWorker();
|
|
91
|
+
const identity = owner.identity || workerBinding || {
|
|
92
|
+
provider: task.backend, session: task.id,
|
|
93
|
+
// Standalone direct invocations inherit the existing account environment.
|
|
94
|
+
// This is not a verified existing app-server/live-hook account binding.
|
|
95
|
+
account: "inherited-provider-environment", origin: "simy-direct",
|
|
96
|
+
worktree: path.resolve(task.repository_path),
|
|
97
|
+
};
|
|
98
|
+
task.worker = owner;
|
|
99
|
+
task.worker_handle = { attemptId: task.id, identity };
|
|
100
|
+
const originalDeadline = deadlineAt ?? null;
|
|
101
|
+
task.worker_start = owner.start({ ...task.worker_handle, command, cwd: task.repository_path, backend: task.backend,
|
|
102
|
+
deadlineAt: originalDeadline, taskId: task.id,
|
|
103
|
+
preservation: { taskState: "active", metadata: directTaskSnapshot(task), policy: workerPolicy || { model: "provider-default", permissions: task.permission_mode, approvals: { operation: task.operation, maxProviderInvocations: 1 }, budget: null, deadline: originalDeadline }, artifacts: [] } });
|
|
104
|
+
try { task.worker_handle = { ...task.worker_handle, ...await task.worker_start }; }
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error.workerHandle) task.worker_handle = error.workerHandle;
|
|
107
|
+
task.status = "recovery_required";
|
|
108
|
+
task.error = "Worker launch outcome could not be confirmed. Reconcile saved evidence before retrying.";
|
|
109
|
+
return task;
|
|
110
|
+
}
|
|
111
|
+
if (task.stop_requested) await owner.stop(task.worker_handle);
|
|
112
|
+
return await reconcileDirectWorker(task);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
atomicSave(recordRef, { schemaVersion: 1, revision: 0, ...directTaskSnapshot(task) });
|
|
78
116
|
return new Promise((resolve) => {
|
|
79
117
|
let settled = false;
|
|
80
118
|
let terminalError = null;
|
|
81
119
|
const rawStdout = [];
|
|
82
120
|
const tokenRecords = [];
|
|
83
|
-
const child = command.spawn(command.bin, command.args, {
|
|
121
|
+
const child = dispatchWithFence(dispatchOwner, { taskId: task.id, attemptId: dispatchAttemptId, provenance }, () => command.spawn(command.bin, command.args, {
|
|
84
122
|
cwd: task.repository_path,
|
|
85
123
|
env: { ...process.env, ...command.env },
|
|
86
124
|
stdio: ["ignore", "pipe", "pipe"],
|
|
87
125
|
detached: process.platform !== "win32",
|
|
88
|
-
});
|
|
126
|
+
}));
|
|
89
127
|
task.child = child;
|
|
90
128
|
const onLine = (line) => {
|
|
91
129
|
const normalized = String(line || "").trim();
|
|
@@ -99,6 +137,7 @@ export async function startDirectTask(
|
|
|
99
137
|
const stdoutDecoder = createProviderStreamDecoder({
|
|
100
138
|
backend: task.backend,
|
|
101
139
|
stream: "stdout",
|
|
140
|
+
onEvent: event => { const identity = observeDispatchEvent({ root: dispatchOwner.root, attemptId: dispatchAttemptId, provider: task.backend, event, onError: error => { task.dispatch_recovery_required = error.code || 'provenance_persistence_failed'; } }); if (identity) task.dispatch_identity = identity; },
|
|
102
141
|
onLine,
|
|
103
142
|
onTerminalError: (message) => { terminalError ||= message; },
|
|
104
143
|
onUsage: (usage) => tokenRecords.push({ ...usage, phase: "direct", backend: task.backend }),
|
|
@@ -127,6 +166,13 @@ export async function startDirectTask(
|
|
|
127
166
|
task.error = task.stop_requested ? null : error || (exitCode !== 0 ? `Provider exited with code ${exitCode}.` : null);
|
|
128
167
|
task.result = task.output.at(-1) || rawStdout.join("").trim() || null;
|
|
129
168
|
task.updated_at = new Date().toISOString();
|
|
169
|
+
try {
|
|
170
|
+
atomicSave(recordRef, { schemaVersion: 1, revision: 1, ...directTaskSnapshot(task), ...(task.dispatch_recovery_required ? { dispatch_recovery_required: task.dispatch_recovery_required } : {}), ...(task.dispatch_identity ? { dispatch_identity: task.dispatch_identity } : {}) });
|
|
171
|
+
queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `result:${dispatchAttemptId}`, resultRef: recordRef });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
task.dispatch_recovery_required = error.code || 'provenance_persistence_failed';
|
|
174
|
+
try { atomicSave(recordRef, { schemaVersion: 1, revision: 1, ...directTaskSnapshot(task), ...(task.dispatch_identity ? { dispatch_identity: task.dispatch_identity } : {}) }); } catch {}
|
|
175
|
+
}
|
|
130
176
|
resolve(task);
|
|
131
177
|
};
|
|
132
178
|
child.on("error", (error) => finish({ error: error.message }));
|
|
@@ -137,6 +183,7 @@ export async function startDirectTask(
|
|
|
137
183
|
export function directTaskSnapshot(task) {
|
|
138
184
|
return {
|
|
139
185
|
id: task.id,
|
|
186
|
+
...(task.dispatch_recovery_required ? { dispatch_recovery_required: task.dispatch_recovery_required } : {}),
|
|
140
187
|
status: task.status,
|
|
141
188
|
instruction: task.instruction,
|
|
142
189
|
backend: task.backend,
|
|
@@ -153,15 +200,50 @@ export function directTaskSnapshot(task) {
|
|
|
153
200
|
};
|
|
154
201
|
}
|
|
155
202
|
|
|
203
|
+
// Read-only reconnect: never starts a second Provider invocation.
|
|
204
|
+
export async function reconcileDirectWorker(task, { worker = task.worker || createExecutionWorker() } = {}) {
|
|
205
|
+
while (true) {
|
|
206
|
+
if (task.observation_suspended) return task;
|
|
207
|
+
const state = await worker.result(task.worker_handle);
|
|
208
|
+
if (task.observation_suspended) return task;
|
|
209
|
+
if (state.state === "completed") {
|
|
210
|
+
const receipt = state.receipt;
|
|
211
|
+
Object.assign(task, {
|
|
212
|
+
status: receipt.status, output: receipt.output, result: receipt.result,
|
|
213
|
+
error: receipt.error, token_usage: receipt.token_usage,
|
|
214
|
+
updated_at: new Date(receipt.finishedAt).toISOString(),
|
|
215
|
+
});
|
|
216
|
+
return task;
|
|
217
|
+
}
|
|
218
|
+
if (state.state === "recovery_required") {
|
|
219
|
+
task.status = "recovery_required";
|
|
220
|
+
task.error = state.storageError
|
|
221
|
+
? "Worker receipt storage failed. The confirmed owner remains stoppable; review saved evidence before retrying."
|
|
222
|
+
: "Worker owner could not be confirmed. Review saved evidence before retrying.";
|
|
223
|
+
return task;
|
|
224
|
+
}
|
|
225
|
+
task.output = state.output || task.output;
|
|
226
|
+
task.token_usage = state.token_usage || task.token_usage;
|
|
227
|
+
task.updated_at = new Date().toISOString();
|
|
228
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
156
232
|
// Only the child captured by this task may be signalled, including its owned tools.
|
|
157
233
|
export function stopDirectTask(task) {
|
|
158
234
|
if (!task) return Promise.resolve(task);
|
|
159
235
|
return task.stopPromise ||= stopOwnedDirectTask(task);
|
|
160
236
|
}
|
|
161
237
|
async function stopOwnedDirectTask(task) {
|
|
162
|
-
if (!task || !["queued", "running", "stopping"].includes(task.status)) return task;
|
|
238
|
+
if (!task || (!["queued", "running", "stopping"].includes(task.status) && !(task.status === "recovery_required" && task.worker_handle))) return task;
|
|
163
239
|
task.stop_requested = true;
|
|
164
240
|
task.status = "stopping";
|
|
241
|
+
if (task.worker_handle) {
|
|
242
|
+
// Launch uncertainty does not prevent stopping a separately confirmed owner.
|
|
243
|
+
await task.worker_start?.catch(() => {});
|
|
244
|
+
await task.worker.stop(task.worker_handle);
|
|
245
|
+
return reconcileDirectWorker(task);
|
|
246
|
+
}
|
|
165
247
|
const child = task.child;
|
|
166
248
|
if (!child) { task.status = "stopped"; task.updated_at = new Date().toISOString(); return task; }
|
|
167
249
|
for (const signal of ["SIGINT", "SIGTERM", "SIGKILL"]) {
|
|
@@ -176,3 +258,21 @@ async function stopOwnedDirectTask(task) {
|
|
|
176
258
|
if (task.child === child) throw new Error("The local task did not stop. Try quitting SIMY.");
|
|
177
259
|
return task;
|
|
178
260
|
}
|
|
261
|
+
|
|
262
|
+
// The host reconstructs its registry from the registered preservation record,
|
|
263
|
+
// never from a new Provider dispatch or a user-supplied unbound task ID.
|
|
264
|
+
export async function restoreDirectWorkerTask({ worker, handle, registry }) {
|
|
265
|
+
const state = await worker.adopt(handle);
|
|
266
|
+
const snapshot = worker.readSnapshot(handle);
|
|
267
|
+
if (snapshot?.task?.id !== handle.taskId || snapshot.metadata?.id !== handle.taskId) throw new Error("Direct worker task snapshot unavailable.");
|
|
268
|
+
let task = registry.get(handle.taskId);
|
|
269
|
+
if (!task) task = registry.create({ ...structuredClone(snapshot.metadata), repository_path: handle.identity.worktree,
|
|
270
|
+
worker, worker_handle: handle, child: null, invocation_count: 1,
|
|
271
|
+
stop_requested: snapshot.task.stopRequested || snapshot.task.state === "stopped" || state.state === "stopping" });
|
|
272
|
+
if (state.state === "completed") return reconcileDirectWorker(task, { worker });
|
|
273
|
+
task.stop_requested ||= snapshot.task.stopRequested || state.state === "stopping";
|
|
274
|
+
task.status = task.stop_requested ? "stopping" : state.state === "running" ? "running" : "recovery_required";
|
|
275
|
+
task.output = state.output || task.output;
|
|
276
|
+
task.token_usage = state.token_usage || task.token_usage;
|
|
277
|
+
return task;
|
|
278
|
+
}
|