@awak-app/simy-cli 0.5.35 → 0.5.37
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/package.json +1 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +248 -32
- package/src/codex-plugins/simy/hooks/workspace-lease.mjs +71 -0
- package/src/delivery-hooks/command.js +15 -2
- package/src/delivery-hooks/desktop.js +12 -2
- package/src/delivery-hooks/supervisor.js +119 -12
- package/src/desktop-host.js +4 -4
- package/src/session-supervision/codex-app-server.js +34 -3
- package/src/session-supervision/handoff.js +124 -0
- package/src/session-supervision/index.js +3 -2
- package/src/session-supervision/workspace-lease.js +2 -0
- package/src/sqm/claude-plugin.js +1 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { startDelivery, checkpointDelivery, controlDelivery, listRecords, rootDirectory, verifyDelivery } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
|
+
import { continueSession } from '../session-supervision/index.js';
|
|
4
|
+
import { cancelHandoffSession, handoffSession } from '../session-supervision/handoff.js';
|
|
3
5
|
|
|
4
6
|
async function readCheckpointInput(input) {
|
|
5
7
|
const chunks = [];
|
|
@@ -16,11 +18,12 @@ async function readCheckpointInput(input) {
|
|
|
16
18
|
export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), input = process.stdin, output = console.log } = {}) {
|
|
17
19
|
const [action, ...args] = argv;
|
|
18
20
|
if (!action || action === '--help') {
|
|
21
|
+
output('simy delivery continue --provider codex|claude --session ID [--max-no-progress-minutes 30] [--max-tokens N (Codex only)]\nsimy delivery dependency_ready --provider PROVIDER --session ID --task-id ID --wait-id ID --answer TEXT\nsimy delivery handoff|handoff_cancel --provider codex --session ID --task-id ID --target-session ID');
|
|
19
22
|
output('simy delivery start --provider codex|claude --session ID --objective TEXT --next-step TEXT [--managed-file task.json] [--max-continuations 6] [--max-failures 3] [--max-minutes 30]\nsimy delivery checkpoint --provider codex|claude --session ID --file checkpoint.json|-\nsimy delivery status\nsimy delivery stop|pause|resume --provider codex|claude --session ID [--task-id ID]\nsimy delivery replan --provider PROVIDER --session ID --task-id ID\nsimy delivery approve|reject --provider PROVIDER --session ID --task-id ID --approval-id ID\nsimy delivery verify --provider PROVIDER --session ID --task-id ID --criterion-id ID\nsimy delivery set_policy --provider PROVIDER --session ID --task-id ID --autonomy-policy strict|balanced|autonomous\nEnable and review SIMY hooks in your provider first. Manual managed start requires an acceptance specification. Desktop Continue infers the original conversation goal first. Resume preserves original budgets.');
|
|
20
23
|
return 0;
|
|
21
24
|
}
|
|
22
25
|
const options = {};
|
|
23
|
-
const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-continuations', 'max-failures', 'max-minutes', 'file', 'managed-file', 'task-id', 'approval-id', 'criterion-id', 'autonomy-policy']);
|
|
26
|
+
const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-continuations', 'max-failures', 'max-minutes', 'max-no-progress-minutes', 'max-tokens', 'file', 'managed-file', 'task-id', 'approval-id', 'criterion-id', 'autonomy-policy', 'wait-id', 'answer', 'target-session']);
|
|
24
27
|
for (let i = 0; i < args.length; i += 2) {
|
|
25
28
|
const key = args[i]?.replace(/^--/, '');
|
|
26
29
|
if (!args[i]?.startsWith('--') || !allowed.has(key) || args[i + 1] === undefined || Object.hasOwn(options, key)) throw new Error('Invalid delivery option. Use simy delivery --help.');
|
|
@@ -29,6 +32,11 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
|
|
|
29
32
|
const base = { root, cwd, provider: options.provider, session: options.session };
|
|
30
33
|
let result;
|
|
31
34
|
if (action === 'status') result = (await listRecords(root)).filter(r => (!options.provider || r.provider === options.provider) && (!options.session || r.session === options.session));
|
|
35
|
+
else if (action === 'continue') result = await continueSession({ ...base, autonomyPolicy: options['autonomy-policy'],
|
|
36
|
+
...Object.fromEntries([['max-continuations', 'maxContinuations'], ['max-failures', 'maxFailures'], ['max-minutes', 'maxMinutes'],
|
|
37
|
+
['max-no-progress-minutes', 'maxNoProgressMinutes'], ['max-tokens', 'maxTokens']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
|
|
38
|
+
else if (action === 'handoff') result = await handoffSession({ ...base, taskId: options['task-id'], targetSession: options['target-session'] });
|
|
39
|
+
else if (action === 'handoff_cancel') result = await cancelHandoffSession({ ...base, taskId: options['task-id'], targetSession: options['target-session'] });
|
|
32
40
|
else if (action === 'start') {
|
|
33
41
|
let managed;
|
|
34
42
|
if (options['managed-file']) {
|
|
@@ -36,6 +44,11 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
|
|
|
36
44
|
if (Buffer.byteLength(data) > 64 * 1024) throw new Error('Managed task specification exceeds 64 KiB.');
|
|
37
45
|
managed = JSON.parse(data);
|
|
38
46
|
}
|
|
47
|
+
if (options['max-tokens'] !== undefined || options['max-no-progress-minutes'] !== undefined) {
|
|
48
|
+
if (!managed || typeof managed !== 'object') throw new Error('Managed budget flags require --managed-file; use delivery continue for context-first supervision.');
|
|
49
|
+
managed = { ...managed, ...Object.fromEntries([['max-tokens', 'maxTokens'], ['max-no-progress-minutes', 'maxNoProgressMinutes']]
|
|
50
|
+
.filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) };
|
|
51
|
+
}
|
|
39
52
|
result = await startDelivery({ ...base, objective: options.objective, nextStep: options['next-step'], managed,
|
|
40
53
|
...Object.fromEntries([['max-continuations', 'maxContinuations'], ['max-failures', 'maxFailures'], ['max-minutes', 'maxMinutes']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
|
|
41
54
|
}
|
|
@@ -44,7 +57,7 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
|
|
|
44
57
|
const data = options.file === '-' ? await readCheckpointInput(input) : await readFile(options.file, 'utf8');
|
|
45
58
|
if (Buffer.byteLength(data) > 64 * 1024) throw new Error('Checkpoint exceeds 64 KiB.');
|
|
46
59
|
result = await checkpointDelivery({ ...base, checkpoint: JSON.parse(data) });
|
|
47
|
-
} else if (['stop', 'pause', 'resume', 'replan', 'approve', 'reject', 'set_policy'].includes(action)) result = await controlDelivery({ ...base, taskId: options['task-id'], approvalId: options['approval-id'], autonomyPolicy: options['autonomy-policy'], action });
|
|
60
|
+
} else if (['stop', 'pause', 'resume', 'replan', 'approve', 'reject', 'set_policy', 'dependency_ready'].includes(action)) result = await controlDelivery({ ...base, taskId: options['task-id'], approvalId: options['approval-id'], autonomyPolicy: options['autonomy-policy'], waitId: options['wait-id'], answer: options.answer, action });
|
|
48
61
|
else if (action === 'verify') result = await verifyDelivery({ ...base, taskId: options['task-id'], criterionId: options['criterion-id'] });
|
|
49
62
|
else throw new Error('Unknown delivery command.');
|
|
50
63
|
output(JSON.stringify(result, null, 2));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
-
import { canReplanDelivery, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
|
+
import { canReplanDelivery, managedBudgetReason, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
4
4
|
import { originalSessionTarget } from '../session-supervision/original-session-target.js';
|
|
5
5
|
|
|
6
6
|
export const HOOK_SETUP = Object.freeze({
|
|
@@ -27,6 +27,13 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
|
|
|
27
27
|
phase: r.run.managed?.phase || 'active', sessionUnderstanding: r.run.managed?.sessionUnderstanding || null,
|
|
28
28
|
understandingQuestion: r.run.managed?.understandingQuestion || null,
|
|
29
29
|
remainingWork: r.run.managed?.remainingWork || null, nextStep: r.run.nextStep,
|
|
30
|
+
dependencyWait: r.run.managed?.dependencyWait || null,
|
|
31
|
+
executionAttempt: r.run.managed?.executionAttempt || null, executionUncertain: r.run.managed?.executionUncertain || null,
|
|
32
|
+
handoff: r.run.managed?.handoff || null, threadHistory: r.run.managed?.threadHistory || [],
|
|
33
|
+
progressBudget: r.run.managed ? { minutes: r.run.managed.maxNoProgressMinutes, lastVerifiedProgressAt: r.run.managed.lastVerifiedProgressAt,
|
|
34
|
+
attempts: r.run.managed.noProgress, maxAttempts: r.run.managed.maxNoProgress } : null,
|
|
35
|
+
tokenBudget: r.run.managed?.maxTokens ? { limit: r.run.managed.maxTokens, used: r.run.managed.tokensUsed,
|
|
36
|
+
usageAvailable: r.run.managed.usageAvailable, source: 'provider_observed', dollarCost: null } : null,
|
|
30
37
|
pendingApproval: r.run.managed?.pendingApproval || null,
|
|
31
38
|
autonomyPolicy: r.run.managed?.autonomyPolicy || null, plan: r.run.managed?.plan || null,
|
|
32
39
|
decision: r.run.managed?.decision || null, decisionHistory: r.run.managed?.decisionHistory || [],
|
|
@@ -45,12 +52,15 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
|
|
|
45
52
|
r.run.state === 'recovery_required' && ['session_supervision', 'approved_action'].includes(r.run.recoveryCause) &&
|
|
46
53
|
r.run.managed.resumeChannel === 'codex_app_server' && r.run.managed.autoRecovery),
|
|
47
54
|
canResume: ['blocked', 'recovery_required', 'paused'].includes(r.run.state) && r.run.managed?.phase !== 'needs_clarification' &&
|
|
48
|
-
!r.run.managed?.pendingApproval &&
|
|
55
|
+
!r.run.managed?.pendingApproval && !r.run.managed?.dependencyWait && !['pending', 'pending_received', 'transferred'].includes(r.run.managed?.handoff?.status) &&
|
|
56
|
+
!managedBudgetReason(r.run, now) && now < r.run.deadline && r.run.continuations < r.run.maxContinuations,
|
|
49
57
|
canApprove: r.run.state === 'blocked' && Boolean(r.run.managed?.pendingApproval) && !r.run.managed.pendingApproval.denied,
|
|
50
58
|
canReject: r.run.state === 'blocked' && Boolean(r.run.managed?.pendingApproval) && !r.run.managed.pendingApproval.denied,
|
|
51
59
|
canReplan: canReplanDelivery(r.run, r.provider, now),
|
|
52
60
|
canAnswer: r.run.managed?.phase === 'needs_clarification' && r.run.state === 'blocked' && Boolean(r.run.managed.understandingQuestion?.id) &&
|
|
53
61
|
now < r.run.deadline && r.run.continuations < r.run.maxContinuations && r.run.failures < r.run.maxFailures,
|
|
62
|
+
canResolveDependency: r.run.state === 'blocked' && r.run.blocker === 'external_dependency' && Boolean(r.run.managed?.dependencyWait?.id) &&
|
|
63
|
+
!managedBudgetReason(r.run, now) && !['pending', 'pending_received', 'transferred'].includes(r.run.managed?.handoff?.status),
|
|
54
64
|
canSetPolicy: Boolean(r.run.managed?.autonomyPolicy) && !['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(r.run.state),
|
|
55
65
|
})),
|
|
56
66
|
};
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
|
-
import { assessedCheckpointContract, checkpointDelivery, decideStop, listRecords, notifyState, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
4
|
+
import { assessedCheckpointContract, checkpointDelivery, decideStop, listRecords, managedBudgetReason, notifyState, processIdentity, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
5
5
|
import { resolveBackendExecutable } from '../backend-executable.js';
|
|
6
6
|
import { executeCodexAppServer } from '../session-supervision/codex-app-server.js';
|
|
7
|
+
import { acquireWorkspaceLease, leaseOwnerDead } from '../session-supervision/workspace-lease.js';
|
|
7
8
|
|
|
8
9
|
const MAX_OUTPUT = 64 * 1024;
|
|
9
10
|
const SAFE_PROVIDER_CODES = new Set(['identity', 'permission_narrowed', 'permission_restore', 'turn_failed', 'checkpoint',
|
|
10
|
-
'closed', 'timeout', 'unsupported', 'unavailable', 'manual_provider_action', 'checkpoint_rejected', 'provider_approval']);
|
|
11
|
+
'closed', 'timeout', 'unsupported', 'unavailable', 'manual_provider_action', 'checkpoint_rejected', 'provider_approval', 'usage_unavailable']);
|
|
11
12
|
function safeProviderCode(code) { return SAFE_PROVIDER_CODES.has(code) ? code : 'provider_error'; }
|
|
12
13
|
function providerFailureReason(code) {
|
|
13
14
|
if (code === 'permission_restore') return 'Codex sandbox restoration could not be confirmed; inspect the original conversation before resuming.';
|
|
@@ -24,6 +25,7 @@ async function assertActiveLease(root, provider, session, leaseId, signal, curre
|
|
|
24
25
|
throw new Error('Managed recovery lease changed before provider dispatch.');
|
|
25
26
|
}
|
|
26
27
|
if (currentTime() >= current.run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
|
|
28
|
+
if (managedBudgetReason(current.run, currentTime())) throw new Error('Managed progress budget expired before provider dispatch.');
|
|
27
29
|
const managed = current.run.managed;
|
|
28
30
|
const approvedWait = managed.decision?.outcome === 'wait' && managed.approvedAction?.id === managed.decision.id && !managed.approvedAction.dispatchedAt;
|
|
29
31
|
if (managed.autonomyPolicy && ((managed.decision?.outcome === 'wait' && !approvedWait) || managed.resumeLease.decisionId !== managed.decision?.id)) throw new Error('Assessed action changed before provider dispatch.');
|
|
@@ -51,13 +53,14 @@ export function parseAgentCheckpoint(output, expectedSession) {
|
|
|
51
53
|
|
|
52
54
|
// The provider CLI is a new process. It is used only for an exact Codex UUID
|
|
53
55
|
// enrolled for this recovery channel after a corroborated process exit.
|
|
54
|
-
export async function executeCodexResume(record, { signal, root, leaseId, executable = resolveBackendExecutable, currentTime = Date.now } = {}) {
|
|
56
|
+
export async function executeCodexResume(record, { signal, root, leaseId, executable = resolveBackendExecutable, currentTime = Date.now, onActivity = () => {} } = {}) {
|
|
55
57
|
if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
|
|
56
58
|
const binary = await executable('codex');
|
|
57
59
|
if (!binary) throw new Error('Codex executable unavailable.');
|
|
58
60
|
await assertActiveLease(root, record.provider, record.session, leaseId, signal, currentTime);
|
|
59
61
|
const run = record.run;
|
|
60
|
-
|
|
62
|
+
const context = { taskId: run.managed.taskId, session: record.session, objective: run.objective, nextStep: run.nextStep,
|
|
63
|
+
lastDependencyResolution: run.managed.lastDependencyResolution,
|
|
61
64
|
remainingWork: run.managed.remainingWork, acceptanceCriteria: run.managed.acceptanceCriteria, proposedAction: run.managed.proposedAction,
|
|
62
65
|
currentDecision: run.managed.autonomyPolicy ? run.managed.decision : undefined, diagnosis: run.managed.plan?.diagnosis,
|
|
63
66
|
budgets: { continuations: run.continuations, maxContinuations: run.maxContinuations, failures: run.failures, maxFailures: run.maxFailures, deadline: run.deadline } };
|
|
@@ -95,6 +98,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
|
|
|
95
98
|
}, 500);
|
|
96
99
|
watch.unref();
|
|
97
100
|
child.stdout.on('data', chunk => {
|
|
101
|
+
onActivity();
|
|
98
102
|
const lines = (pending + chunk).split('\n');
|
|
99
103
|
pending = lines.pop().slice(-MAX_OUTPUT);
|
|
100
104
|
for (const line of lines) consumeLine(line);
|
|
@@ -111,7 +115,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
|
|
|
111
115
|
});
|
|
112
116
|
}
|
|
113
117
|
|
|
114
|
-
export async function tickManagedTasks({ root, now = Date.now(), execute, inspectProcess, signal } = {}) {
|
|
118
|
+
export async function tickManagedTasks({ root, now = Date.now(), execute, inspectProcess = processIdentity, signal } = {}) {
|
|
115
119
|
const tickWallStart = Date.now();
|
|
116
120
|
const currentTime = () => now + Math.max(0, Date.now() - tickWallStart);
|
|
117
121
|
if (signal?.aborted) return;
|
|
@@ -123,11 +127,18 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
123
127
|
const claimNow = currentTime();
|
|
124
128
|
const run = record.run;
|
|
125
129
|
if (!run?.managed) continue;
|
|
126
|
-
if (run.state === 'auto_resuming'
|
|
130
|
+
if (run.state === 'auto_resuming') {
|
|
131
|
+
const owner = run.managed.resumeLease?.owner;
|
|
132
|
+
const ownerDead = await leaseOwnerDead(owner, inspectProcess);
|
|
133
|
+
// A live/unknown owner is never taken over merely because time passed.
|
|
134
|
+
if (!ownerDead && (owner || claimNow - run.managed.resumeLease?.claimedAt <= 5 * 60_000)) continue;
|
|
127
135
|
await updateRecord(root, record.provider, record.session, current => {
|
|
128
136
|
if (current?.run?.state !== 'auto_resuming' || current.run.managed.resumeLease?.id !== run.managed.resumeLease?.id) return current;
|
|
129
137
|
current.run.state = 'recovery_required'; current.run.managed.autoRecovery = false;
|
|
130
138
|
current.run.reason = 'Managed recovery was interrupted; inspect the original session before manual resume.';
|
|
139
|
+
const attempt = current.run.managed.executionAttempt;
|
|
140
|
+
if (attempt?.phase === 'dispatching' && attempt.action?.kind !== 'read') current.run.managed.executionUncertain = {
|
|
141
|
+
attemptId: attempt.id, decisionId: attempt.decisionId, action: attempt.action, at: claimNow };
|
|
131
142
|
current.run.updatedAt = claimNow; notifyState(current.run, claimNow); return current;
|
|
132
143
|
});
|
|
133
144
|
continue;
|
|
@@ -136,8 +147,11 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
136
147
|
const causeAllowed = run.recoveryCause === 'process_exit' || appServer && ['session_supervision', 'approved_action'].includes(run.recoveryCause);
|
|
137
148
|
const approvedWait = run.managed.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(run.managed.decision.action?.kind) &&
|
|
138
149
|
run.managed.approvedAction?.id === run.managed.decision.id && !run.managed.approvedAction.dispatchedAt;
|
|
150
|
+
if (run.managed.executionUncertain && run.managed.recoveryDiagnosisDecisionId !== run.managed.decision?.id) continue;
|
|
151
|
+
if (['pending', 'transferred', 'pending_received'].includes(run.managed.handoff?.status)) continue;
|
|
139
152
|
if (run.state !== 'recovery_required' || !causeAllowed || !run.managed.autoRecovery || run.managed.pendingApproval || run.managed.autonomyPolicy && run.managed.decision?.outcome === 'wait' && !approvedWait || appServer && run.managed.nextAttemptAt > claimNow) continue;
|
|
140
153
|
const leaseId = randomUUID();
|
|
154
|
+
const ownerIdentity = await inspectProcess(process.pid) || `pid:${process.pid}`;
|
|
141
155
|
let claimed;
|
|
142
156
|
try { claimed = await updateRecord(root, record.provider, record.session, current => {
|
|
143
157
|
const live = current?.run;
|
|
@@ -146,27 +160,39 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
146
160
|
const approved = live?.managed?.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(live.managed.decision.action?.kind) &&
|
|
147
161
|
live.managed.approvedAction?.id === live.managed.decision.id && !live.managed.approvedAction.dispatchedAt;
|
|
148
162
|
if (!live?.managed?.autoRecovery || live.state !== 'recovery_required' || !allowedCause || live.managed.pendingApproval || live.managed.autonomyPolicy && (!live.managed.decision || live.managed.decision.outcome === 'wait' && !approved) || appChannel && live.managed.nextAttemptAt > currentTime()) return current;
|
|
149
|
-
if (currentTime() >= live.deadline || live.continuations >= live.maxContinuations || live.failures >= live.maxFailures) {
|
|
163
|
+
if (managedBudgetReason(live, currentTime()) || currentTime() >= live.deadline || live.continuations >= live.maxContinuations || live.failures >= live.maxFailures) {
|
|
150
164
|
live.state = 'budget_exhausted'; live.reason = 'Original managed continuation budget exhausted.'; live.updatedAt = currentTime(); notifyState(live, live.updatedAt); return current;
|
|
151
165
|
}
|
|
152
|
-
live.state = 'auto_resuming'; live.managed.resumeLease = { id: leaseId, claimedAt: currentTime(), checkpointRevision: live.revision, decisionId: live.managed.decision?.id || null
|
|
166
|
+
live.state = 'auto_resuming'; live.managed.resumeLease = { id: leaseId, claimedAt: currentTime(), checkpointRevision: live.revision, decisionId: live.managed.decision?.id || null,
|
|
167
|
+
owner: { pid: process.pid, identity: ownerIdentity } };
|
|
168
|
+
live.managed.executionAttempt = { id: leaseId, decisionId: live.managed.decision?.id || null,
|
|
169
|
+
action: live.managed.decision?.action || live.managed.proposedAction, phase: 'claimed', claimedAt: currentTime(), lastActivityAt: currentTime() };
|
|
153
170
|
live.managed.autoAttempts = (live.managed.autoAttempts || 0) + 1;
|
|
154
171
|
live.updatedAt = currentTime(); return current;
|
|
155
172
|
}); } catch (error) {
|
|
156
|
-
|
|
173
|
+
// mkdir can become visible before its owner receipt is written. Treat an
|
|
174
|
+
// ambiguous owner as contention, never as permission to reclaim it.
|
|
175
|
+
if (error?.message === 'Delivery state is busy; retry the command.' ||
|
|
176
|
+
error?.message === 'Delivery state lock owner is unavailable; inspect the lock before retrying.') continue;
|
|
157
177
|
throw error;
|
|
158
178
|
}
|
|
159
179
|
if (claimed?.run?.managed?.resumeLease?.id !== leaseId) continue;
|
|
160
180
|
let result;
|
|
181
|
+
let resource;
|
|
182
|
+
let lastActivityAt = currentTime();
|
|
183
|
+
let usage = null;
|
|
184
|
+
let tokenBudgetExceeded = false;
|
|
185
|
+
let dispatched = false, waitingForTool = false;
|
|
161
186
|
const controller = new AbortController();
|
|
162
187
|
const abort = () => controller.abort();
|
|
163
188
|
if (signal?.aborted) controller.abort();
|
|
164
189
|
else signal?.addEventListener('abort', abort, { once: true });
|
|
165
|
-
const timeout = setTimeout(abort, Math.max(1,
|
|
190
|
+
const timeout = setTimeout(abort, Math.max(1, claimed.run.deadline - currentTime()));
|
|
166
191
|
const watch = setInterval(() => {
|
|
167
192
|
void listRecords(root).then(records => {
|
|
168
193
|
const current = records.find(item => item.provider === record.provider && item.session === record.session);
|
|
169
|
-
if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId
|
|
194
|
+
if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId ||
|
|
195
|
+
managedBudgetReason(current.run, currentTime()) || !waitingForTool && currentTime() - lastActivityAt >= 5 * 60_000) controller.abort();
|
|
170
196
|
}).catch(() => controller.abort());
|
|
171
197
|
}, 500);
|
|
172
198
|
watch.unref();
|
|
@@ -174,8 +200,42 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
174
200
|
if (!controller.signal.aborted) await assertActiveLease(root, record.provider, record.session, leaseId, controller.signal, currentTime);
|
|
175
201
|
if (controller.signal.aborted) result = { code: -2, cancelled: true };
|
|
176
202
|
else {
|
|
203
|
+
resource = await acquireWorkspaceLease({ root, record: claimed, leaseId, inspectProcess, onRecover: async prior => {
|
|
204
|
+
await updateRecord(root, prior.provider, prior.session, previous => {
|
|
205
|
+
if (previous?.run?.managed?.taskId === prior.taskId && previous.run.managed.executionAttempt?.phase !== 'checkpointed') {
|
|
206
|
+
previous.run.managed.executionUncertain ||= { attemptId: prior.leaseId, decisionId: prior.decisionId, at: currentTime() };
|
|
207
|
+
previous.run.managed.autoRecovery = false;
|
|
208
|
+
}
|
|
209
|
+
return previous;
|
|
210
|
+
});
|
|
211
|
+
} });
|
|
212
|
+
if (resource.recoveredOwner) {
|
|
213
|
+
// The earlier writer may have changed files before its host crashed.
|
|
214
|
+
// Keep this workspace blocked until its original task is inspected.
|
|
215
|
+
await updateRecord(root, resource.recoveredOwner.provider, resource.recoveredOwner.session, previous => {
|
|
216
|
+
if (previous?.run?.managed?.taskId === resource.recoveredOwner.taskId && previous.run.managed.executionAttempt?.phase !== 'checkpointed') {
|
|
217
|
+
previous.run.managed.executionUncertain ||= { attemptId: resource.recoveredOwner.leaseId,
|
|
218
|
+
decisionId: resource.recoveredOwner.decisionId, at: currentTime() };
|
|
219
|
+
previous.run.managed.autoRecovery = false;
|
|
220
|
+
if (!['stopped', 'paused', 'completed', 'reported_done'].includes(previous.run.state)) previous.run.state = 'recovery_required';
|
|
221
|
+
previous.run.reason = 'An interrupted workspace writer needs read-only result reconciliation before more changes.';
|
|
222
|
+
}
|
|
223
|
+
return previous;
|
|
224
|
+
});
|
|
225
|
+
result = { code: -3, busy: true };
|
|
226
|
+
} else if (resource.busy) result = { code: -3, busy: true };
|
|
227
|
+
else if (['local_edit', 'local_test'].includes(claimed.run.managed.decision?.action.kind) &&
|
|
228
|
+
(await listRecords(root)).some(other => other.cwd === claimed.cwd && other.run?.managed?.executionUncertain)) result = { code: -3, busy: true };
|
|
229
|
+
if (!result) {
|
|
177
230
|
const runner = execute || (appServer ? executeCodexAppServer : executeCodexResume);
|
|
178
231
|
result = await runner(claimed, { signal: controller.signal, root, leaseId, currentTime,
|
|
232
|
+
onActivity: observation => { lastActivityAt = currentTime(); waitingForTool = observation?.waitingForTool === true; },
|
|
233
|
+
onUsage: observed => {
|
|
234
|
+
usage = observed;
|
|
235
|
+
if (claimed.run.managed.maxTokens && (claimed.run.managed.tokensUsed || 0) + observed.tokens >= claimed.run.managed.maxTokens) {
|
|
236
|
+
tokenBudgetExceeded = true; controller.abort();
|
|
237
|
+
}
|
|
238
|
+
},
|
|
179
239
|
beforeDispatch: async providerSandbox => {
|
|
180
240
|
await assertActiveLease(root, record.provider, record.session, leaseId, controller.signal, currentTime);
|
|
181
241
|
await updateRecord(root, record.provider, record.session, current => {
|
|
@@ -187,6 +247,10 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
187
247
|
...(providerSandbox.type === 'workspaceWrite' ? { writableRoots: Array.isArray(providerSandbox.writableRoots) ? providerSandbox.writableRoots.filter(value => typeof value === 'string').slice(0, 20) : [] } : {}) };
|
|
188
248
|
}
|
|
189
249
|
const approved = live.managed.approvedAction;
|
|
250
|
+
live.managed.executionAttempt.phase = 'dispatching';
|
|
251
|
+
live.managed.executionAttempt.dispatchedAt = currentTime();
|
|
252
|
+
live.managed.executionAttempt.lastActivityAt = currentTime();
|
|
253
|
+
dispatched = true;
|
|
190
254
|
if (live.managed.decision?.outcome === 'wait') {
|
|
191
255
|
if (approved?.id !== live.managed.decision.id || approved.dispatchedAt) throw new Error('Selected action needs fresh approval.');
|
|
192
256
|
approved.dispatchedAt = currentTime();
|
|
@@ -194,21 +258,51 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
194
258
|
return current;
|
|
195
259
|
});
|
|
196
260
|
} });
|
|
261
|
+
}
|
|
197
262
|
}
|
|
198
263
|
} catch (error) { result = { code: controller.signal.aborted ? -2 : -1, cancelled: controller.signal.aborted,
|
|
199
264
|
errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
|
|
200
265
|
finally { clearTimeout(timeout); clearInterval(watch); signal?.removeEventListener('abort', abort); }
|
|
201
266
|
if (controller.signal.aborted) result = { ...result, code: -2, cancelled: true };
|
|
267
|
+
if (claimed.run.managed.maxTokens && !usage && !result.busy && dispatched) {
|
|
268
|
+
result = { ...result, usageUnavailable: true };
|
|
269
|
+
}
|
|
202
270
|
if (result.code === 0) {
|
|
203
271
|
try {
|
|
204
272
|
await checkpointDelivery({ root, provider: record.provider, session: record.session, cwd: record.cwd,
|
|
205
273
|
checkpoint: result.checkpoint || parseAgentCheckpoint(result.output, record.session), expectedLease: leaseId, now: Date.now() });
|
|
274
|
+
await updateRecord(root, record.provider, record.session, current => {
|
|
275
|
+
if (current?.run?.managed?.executionAttempt?.id !== leaseId) return current;
|
|
276
|
+
current.run.managed.executionAttempt.phase = 'checkpointed';
|
|
277
|
+
current.run.managed.executionAttempt.checkpointRevision = current.run.revision;
|
|
278
|
+
if (claimed.run.managed.recoveryDiagnosisDecisionId === claimed.run.managed.decision?.id &&
|
|
279
|
+
current.run.managed.workHistory?.at(-1)?.result?.status === 'observed') {
|
|
280
|
+
// This accepts a read-only observation, never grants a retry of the
|
|
281
|
+
// old action. The newly assessed plan still passes the normal gate.
|
|
282
|
+
current.run.managed.reconciledExecutions ||= [];
|
|
283
|
+
current.run.managed.reconciledExecutions.push({ ...current.run.managed.executionUncertain, reconciledAt: Date.now(), revision: current.run.revision });
|
|
284
|
+
current.run.managed.reconciledExecutions = current.run.managed.reconciledExecutions.slice(-10);
|
|
285
|
+
delete current.run.managed.executionUncertain;
|
|
286
|
+
delete current.run.managed.recoveryDiagnosisDecisionId;
|
|
287
|
+
}
|
|
288
|
+
return current;
|
|
289
|
+
});
|
|
206
290
|
} catch { result = { code: -1, errorCode: 'checkpoint_rejected' }; }
|
|
207
291
|
}
|
|
208
292
|
await updateRecord(root, record.provider, record.session, async current => {
|
|
209
293
|
const live = current?.run;
|
|
210
294
|
if (live?.managed?.resumeLease?.id !== leaseId) return current;
|
|
211
295
|
delete live.managed.resumeLease;
|
|
296
|
+
if (usage) {
|
|
297
|
+
live.managed.tokensUsed = (live.managed.tokensUsed || 0) + usage.tokens;
|
|
298
|
+
live.managed.usageAvailable = true;
|
|
299
|
+
live.managed.lastUsage = { ...usage, attemptId: leaseId };
|
|
300
|
+
} else if (!result.busy && live.managed.maxTokens && dispatched) {
|
|
301
|
+
live.managed.usageAvailable = false; live.managed.usageBlocked = true;
|
|
302
|
+
}
|
|
303
|
+
const attempt = live.managed.executionAttempt;
|
|
304
|
+
if (!result.busy && attempt?.phase === 'dispatching' && attempt.action?.kind !== 'read') live.managed.executionUncertain = {
|
|
305
|
+
attemptId: leaseId, decisionId: attempt.decisionId, action: attempt.action, at: Date.now() };
|
|
212
306
|
live.managed.attempts ||= [];
|
|
213
307
|
live.managed.attempts.push({ id: leaseId, startedAt: claimed.run.managed.resumeLease.claimedAt, endedAt: Date.now(),
|
|
214
308
|
checkpointRevision: live.revision, outcome: result.busy ? 'busy' : result.cancelled ? 'cancelled' : result.code === 0 ? 'checkpoint' : 'failed',
|
|
@@ -222,9 +316,19 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
222
316
|
if (live.state !== 'auto_resuming') live.reason = `${String(live.reason || '').slice(0, 400)} ${live.managed.providerWarning}`.trim();
|
|
223
317
|
}
|
|
224
318
|
if (live.state !== 'auto_resuming') {
|
|
319
|
+
if (live.managed.usageBlocked && !['stopped', 'paused', 'completed', 'reported_done'].includes(live.state)) {
|
|
320
|
+
live.managed.autoRecovery = false;
|
|
321
|
+
live.reason = `${live.reason} Provider token usage is unavailable; further budgeted turns are blocked.`;
|
|
322
|
+
}
|
|
225
323
|
live.updatedAt = Date.now(); notifyState(live, live.updatedAt); return current;
|
|
226
324
|
}
|
|
227
|
-
if (
|
|
325
|
+
if (tokenBudgetExceeded || live.managed.maxTokens && live.managed.tokensUsed >= live.managed.maxTokens) {
|
|
326
|
+
live.state = 'budget_exhausted'; live.managed.autoRecovery = false;
|
|
327
|
+
live.reason = 'Observed provider token budget exhausted; inspect any interrupted action in the original conversation.';
|
|
328
|
+
} else if (live.managed.usageBlocked) {
|
|
329
|
+
live.state = 'recovery_required'; live.managed.autoRecovery = false;
|
|
330
|
+
live.reason = 'Provider usage was unavailable; token-budgeted continuation cannot safely start another turn.';
|
|
331
|
+
} else if (result.busy) {
|
|
228
332
|
live.state = 'recovery_required'; live.recoveryCause = 'session_supervision';
|
|
229
333
|
live.reason = 'Original Codex conversation is in use; SIMY will retry after the writer lock becomes available.';
|
|
230
334
|
live.managed.nextAttemptAt = Date.now() + 15_000;
|
|
@@ -264,6 +368,9 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
|
|
|
264
368
|
}
|
|
265
369
|
current.run.updatedAt = Date.now(); notifyState(current.run, current.run.updatedAt); return current;
|
|
266
370
|
});
|
|
371
|
+
// Settling uncertainty must precede unlocking: otherwise another session
|
|
372
|
+
// could start a write in the effect/checkpoint persistence gap.
|
|
373
|
+
await resource?.release({ unconfirmed: result.restorationUnverified === true });
|
|
267
374
|
}
|
|
268
375
|
}
|
|
269
376
|
|
package/src/desktop-host.js
CHANGED
|
@@ -233,9 +233,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
233
233
|
else if (action === "codex_hooks" || action === "claude_hooks") {
|
|
234
234
|
const provider = action === "codex_hooks" ? "codex" : "claude";
|
|
235
235
|
await (dependencies.openHookSettings || openHookSetup)(provider);
|
|
236
|
-
} else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy"].includes(action)) {
|
|
236
|
+
} else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy", "delivery_dependency_ready"].includes(action)) {
|
|
237
237
|
await controlDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
|
|
238
|
-
taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, action: action.slice("delivery_".length) });
|
|
238
|
+
taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, waitId: command.waitId, answer: command.answer, action: action.slice("delivery_".length) });
|
|
239
239
|
} else if (action === "delivery_verify") {
|
|
240
240
|
await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
|
|
241
241
|
taskId: command.taskId, criterionId: command.criterionId });
|
|
@@ -247,12 +247,12 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
247
247
|
await (dependencies.enrollSession || enrollSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
|
248
248
|
session: command.session, objective: command.objective, acceptanceCriteria: command.acceptanceCriteria,
|
|
249
249
|
autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
|
|
250
|
-
maxContinuations: command.maxContinuations, maxFailures: command.maxFailures });
|
|
250
|
+
maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
|
|
251
251
|
await refreshSessions(true);
|
|
252
252
|
} else if (action === "session_continue") {
|
|
253
253
|
await (dependencies.continueSession || continueSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
|
254
254
|
session: command.session, autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
|
|
255
|
-
maxContinuations: command.maxContinuations, maxFailures: command.maxFailures });
|
|
255
|
+
maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
|
|
256
256
|
await refreshSessions(true);
|
|
257
257
|
} else if (action === "session_answer") {
|
|
258
258
|
await (dependencies.answerSession || answerSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
|
@@ -166,7 +166,7 @@ export function isCodexWriterBusy(error) {
|
|
|
166
166
|
return error instanceof CodexAppServerError && error.code === -32600 && /already has an active writer/i.test(error.message);
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
export async function executeCodexAppServer(record, { openClient = openCodexAppServer, signal, beforeDispatch = async () => {} } = {}) {
|
|
169
|
+
export async function executeCodexAppServer(record, { openClient = openCodexAppServer, signal, beforeDispatch = async () => {}, onActivity = () => {}, onUsage = () => {} } = {}) {
|
|
170
170
|
const run = record.run, decision = run.managed?.decision;
|
|
171
171
|
if (!decision || !run.managed.autonomyPolicy) throw new CodexAppServerError('decision', 'An assessed action is required before Codex dispatch.');
|
|
172
172
|
if (!['read', 'local_edit', 'local_test'].includes(decision.action?.kind)) throw new CodexAppServerError('manual_provider_action', 'This action must be completed in the original provider conversation.');
|
|
@@ -177,6 +177,7 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
|
|
|
177
177
|
const client = await openClient({ signal });
|
|
178
178
|
let originalSandbox = null, overrideStarted = false, activeTurnId = null, turnCompleted = false, turnStart = null;
|
|
179
179
|
let primaryError = null;
|
|
180
|
+
let usage = null, lastTotal = null;
|
|
180
181
|
try {
|
|
181
182
|
const metadata = await readCodexSession(client, record.session);
|
|
182
183
|
if (await realpath(metadata.cwd) !== record.cwd || metadata.model !== run.managed.resumeModel) throw new CodexAppServerError('identity', 'Codex session workspace or model changed.');
|
|
@@ -191,6 +192,7 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
|
|
|
191
192
|
const context = { taskId: run.managed.taskId, objective: run.objective, acceptanceCriteria: run.managed.acceptanceCriteria,
|
|
192
193
|
decision, diagnosis: run.managed.plan?.diagnosis, remainingWork: run.managed.remainingWork, nextStep: run.nextStep,
|
|
193
194
|
enrolledScopes: run.managed.approvalScopes,
|
|
195
|
+
lastDependencyResolution: run.managed.lastDependencyResolution,
|
|
194
196
|
...(authorizedWait ? { approvedAction: { approvalId: approved.id, action: approved.action, oneShot: true } } : {}),
|
|
195
197
|
budgets: { deadline: run.deadline, continuations: run.continuations, maxContinuations: run.maxContinuations, failures: run.failures, maxFailures: run.maxFailures } };
|
|
196
198
|
const approvalInstruction = authorizedWait ? `SIMY recorded a matching, one-shot scoped user approval ${approved.id} for this exact selected action. The decision outcome "wait" describes its pre-approval assessment; the approved action is authorized now. Execute it once, then report what actually happened. ` : '';
|
|
@@ -207,6 +209,22 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
|
|
|
207
209
|
const done = new Promise((resolve, reject) => {
|
|
208
210
|
let turnId = null, completion = null, settled = false;
|
|
209
211
|
const messages = new Map();
|
|
212
|
+
const activeTools = new Set();
|
|
213
|
+
const earlyUsage = [];
|
|
214
|
+
const earlyActivity = [];
|
|
215
|
+
const observeActivity = (method, params) => {
|
|
216
|
+
if (method === 'item/started' && ['commandExecution', 'mcpToolCall', 'webSearch', 'fileChange'].includes(params.item?.type)) activeTools.add(params.item.id);
|
|
217
|
+
if (method === 'item/completed') activeTools.delete(params.item?.id);
|
|
218
|
+
onActivity({ waitingForTool: activeTools.size > 0 });
|
|
219
|
+
};
|
|
220
|
+
const observeUsage = params => {
|
|
221
|
+
const total = params.tokenUsage?.total?.totalTokens, last = params.tokenUsage?.last?.totalTokens;
|
|
222
|
+
if (!Number.isSafeInteger(total) || total < 0 || !Number.isSafeInteger(last) || last < 0 || last > total) return;
|
|
223
|
+
if (lastTotal === null) usage = { source: 'codex_app_server', tokens: last, observedAt: Date.now() };
|
|
224
|
+
else usage.tokens += Math.max(0, total - lastTotal);
|
|
225
|
+
lastTotal = Math.max(lastTotal ?? 0, total);
|
|
226
|
+
onUsage({ ...usage });
|
|
227
|
+
};
|
|
210
228
|
const cleanup = () => { unsubscribe(); signal?.removeEventListener('abort', cancelled); client.child.removeListener('close', closed); };
|
|
211
229
|
const finish = (error, value) => { if (settled) return; settled = true; cleanup(); if (error) reject(error); else resolve(value); };
|
|
212
230
|
const cancelled = () => finish(new CodexAppServerError('cancelled', 'Codex turn was cancelled.'));
|
|
@@ -222,6 +240,15 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
|
|
|
222
240
|
const unsubscribe = client.onNotification((method, params, requestId) => {
|
|
223
241
|
if (requestId !== undefined && /requestApproval|requestPermissions|requestUserInput/i.test(method)) return finish(new CodexAppServerError('provider_approval', 'Codex requires provider approval or user input.'));
|
|
224
242
|
if (params.threadId !== record.session) return;
|
|
243
|
+
// Only this owned turn's activity may renew its response watchdog.
|
|
244
|
+
if (params.turnId === turnId || params.turn?.id === turnId) {
|
|
245
|
+
if (turnId) observeActivity(method, params);
|
|
246
|
+
} else if (!turnId && typeof params.turnId === 'string' && earlyActivity.length < 100) earlyActivity.push({ method, turnId: params.turnId,
|
|
247
|
+
item: { id: params.item?.id, type: params.item?.type } });
|
|
248
|
+
if (method === 'thread/tokenUsage/updated') {
|
|
249
|
+
if (turnId && params.turnId === turnId) observeUsage(params);
|
|
250
|
+
else if (!turnId && earlyUsage.length < 100) earlyUsage.push({ turnId: params.turnId, tokenUsage: params.tokenUsage });
|
|
251
|
+
}
|
|
225
252
|
if (method === 'item/completed' && params.item?.type === 'agentMessage' && typeof params.item.text === 'string' && typeof params.turnId === 'string') messages.set(params.turnId, params.item.text);
|
|
226
253
|
if (method === 'turn/completed') { completion = params; if (params.turn?.id === turnId) turnCompleted = true; complete(); }
|
|
227
254
|
});
|
|
@@ -230,11 +257,15 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
|
|
|
230
257
|
overrideStarted = true;
|
|
231
258
|
turnStart = client.request('turn/start', { threadId: record.session, input: [{ type: 'text', text: prompt }], cwd: record.cwd,
|
|
232
259
|
model: run.managed.resumeModel, sandboxPolicy })
|
|
233
|
-
.then(response => { turnId = response?.turn?.id; activeTurnId = turnId; if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else
|
|
260
|
+
.then(response => { turnId = response?.turn?.id; activeTurnId = turnId; if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
|
|
261
|
+
for (const params of earlyUsage) if (params.turnId === turnId) observeUsage(params);
|
|
262
|
+
for (const params of earlyActivity) if (params.turnId === turnId) observeActivity(params.method, params);
|
|
263
|
+
complete();
|
|
264
|
+
} })
|
|
234
265
|
.catch(error => finish(error));
|
|
235
266
|
});
|
|
236
267
|
const checkpoint = await done;
|
|
237
|
-
return { code: 0, checkpoint };
|
|
268
|
+
return { code: 0, checkpoint, usage };
|
|
238
269
|
} catch (error) {
|
|
239
270
|
primaryError = error;
|
|
240
271
|
throw error;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
+
import { evaluateAssessedPlan, listRecords, managedBudgetReason, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
5
|
+
import { openCodexAppServer, readCodexSession } from './codex-app-server.js';
|
|
6
|
+
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
function unsettled(managed) {
|
|
9
|
+
return managed.phase === 'understanding' || managed.executionUncertain || managed.executionAttempt && managed.executionAttempt.phase !== 'checkpointed' || managed.providerWarning || managed.resumeLease || managed.pendingApproval || managed.approvedAction && !managed.approvedAction.dispatchedAt ||
|
|
10
|
+
managed.dispatchLedger?.some(entry => !['settled', 'cancelled'].includes(entry.status)) ||
|
|
11
|
+
managed.dispatches?.some(entry => entry.status && !['settled', 'cancelled', 'completed'].includes(entry.status));
|
|
12
|
+
}
|
|
13
|
+
function exactSource(record, taskId, targetSession, now, allowStoppedPending = false) {
|
|
14
|
+
if (!record?.run?.managed || !record.admission || record.admission.session !== record.session || record.admission.cwd !== record.cwd || record.run.managed.taskId !== taskId) throw new Error('Exact managed task is unavailable.');
|
|
15
|
+
if (now !== undefined && managedBudgetReason(record.run, now)) throw new Error('Original task budget exhausted; handoff cannot restore it.');
|
|
16
|
+
const prior = record.run.managed.handoff;
|
|
17
|
+
const transfer = prior?.status === 'received' ? null : prior;
|
|
18
|
+
if (transfer && transfer.targetSession !== targetSession) throw new Error('A different handoff is already recorded.');
|
|
19
|
+
if (!transfer && (record.run.state !== 'paused' || unsettled(record.run.managed))) throw new Error('Handoff requires paused source with no unsettled lease, dispatch, or approval.');
|
|
20
|
+
if (transfer && (transfer.status === 'pending' && record.run.state !== 'paused' && !(allowStoppedPending && record.run.state === 'stopped') || transfer.status === 'transferred' && record.run.state !== 'stopped')) throw new Error('Source handoff state changed.');
|
|
21
|
+
if (transfer && !['pending', 'transferred'].includes(transfer.status)) throw new Error('This session is a handoff recipient.');
|
|
22
|
+
return record;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Explicit user command only. This transfers SIMY's durable work binding, not
|
|
26
|
+
// provider turns or external effects. Neither session receives a turn here.
|
|
27
|
+
export async function handoffSession({ root, provider, session, taskId, targetSession, openClient = openCodexAppServer, now = Date.now() }) {
|
|
28
|
+
if (provider !== 'codex') throw new Error('Claude handoff is unsupported; return to the original live conversation.');
|
|
29
|
+
if (!UUID.test(session || '') || !UUID.test(targetSession || '') || session === targetSession || !taskId) throw new Error('Distinct exact Codex session IDs and task ID are required.');
|
|
30
|
+
const source = exactSource((await listRecords(root)).find(item => item.provider === provider && item.session === session), taskId, targetSession, now);
|
|
31
|
+
const startedWall = Date.now();
|
|
32
|
+
const budgetNow = () => now + Math.max(0, Date.now() - startedWall);
|
|
33
|
+
const client = await openClient();
|
|
34
|
+
let metadata;
|
|
35
|
+
try {
|
|
36
|
+
const original = await readCodexSession(client, session);
|
|
37
|
+
metadata = await readCodexSession(client, targetSession);
|
|
38
|
+
if (await realpath(original.cwd) !== source.cwd || await realpath(metadata.cwd) !== source.cwd || !metadata.model || metadata.model !== source.run.managed.resumeModel || original.model !== metadata.model) throw new Error('Handoff workspace or model does not match admitted source.');
|
|
39
|
+
const sourceConfiguration = await client.request('thread/resume', { threadId: session, excludeTurns: true });
|
|
40
|
+
const targetConfiguration = await client.request('thread/resume', { threadId: targetSession, excludeTurns: true });
|
|
41
|
+
for (const [configuration, expected] of [[sourceConfiguration, session], [targetConfiguration, targetSession]]) {
|
|
42
|
+
if (configuration?.thread?.id !== expected || !configuration.cwd || await realpath(configuration.cwd) !== source.cwd || configuration.model !== metadata.model || !['readOnly', 'workspaceWrite', 'dangerFullAccess'].includes(configuration.sandbox?.type)) throw new Error('Exact session admission or sandbox is unavailable.');
|
|
43
|
+
}
|
|
44
|
+
if (!isDeepStrictEqual(sourceConfiguration.sandbox, targetConfiguration.sandbox)) throw new Error('Target sandbox must preserve exact source rights.');
|
|
45
|
+
const enrolled = source.run.managed.providerSandbox;
|
|
46
|
+
if (enrolled && Object.entries(enrolled).some(([key, value]) => !isDeepStrictEqual(value, sourceConfiguration.sandbox[key]))) throw new Error('Source sandbox changed from enrolled rights.');
|
|
47
|
+
// Freeze source first. The ordinary control/hook/supervisor paths reject
|
|
48
|
+
// pending/transferred and pending_received markers, including after crash.
|
|
49
|
+
const frozen = await updateRecord(root, provider, session, record => {
|
|
50
|
+
exactSource(record, taskId, targetSession, budgetNow());
|
|
51
|
+
if ((record.run.managed.handoff?.id || null) !== (source.run.managed.handoff?.id || null)) throw new Error('Handoff identity changed or cancelled during admission.');
|
|
52
|
+
if (!record.run.managed.handoff || record.run.managed.handoff.status === 'received') record.run.managed.handoff = { id: randomUUID(), sourceSession: session, targetSession, status: 'pending', startedAt: now };
|
|
53
|
+
return record;
|
|
54
|
+
});
|
|
55
|
+
const handoff = frozen.run.managed.handoff;
|
|
56
|
+
await updateRecord(root, provider, session, async sourceRecord => {
|
|
57
|
+
exactSource(sourceRecord, taskId, targetSession, budgetNow());
|
|
58
|
+
if (sourceRecord.run.managed.handoff?.id !== handoff.id) throw new Error('Handoff identity changed or cancelled.');
|
|
59
|
+
await updateRecord(root, provider, targetSession, target => {
|
|
60
|
+
if (target?.run) {
|
|
61
|
+
if (target.run.id !== frozen.run.id || target.run.managed?.handoff?.id !== handoff.id || target.run.managed.taskId !== taskId) throw new Error('Target already owns another run.');
|
|
62
|
+
return target;
|
|
63
|
+
}
|
|
64
|
+
if (target && target.cwd !== source.cwd) throw new Error('Target admission workspace changed.');
|
|
65
|
+
const run = structuredClone(frozen.run);
|
|
66
|
+
run.state = 'paused';
|
|
67
|
+
run.managed.thread = { provider, id: targetSession };
|
|
68
|
+
run.managed.providerSandbox = structuredClone(targetConfiguration.sandbox);
|
|
69
|
+
const option = { id: 'inspect_handoff', action: { kind: 'read', scope: run.managed.approvalScopes[0], summary: 'Inspect transferred goal, frozen acceptance, prior evidence, and current workspace before proposing the next action' },
|
|
70
|
+
assessment: { risk: 'low', reversibility: 'reversible', externalEffect: 'none', blastRadius: 'single_file', uncertainty: 'low', inObjective: true, userChoice: false },
|
|
71
|
+
expectedVerification: 'Read-only observations reconcile the durable task with this conversation and workspace.', rollback: 'No changes to undo.' };
|
|
72
|
+
run.managed.plan = { diagnosis: 'Explicit work handoff requires read-only reconciliation before further work.', alternatives: [option], selectedOptionId: option.id, recommendation: { optionId: option.id, reason: 'Preserve task evidence without replaying the previous action.' } };
|
|
73
|
+
run.managed.decision = evaluateAssessedPlan(run.managed.plan, run.managed.autonomyPolicy, run.managed.approvalScopes, now);
|
|
74
|
+
run.managed.decisionHistory = [...(run.managed.decisionHistory || []), run.managed.decision].slice(-20);
|
|
75
|
+
delete run.managed.approvedAction;
|
|
76
|
+
run.checkpoint = null;
|
|
77
|
+
run.nextStep = option.action.summary;
|
|
78
|
+
run.reason = 'Explicit handoff is paused; resume starts with read-only reconciliation.';
|
|
79
|
+
run.managed.handoff = { ...handoff, status: 'pending_received' };
|
|
80
|
+
run.managed.threadHistory = [...(run.managed.threadHistory || [session]), targetSession].slice(-20);
|
|
81
|
+
run.managed.workBindingHistory = [...(run.managed.workBindingHistory || []), { from: session, to: targetSession, handoffId: handoff.id, at: handoff.startedAt }].slice(-20);
|
|
82
|
+
return { ...(target || {}), schemaVersion: 1, provider, session: targetSession, cwd: source.cwd, monitor: true,
|
|
83
|
+
admission: { source: 'codex_app_server', provider, session: targetSession, cwd: source.cwd, model: metadata.model, validatedAt: now }, run };
|
|
84
|
+
});
|
|
85
|
+
return sourceRecord;
|
|
86
|
+
});
|
|
87
|
+
await updateRecord(root, provider, session, record => {
|
|
88
|
+
exactSource(record, taskId, targetSession, budgetNow());
|
|
89
|
+
if (record.run.managed.handoff.id !== handoff.id) throw new Error('Handoff identity changed.');
|
|
90
|
+
record.run.state = 'stopped'; record.run.reason = 'Work transferred to the exact target conversation.';
|
|
91
|
+
record.run.managed.handoff.status = 'transferred';
|
|
92
|
+
record.run.managed.handoff.transferredAt ||= now;
|
|
93
|
+
return record;
|
|
94
|
+
});
|
|
95
|
+
const received = await updateRecord(root, provider, targetSession, record => {
|
|
96
|
+
if (record?.run?.managed?.handoff?.id !== handoff.id) throw new Error('Target handoff identity changed.');
|
|
97
|
+
record.run.managed.handoff.status = 'received';
|
|
98
|
+
return record;
|
|
99
|
+
});
|
|
100
|
+
return { provider, session: targetSession, sourceSession: session, taskId, runId: frozen.run.id, state: received.run.state, handoffId: handoff.id };
|
|
101
|
+
} finally { await client.close(); }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
// Cancellation is explicit and permitted only before this transfer has created
|
|
106
|
+
// its target run. Source-then-target locking shares creation's lock order, so
|
|
107
|
+
// an in-flight creator cannot publish an orphan after the marker is cleared.
|
|
108
|
+
export async function cancelHandoffSession({ root, provider, session, taskId, targetSession }) {
|
|
109
|
+
if (provider !== 'codex' || !UUID.test(session || '') || !UUID.test(targetSession || '') || session === targetSession || !taskId) throw new Error('Exact Codex source, target, and task ID are required.');
|
|
110
|
+
const source = await updateRecord(root, provider, session, async record => {
|
|
111
|
+
exactSource(record, taskId, targetSession, undefined, true);
|
|
112
|
+
const marker = record.run.managed.handoff;
|
|
113
|
+
if (marker?.status !== 'pending') throw new Error('Only a pending handoff without a target run can be cancelled.');
|
|
114
|
+
await updateRecord(root, provider, targetSession, target => {
|
|
115
|
+
if (target?.run?.managed?.handoff?.id === marker.id) throw new Error('Target run exists; complete the exact handoff instead of cancelling.');
|
|
116
|
+
return target;
|
|
117
|
+
});
|
|
118
|
+
record.run.managed.workBindingHistory = [...(record.run.managed.workBindingHistory || []),
|
|
119
|
+
{ from: session, to: targetSession, handoffId: marker.id, cancelled: true }].slice(-20);
|
|
120
|
+
delete record.run.managed.handoff;
|
|
121
|
+
return record;
|
|
122
|
+
});
|
|
123
|
+
return { provider, session, targetSession, taskId, state: source.run.state, cancelled: true };
|
|
124
|
+
}
|