@awak-app/simy-cli 0.5.36 → 0.5.38
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 +74 -11
- package/src/session-supervision/analysis-manager.js +187 -0
- package/src/session-supervision/claude-sessions.js +47 -5
- package/src/session-supervision/codex-app-server.js +78 -8
- package/src/session-supervision/continuation-analysis.js +121 -0
- package/src/session-supervision/conversation-context.js +51 -0
- package/src/session-supervision/handoff.js +124 -0
- package/src/session-supervision/index.js +6 -3
- package/src/session-supervision/workspace-lease.js +2 -0
- package/src/sqm/claude-plugin.js +1 -0
- package/src/sqm/review-worker.js +55 -8
|
@@ -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
|
@@ -2,6 +2,8 @@ import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
|
|
|
2
2
|
import { controlDelivery, verifyDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
3
3
|
import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
|
|
4
4
|
import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession } from "./session-supervision/index.js";
|
|
5
|
+
import { createContinuationAnalysisManager } from "./session-supervision/analysis-manager.js";
|
|
6
|
+
import { readContinuationContext } from "./session-supervision/continuation-analysis.js";
|
|
5
7
|
import { createWindowsDesktopUpdater } from "./windows-desktop-updater.js";
|
|
6
8
|
import { spawn, execFile } from "node:child_process";
|
|
7
9
|
import { promisify } from "node:util";
|
|
@@ -28,6 +30,9 @@ import {
|
|
|
28
30
|
} from "./desktop-process-lock.js";
|
|
29
31
|
|
|
30
32
|
const silentLogger = { log() {}, error() {} };
|
|
33
|
+
// Match the native DesktopStatusFrames limit: 200 conversation summaries can
|
|
34
|
+
// exceed 128 KiB, including after a signed runtime handoff.
|
|
35
|
+
const MAX_DESKTOP_FRAME_BYTES = 8 * 1024 * 1024;
|
|
31
36
|
|
|
32
37
|
export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
|
|
33
38
|
dependencies = {}, exit = (code) => { process.exitCode = code; } } = {}) {
|
|
@@ -40,7 +45,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
40
45
|
}
|
|
41
46
|
const lock = candidate ? null : await acquireDesktopProcessLock(root, { webOrigin });
|
|
42
47
|
const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
|
|
43
|
-
let agent, updater, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null;
|
|
48
|
+
let agent, updater, analysisManager, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null;
|
|
44
49
|
const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks") });
|
|
45
50
|
let shuttingDown = null;
|
|
46
51
|
let commands;
|
|
@@ -50,13 +55,22 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
50
55
|
if (force && sessionRefresh) sessionRefreshAgain = true;
|
|
51
56
|
if (!sessionRefresh && (force || Date.now() - sessionRefreshedAt >= 15_000)) {
|
|
52
57
|
sessionRefresh = Promise.resolve().then(() => (dependencies.discoverSessions || discoverSessions)({ root: join(root, "delivery-hooks") }))
|
|
53
|
-
.then(value => { sessionStatus = value; sessionRefreshedAt = Date.now(); })
|
|
58
|
+
.then(async value => { await analysisManager.update(value.sessions); sessionStatus = value; sessionRefreshedAt = Date.now(); })
|
|
54
59
|
.catch(() => { sessionStatus = { ...sessionStatus, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
|
|
55
60
|
.finally(() => { sessionRefresh = null; if (sessionRefreshAgain) { sessionRefreshAgain = false; refreshSessions(true); }
|
|
56
61
|
if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
|
|
57
62
|
}
|
|
58
63
|
return sessionStatus;
|
|
59
64
|
};
|
|
65
|
+
const selectedSession = async command => {
|
|
66
|
+
refreshSessions(true);
|
|
67
|
+
await sessionRefresh;
|
|
68
|
+
// A forced refresh requested during discovery schedules one follow-up.
|
|
69
|
+
if (sessionRefresh) await sessionRefresh;
|
|
70
|
+
const candidate = sessionStatus.sessions.find(item => item.provider === command.provider && item.session === command.session);
|
|
71
|
+
if (!candidate) throw new Error('This conversation is unavailable; refresh its provider.');
|
|
72
|
+
return candidate;
|
|
73
|
+
};
|
|
60
74
|
let pluginSetup = { state: "unknown" };
|
|
61
75
|
const inspectPlugins = dependencies.inspectPlugins || inspectSqmAgentSkill;
|
|
62
76
|
const refreshPlugins = async () => {
|
|
@@ -66,19 +80,25 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
66
80
|
} catch { pluginSetup = { state: "error" }; }
|
|
67
81
|
};
|
|
68
82
|
const packageInfo = dependencies.packageInfo || await readCliPackageInfo();
|
|
83
|
+
const makeAnalysisManager = () => (dependencies.createAnalysisManager || createContinuationAnalysisManager)({
|
|
84
|
+
root: join(root, 'session-analysis'), analyze: dependencies.analyzeContinuation,
|
|
85
|
+
onChange: () => { if (!closed && !relay) setImmediate(() => { void publish().catch(() => {}); }); },
|
|
86
|
+
});
|
|
69
87
|
const publish = () => {
|
|
70
88
|
if (closed || relay || !agent) return Promise.resolve();
|
|
71
89
|
if (publishing) return publishing;
|
|
72
90
|
publishing = Promise.resolve(agent.getDesktopStatus()).then(async (status) => {
|
|
73
91
|
status.pluginSetup = { ...pluginSetup };
|
|
74
92
|
status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: join(root, "delivery-hooks") }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
|
|
75
|
-
|
|
93
|
+
const discovery = refreshSessions();
|
|
94
|
+
status.sessionSupervision = { ...discovery, sessions: discovery.sessions.map(candidate => analysisManager.project(candidate)) };
|
|
76
95
|
if (!closed && !relay) emit({ type: "status", status });
|
|
77
96
|
}).finally(() => { publishing = null; });
|
|
78
97
|
return publishing;
|
|
79
98
|
};
|
|
80
99
|
const stopAgent = async () => {
|
|
81
100
|
clearInterval(timer);
|
|
101
|
+
await analysisManager?.close();
|
|
82
102
|
await managedSupervisor.close();
|
|
83
103
|
await stopWorkflows();
|
|
84
104
|
if (agent) await createAgentShutdown(agent)();
|
|
@@ -99,6 +119,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
99
119
|
})();
|
|
100
120
|
|
|
101
121
|
try {
|
|
122
|
+
analysisManager = await makeAnalysisManager();
|
|
102
123
|
if (!candidate) {
|
|
103
124
|
await (dependencies.assertNoLegacyDaemon || assertNoLegacyCliDaemon)(webOrigin);
|
|
104
125
|
}
|
|
@@ -119,11 +140,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
119
140
|
updater.on?.("change", () => { void publish().catch(() => {}); });
|
|
120
141
|
updater.start({ registry: agent.registry, restartDaemon: async (targetVersion, installation) => {
|
|
121
142
|
if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
|
|
122
|
-
|
|
143
|
+
// The candidate shares this cache. Stop inference before it starts so a
|
|
144
|
+
// signed handoff cannot create two analysis writers or duplicate calls.
|
|
145
|
+
await analysisManager.close();
|
|
146
|
+
let child;
|
|
147
|
+
try { child = (dependencies.spawn || spawn)(installation.executablePath,
|
|
123
148
|
[join(dirname(installation.entryPath), "desktop-host.js")], {
|
|
124
149
|
stdio: ["pipe", "pipe", "ignore"],
|
|
125
150
|
env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
|
|
126
|
-
});
|
|
151
|
+
}); } catch (error) { analysisManager = await makeAnalysisManager(); refreshSessions(true); throw error; }
|
|
127
152
|
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
128
153
|
let accepted = false;
|
|
129
154
|
child.stdin.on("error", () => {
|
|
@@ -145,7 +170,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
145
170
|
child.once("error", () => finish(reject, new Error("Updated desktop runtime could not start.")));
|
|
146
171
|
child.once("exit", () => { if (!accepted) finish(reject, new Error("Updated desktop runtime exited before verification.")); });
|
|
147
172
|
lines.on("line", (line) => {
|
|
148
|
-
if (line
|
|
173
|
+
if (Buffer.byteLength(line, "utf8") > MAX_DESKTOP_FRAME_BYTES) {
|
|
174
|
+
if (accepted) emit({ type: "error", message: "SIMY update status was too large. Reconnect to start the service again." });
|
|
175
|
+
else finish(reject, new Error("Updated desktop runtime status was too large."));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
149
178
|
let event; try { event = JSON.parse(line); } catch { return; }
|
|
150
179
|
if (accepted) { emit(event); return; }
|
|
151
180
|
if (event.type === "status") {
|
|
@@ -160,7 +189,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
160
189
|
if (child.exitCode !== null || closed) throw new Error("Updated runtime stopped during handoff.");
|
|
161
190
|
emit(first);
|
|
162
191
|
} catch (error) {
|
|
163
|
-
lines.close(); child
|
|
192
|
+
lines.close(); await stopUpdateCandidate(child);
|
|
193
|
+
if (!closed && !relay) { analysisManager = await makeAnalysisManager(); refreshSessions(true); }
|
|
194
|
+
throw error;
|
|
164
195
|
}
|
|
165
196
|
} });
|
|
166
197
|
await refreshPlugins();
|
|
@@ -233,9 +264,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
233
264
|
else if (action === "codex_hooks" || action === "claude_hooks") {
|
|
234
265
|
const provider = action === "codex_hooks" ? "codex" : "claude";
|
|
235
266
|
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)) {
|
|
267
|
+
} else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy", "delivery_dependency_ready"].includes(action)) {
|
|
237
268
|
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) });
|
|
269
|
+
taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, waitId: command.waitId, answer: command.answer, action: action.slice("delivery_".length) });
|
|
239
270
|
} else if (action === "delivery_verify") {
|
|
240
271
|
await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
|
|
241
272
|
taskId: command.taskId, criterionId: command.criterionId });
|
|
@@ -247,12 +278,28 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
247
278
|
await (dependencies.enrollSession || enrollSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
|
248
279
|
session: command.session, objective: command.objective, acceptanceCriteria: command.acceptanceCriteria,
|
|
249
280
|
autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
|
|
250
|
-
maxContinuations: command.maxContinuations, maxFailures: command.maxFailures });
|
|
281
|
+
maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
|
|
251
282
|
await refreshSessions(true);
|
|
283
|
+
} else if (action === "session_analyze") {
|
|
284
|
+
await analysisManager.request(await selectedSession(command));
|
|
252
285
|
} else if (action === "session_continue") {
|
|
286
|
+
const candidate = await selectedSession(command);
|
|
287
|
+
const manager = analysisManager;
|
|
288
|
+
const recommendation = manager.requireRecommendation(candidate);
|
|
289
|
+
if (candidate.capabilities?.canContinue !== true) throw new Error('Automatic continuation is unavailable for this conversation.');
|
|
290
|
+
const context = await (dependencies.readContinuationContext || readContinuationContext)(candidate);
|
|
291
|
+
// Provider activity timestamps can have one-second precision. Re-read
|
|
292
|
+
// the bounded context before enrollment to reject same-second edits.
|
|
293
|
+
if (context.incomplete || !recommendation.contextDigest || context.contextDigest !== recommendation.contextDigest) {
|
|
294
|
+
await manager.invalidate(candidate);
|
|
295
|
+
throw new Error('This conversation changed; wait for its updated analysis.');
|
|
296
|
+
}
|
|
297
|
+
// Reading provider context can overlap an update handoff. The old
|
|
298
|
+
// manager must still be active before task enrollment is dispatched.
|
|
299
|
+
manager.requireRecommendation(candidate);
|
|
253
300
|
await (dependencies.continueSession || continueSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
|
254
301
|
session: command.session, autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
|
|
255
|
-
maxContinuations: command.maxContinuations, maxFailures: command.maxFailures });
|
|
302
|
+
maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
|
|
256
303
|
await refreshSessions(true);
|
|
257
304
|
} else if (action === "session_answer") {
|
|
258
305
|
await (dependencies.answerSession || answerSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
|
|
@@ -295,6 +342,22 @@ function validCandidateStatus(status, version, webOrigin) {
|
|
|
295
342
|
&& Array.isArray(status.tasks.items) && typeof status.codex?.state === "string";
|
|
296
343
|
}
|
|
297
344
|
|
|
345
|
+
async function stopUpdateCandidate(child) {
|
|
346
|
+
if (child.exitCode !== null) return;
|
|
347
|
+
await new Promise((resolve, reject) => {
|
|
348
|
+
const forced = setTimeout(() => child.kill('SIGKILL'), 2000);
|
|
349
|
+
const limit = setTimeout(() => finish(new Error('Updated desktop runtime did not stop.')), 10_000);
|
|
350
|
+
const finish = error => {
|
|
351
|
+
clearTimeout(forced); clearTimeout(limit);
|
|
352
|
+
child.removeListener('exit', exited);
|
|
353
|
+
error ? reject(error) : resolve();
|
|
354
|
+
};
|
|
355
|
+
const exited = () => finish();
|
|
356
|
+
child.once('exit', exited);
|
|
357
|
+
child.kill('SIGTERM');
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
298
361
|
export async function openProviderLogin(provider, root, {
|
|
299
362
|
inspectExecutable = inspectBackendExecutable, spawnImpl = spawn,
|
|
300
363
|
execFileImpl = promisify(execFile), platform = process.platform, env = process.env,
|