@awak-app/simy-cli 0.7.4 → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/package.json +2 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +2 -2
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +144 -59
- package/src/codex-plugins/simy/hooks/perspective-memory.mjs +115 -0
- package/src/codex-plugins/simy/hooks/purpose-cycle.mjs +43 -12
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +31 -8
- package/src/codex-plugins/simy/hooks/purpose-search.mjs +2 -1
- package/src/codex-plugins/simy/hooks/state-limits.mjs +2 -0
- package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +23 -1
- package/src/codex-plugins/simy/skills/sqm-loop/SKILL.md +2 -0
- package/src/delivery-hooks/checkpoint-diagnostic.js +1 -0
- package/src/delivery-hooks/command.js +10 -2
- package/src/delivery-hooks/consistency-monitor.js +71 -22
- package/src/delivery-hooks/desktop.js +2 -2
- package/src/delivery-hooks/local-runtime.js +2 -2
- package/src/delivery-hooks/supervisor.js +15 -4
- package/src/desktop-host.js +7 -2
- package/src/session-supervision/codex-app-server.js +84 -36
- package/src/session-supervision/consistency-auditor.js +87 -34
- package/src/session-supervision/index.js +22 -1
- package/src/session-supervision/native-delegation.js +86 -0
- package/src/session-supervision/purpose-instruction.js +4 -2
- package/src/session-supervision/purpose-recovery.js +48 -16
- package/src/session-supervision/purpose-verification.js +95 -10
- package/src/session-supervision/redelegate-native.js +48 -0
- package/src/sqm/claude-plugin.js +2 -0
- package/src/sqm/command.js +10 -3
- package/src/sqm/hypothesis-knowledge.js +2 -1
- package/src/sqm/index.js +11 -5
- package/src/sqm/local-tests.js +155 -0
- package/src/sqm/proof.js +14 -3
- package/src/sqm/report.js +8 -3
- package/src/sqm/review-worker.js +16 -2
|
@@ -2,13 +2,14 @@ import { completionClaimUnbound } from '../codex-plugins/simy/hooks/completion-b
|
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { automaticContinuationAllowed, queuePurposeVerification, processIdentity, readRecord, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
4
4
|
import { purposeDiagnosisPlan } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
|
|
5
|
-
import { reconcileFindings } from '../codex-plugins/simy/hooks/purpose-evidence.mjs';
|
|
5
|
+
import { reconcileFindings, evidenceExcerpt } from '../codex-plugins/simy/hooks/purpose-evidence.mjs';
|
|
6
6
|
import { requireProviderLink } from '../provider-links.js';
|
|
7
7
|
import { executeCodexAppServer, openCodexAppServer, readCodexSession } from './codex-app-server.js';
|
|
8
8
|
import { pauseOrigin } from './autonomous-upgrade.js';
|
|
9
9
|
import { realpath } from 'node:fs/promises';
|
|
10
10
|
import { leaseOwnerDead } from './workspace-lease.js';
|
|
11
11
|
|
|
12
|
+
const isOpinionOnly = findings => findings.length > 0 && findings.every(row => row.code === 'outcome_alignment');
|
|
12
13
|
const contract = record => createHash('sha256').update(JSON.stringify([record.run.id, record.run.revision,
|
|
13
14
|
record.run.objective, record.run.managed.acceptanceCriteria, record.autoContinueEpoch])).digest('hex');
|
|
14
15
|
export const canAuditOutcome = record => record?.provider === 'codex' && record.autoContinue === true &&
|
|
@@ -22,6 +23,33 @@ export const canAuditOutcome = record => record?.provider === 'codex' && record.
|
|
|
22
23
|
!(Number.isFinite(record.run.maxContinuations) && record.run.continuations >= record.run.maxContinuations) &&
|
|
23
24
|
!(record.run.managed.outcomeAudit?.usageReceipts?.filter(row => !row.settled).length >= 128) && !record.run.managed.usageBlocked && !(record.run.managed.maxTokens && record.run.managed.tokensUsed >= record.run.managed.maxTokens);
|
|
24
25
|
|
|
26
|
+
// Lease accounting is recovery bookkeeping, not permission to start an audit.
|
|
27
|
+
// Reach it even when a legacy worker lease coexists with the audit lease.
|
|
28
|
+
export async function recoverOutcomeAuditLease(record, { root, writeRecord = updateRecord, inspectProcess = processIdentity } = {}) {
|
|
29
|
+
const oldLease = record?.run?.managed?.outcomeAudit?.lease;
|
|
30
|
+
if (!oldLease) return record;
|
|
31
|
+
const oldOwnerDead = await leaseOwnerDead(oldLease.owner, inspectProcess);
|
|
32
|
+
if (!(Date.now() >= oldLease.expiresAt || oldOwnerDead)) return record;
|
|
33
|
+
const boundWrite = (root, provider, session, mutate) => writeRecord(root, provider, session, live => {
|
|
34
|
+
if (live?.run?.id !== record.run.id || live.run.managed?.taskId !== record.run.managed.taskId ||
|
|
35
|
+
live.run.managed.outcomeAudit?.lease?.id !== oldLease.id) return live;
|
|
36
|
+
return mutate(live);
|
|
37
|
+
});
|
|
38
|
+
// Keep unknown consumption and hard-budget blocks before releasing the lease.
|
|
39
|
+
await accountAuditUsage(root, record, oldLease.id, null, boundWrite);
|
|
40
|
+
return boundWrite(root, record.provider, record.session, live => {
|
|
41
|
+
const audit = live.run.managed.outcomeAudit;
|
|
42
|
+
delete audit.lease;
|
|
43
|
+
// Expiry fences late results; only confirmed death settles late-usage slots.
|
|
44
|
+
if (oldOwnerDead) {
|
|
45
|
+
const receipt = audit.usageReceipts?.find(row => row.id === oldLease.id);
|
|
46
|
+
if (receipt) receipt.settled = true;
|
|
47
|
+
}
|
|
48
|
+
audit.lastFailure = { at: Date.now(), code: 'audit_owner_exited', requestId: oldLease.requestId, attemptId: oldLease.id };
|
|
49
|
+
return live;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
25
53
|
// A distinct provider context reads original instructions and primary evidence.
|
|
26
54
|
// It has its own read-only lease; original turns are never resumed or edited.
|
|
27
55
|
async function auditConsistencyAttempt(record, findings, request, { root, signal,
|
|
@@ -36,34 +64,18 @@ async function auditConsistencyAttempt(record, findings, request, { root, signal
|
|
|
36
64
|
signal?.throwIfAborted();
|
|
37
65
|
const key = contract(record), id = request.attemptId;
|
|
38
66
|
const owner = { pid: process.pid, identity: await processIdentity(process.pid) };
|
|
39
|
-
const oldLease = record.run.managed.outcomeAudit?.lease;
|
|
40
|
-
const oldOwnerDead = oldLease && await leaseOwnerDead(oldLease.owner, processIdentity);
|
|
41
|
-
if (oldLease && (Date.now() >= oldLease.expiresAt || oldOwnerDead)) {
|
|
42
|
-
// The final provider total cannot be recovered from a dead owner's memory.
|
|
43
|
-
await accountAuditUsage(root, record, oldLease.id, null, writeRecord);
|
|
44
|
-
await writeRecord(root, record.provider, record.session, live => {
|
|
45
|
-
if (live?.run?.managed?.outcomeAudit?.lease?.id === oldLease.id) {
|
|
46
|
-
delete live.run.managed.outcomeAudit.lease;
|
|
47
|
-
// A confirmed dead process cannot deliver late usage. Settle its dedupe
|
|
48
|
-
// slot, while retaining unknown consumption and every hard-budget block.
|
|
49
|
-
// Expiry alone does not prove death, so live-owner receipts stay pending.
|
|
50
|
-
if (oldOwnerDead) {
|
|
51
|
-
const receipt = live.run.managed.outcomeAudit.usageReceipts?.find(row => row.id === oldLease.id);
|
|
52
|
-
if (receipt) receipt.settled = true;
|
|
53
|
-
}
|
|
54
|
-
live.run.managed.outcomeAudit.lastFailure = { at: Date.now(), code: 'audit_owner_exited' };
|
|
55
|
-
}
|
|
56
|
-
return live;
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
67
|
const claimed = await writeRecord(root, record.provider, record.session, live => {
|
|
60
68
|
if (signal?.aborted || !canAuditOutcome(live) || contract(live) !== key || live.run.managed.outcomeAudit?.lease) return live;
|
|
61
69
|
live.run.managed.outcomeAudit = { ...live.run.managed.outcomeAudit,
|
|
70
|
+
lastAttempt: { requestId: request.id, attemptId: id, stage: 'preparing', session: null },
|
|
62
71
|
lease: { id, owner, requestId: request.id, contract: key, startedAt: Date.now(), expiresAt: request.deadlineAt } };
|
|
63
72
|
return live;
|
|
64
73
|
});
|
|
65
74
|
if (claimed?.run?.managed?.outcomeAudit?.lease?.id !== id) return { received: false, reason: 'audit_lease_busy' };
|
|
66
75
|
const view = structuredClone(claimed), m = view.run.managed;
|
|
76
|
+
// Routine purpose alignment asks for a colleague's opinion. Existing outcome
|
|
77
|
+
// gaps and completion verification retain the canonical acceptance path.
|
|
78
|
+
const opinionOnly = isOpinionOnly(findings);
|
|
67
79
|
const plan = purposeDiagnosisPlan(m, 'Independently compare the original user request and later corrections with actual outcomes. Inspect omitted targets, runtime adoption, existing state, evidence and remaining work; do not accept an implementer plan as the original goal.');
|
|
68
80
|
m.autonomyPolicy = 'autonomous'; m.resumeModel = metadata.model;
|
|
69
81
|
m.purpose = { ...m.purpose, enabled: true, version: 2, decisionPolicy: 'llm-first-hc102' };
|
|
@@ -82,38 +94,72 @@ async function auditConsistencyAttempt(record, findings, request, { root, signal
|
|
|
82
94
|
signal?.addEventListener('abort', abort, { once: true });
|
|
83
95
|
if (signal?.aborted) abort();
|
|
84
96
|
let result;
|
|
97
|
+
let diagnosticClosed = false, activityObserved = false, diagnosticWrites = Promise.resolve();
|
|
98
|
+
const retainDiagnostic = (stage, sessionId) => {
|
|
99
|
+
if (diagnosticClosed || controller.signal.aborted) return Promise.resolve();
|
|
100
|
+
diagnosticWrites = diagnosticWrites.then(() => writeRecord(root, record.provider, record.session, live => {
|
|
101
|
+
const audit = live?.run?.managed?.outcomeAudit;
|
|
102
|
+
if (diagnosticClosed || controller.signal.aborted || Date.now() >= request.deadlineAt ||
|
|
103
|
+
!canAuditOutcome(live) || contract(live) !== key || audit?.lease?.id !== id ||
|
|
104
|
+
audit.lease.requestId !== request.id || audit.lastAttempt?.attemptId !== id ||
|
|
105
|
+
audit.lastAttempt.requestId !== request.id) return live;
|
|
106
|
+
if (sessionId && audit.lastAttempt.session && audit.lastAttempt.session !== sessionId) return live;
|
|
107
|
+
audit.lastAttempt.stage = stage;
|
|
108
|
+
if (sessionId) audit.lastAttempt.session = sessionId;
|
|
109
|
+
return live;
|
|
110
|
+
}));
|
|
111
|
+
diagnosticWrites.catch(() => controller.abort());
|
|
112
|
+
return diagnosticWrites;
|
|
113
|
+
};
|
|
85
114
|
let usage = null, usageWrites = Promise.resolve();
|
|
86
115
|
try {
|
|
87
|
-
result = await execute(view, { root, signal: controller.signal, openClient, auditOnly: true,
|
|
116
|
+
result = await execute(view, { root, signal: controller.signal, openClient, auditOnly: true, opinionOnly, peerAction: record.run.managed.decision?.action, authorizePreparation:beforeDispatch,
|
|
117
|
+
beforeDispatch: async () => { await beforeDispatch(); await retainDiagnostic('dispatching'); },
|
|
118
|
+
onEvidence: evidence => {
|
|
119
|
+
const context = evidence?.workerContext;
|
|
120
|
+
if (evidence?.source !== 'adapter_capture' || evidence.actionId !== id || context?.originalSession !== record.session ||
|
|
121
|
+
typeof context.session !== 'string' || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(context.session) || context.session === record.session) return;
|
|
122
|
+
return retainDiagnostic('session_bound', context.session);
|
|
123
|
+
},
|
|
124
|
+
onActivity: () => { if (!activityObserved) { activityObserved = true; retainDiagnostic('provider_active'); } },
|
|
88
125
|
onUsage: value => { usage = value; usageWrites = usageWrites.then(() => accountAuditUsage(root, record, id, value, writeRecord)); usageWrites.catch(() => controller.abort()); if (m.maxTokens && Number.isFinite(value?.tokens) && (m.tokensUsed || 0) + value.tokens >= m.maxTokens) controller.abort(); } });
|
|
126
|
+
await retainDiagnostic('result_received');
|
|
89
127
|
await beforeDispatch();
|
|
90
|
-
const review = result.verification;
|
|
128
|
+
const review = opinionOnly ? result.opinion : result.verification;
|
|
91
129
|
if (!review || review.source !== 'independent_codex_session' || !review.session || review.session === record.session) throw new Error('Independent audit provenance unavailable.');
|
|
130
|
+
if (opinionOnly && (typeof review.text !== 'string' || !review.text.trim())) throw new Error('Independent opinion was empty.');
|
|
92
131
|
let persisted = false;
|
|
93
132
|
await writeRecord(root, record.provider, record.session, live => {
|
|
94
133
|
if (signal?.aborted || Date.now() >= request.deadlineAt || !live?.run?.managed || contract(live) !== key || live.run.managed.outcomeAudit?.lease?.id !== id || !canAuditOutcome(live)) return live;
|
|
95
134
|
const audit = live.run.managed.outcomeAudit;
|
|
96
|
-
audit.
|
|
135
|
+
if (opinionOnly) audit.lastOpinion = { source: review.source, session: review.session, ...(review.model ? { model: review.model } : {}), text: evidenceExcerpt(review.text, 2000).text, at: Date.now() };
|
|
136
|
+
else audit.lastReport = review;
|
|
137
|
+
audit.lastSuccessAt = Date.now(); audit.requestId = request.id;
|
|
97
138
|
audit.targetContract = key; audit.lastFailure = null;
|
|
98
139
|
// Existing finding reconciliation enforces stable IDs, counterevidence
|
|
99
140
|
// and reopen reasons. A delivered audit never certifies task completion.
|
|
100
|
-
if (completionClaimUnbound(live)) {
|
|
141
|
+
if (!opinionOnly && completionClaimUnbound(live)) {
|
|
101
142
|
audit.completionRecovery = { at: Date.now(), priorState: live.run.state, priorRevision: live.run.revision };
|
|
102
143
|
live.run.revision++;
|
|
103
144
|
queuePurposeVerification(live.run, 'Prior terminal claim has no current controller completion receipt. Independently recheck the original frozen acceptance and remaining defects; an audit report alone cannot certify completion.', Date.now());
|
|
104
|
-
} else if (live.run.managed.purpose?.enabled) reconcileFindings(live.run.managed, review, Date.now());
|
|
145
|
+
} else if (!opinionOnly && live.run.managed.purpose?.enabled) reconcileFindings(live.run.managed, review, Date.now());
|
|
105
146
|
persisted = true;
|
|
106
147
|
return live;
|
|
107
148
|
});
|
|
108
|
-
return { received: true, audited: persisted, ...(persisted ? { passed: review.passed } : { reason: 'target_changed' }) };
|
|
149
|
+
return opinionOnly && persisted ? { received: true, advised: true } : { received: true, audited: persisted, ...(persisted ? { passed: review.passed } : { reason: 'target_changed' }) };
|
|
109
150
|
} catch (error) {
|
|
110
|
-
|
|
151
|
+
// Persist only fixed classifications, never provider exception text or metadata.
|
|
152
|
+
const safeCode = ['cancelled', 'closed', 'identity', 'unsupported', 'approval', 'permission_restore', 'manual_provider_action', 'timeout',
|
|
153
|
+
'unavailable', 'authentication_required', 'permission_narrowed', 'provider_approval', 'archived', 'context_unavailable',
|
|
154
|
+
'checkpoint', 'decision', 'response_too_large', 'revision_changed', 'turn_failed', 'turn_settling'].includes(error?.code) ? error.code : 'independent_audit_failed';
|
|
111
155
|
await writeRecord(root, record.provider, record.session, live => {
|
|
112
|
-
if (live?.run?.managed?.outcomeAudit?.lease?.id === id) live.run.managed.outcomeAudit.lastFailure = { at: Date.now(), code: safeCode };
|
|
156
|
+
if (live?.run?.managed?.outcomeAudit?.lease?.id === id) live.run.managed.outcomeAudit.lastFailure = { at: Date.now(), code: safeCode, requestId: request.id, attemptId: id };
|
|
113
157
|
return live;
|
|
114
158
|
});
|
|
115
159
|
return { received: true, audited: false, reason: 'independent_audit_failed' };
|
|
116
160
|
} finally {
|
|
161
|
+
diagnosticClosed = true;
|
|
162
|
+
try { await diagnosticWrites; } catch { /* The attempt already failed closed. */ }
|
|
117
163
|
signal?.removeEventListener('abort', abort);
|
|
118
164
|
let usageWriteFailed = false;
|
|
119
165
|
try { await usageWrites; } catch { usageWriteFailed = true; }
|
|
@@ -132,7 +178,7 @@ async function accountAuditUsage(root, record, id, observed, writeRecord = updat
|
|
|
132
178
|
await writeRecord(root, record.provider, record.session, live => {
|
|
133
179
|
if (!live?.run?.managed || live.run.id !== record.run.id || live.run.managed.taskId !== record.run.managed.taskId) return live;
|
|
134
180
|
const audit = live.run.managed.outcomeAudit ||= {};
|
|
135
|
-
const measured = observed?.source
|
|
181
|
+
const measured = ['codex_app_server', 'codex_exec'].includes(observed?.source) && Number.isSafeInteger(observed.tokens) && observed.tokens >= 0;
|
|
136
182
|
audit.usageReceipts ||= [];
|
|
137
183
|
let receipt = audit.usageReceipts.find(row => row.id === id);
|
|
138
184
|
if (!receipt) { receipt = { id, tokens: 0, source: measured ? 'measured' : 'unknown' }; audit.usageReceipts.push(receipt); }
|
|
@@ -155,17 +201,25 @@ async function accountAuditUsage(root, record, id, observed, writeRecord = updat
|
|
|
155
201
|
// An inspection deadline bounds one read-only audit, not the user's task.
|
|
156
202
|
// Abort fences all late report adoption. Original effect leases are untouched.
|
|
157
203
|
export async function auditConsistencyTask(record, findings, request, options = {}) {
|
|
204
|
+
record = await recoverOutcomeAuditLease(record, options);
|
|
158
205
|
if (!canAuditOutcome(record)) return { received: false, reason: 'audit_boundary' };
|
|
159
206
|
const controller = new AbortController();
|
|
160
207
|
const abort = () => controller.abort();
|
|
161
208
|
options.signal?.addEventListener('abort', abort, { once: true });
|
|
162
209
|
if (options.signal?.aborted) abort();
|
|
163
210
|
const attemptId = randomUUID();
|
|
164
|
-
|
|
165
|
-
|
|
211
|
+
// Full outcome inspection reads artifacts and checks the original contract.
|
|
212
|
+
// Keep it finite, but do not give it the tools-disabled opinion's deadline.
|
|
213
|
+
const requestedTimeout = options.auditTimeoutMs ?? (isOpinionOnly(findings) ? 120_000 : 300_000);
|
|
214
|
+
if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) throw new Error('Invalid audit inspection deadline.');
|
|
215
|
+
const deadlineAt = Math.min(Date.now() + requestedTimeout,
|
|
216
|
+
Number.isFinite(record.run.deadline) ? record.run.deadline : Infinity);
|
|
217
|
+
const timeoutMs = Math.max(1, deadlineAt - Date.now());
|
|
166
218
|
let timer, writes = Promise.resolve();
|
|
167
219
|
const writeRecord = (...args) => {
|
|
168
220
|
const pending = writes.catch(() => {}).then(async () => {
|
|
221
|
+
// The standard writer already bounds acquisition retries before mutation.
|
|
222
|
+
if (!options.writeRecord) return updateRecord(...args);
|
|
169
223
|
for (let retry = 0; ; retry++) {
|
|
170
224
|
try { return await (options.writeRecord || updateRecord)(...args); }
|
|
171
225
|
catch (error) {
|
|
@@ -176,7 +230,6 @@ export async function auditConsistencyTask(record, findings, request, options =
|
|
|
176
230
|
});
|
|
177
231
|
writes = pending; return pending;
|
|
178
232
|
};
|
|
179
|
-
const deadlineAt = Date.now() + timeoutMs;
|
|
180
233
|
let attemptFinished = false, outerFinished = false;
|
|
181
234
|
const settleReceipt = async () => {
|
|
182
235
|
await writeRecord(options.root, record.provider, record.session, live => {
|
|
@@ -201,7 +254,7 @@ export async function auditConsistencyTask(record, findings, request, options =
|
|
|
201
254
|
await writeRecord(options.root, record.provider, record.session, live => {
|
|
202
255
|
if (live?.run?.managed?.outcomeAudit?.lease?.id === attemptId) {
|
|
203
256
|
delete live.run.managed.outcomeAudit.lease;
|
|
204
|
-
live.run.managed.outcomeAudit.lastFailure = { at: Date.now(), code: 'audit_timeout' };
|
|
257
|
+
live.run.managed.outcomeAudit.lastFailure = { at: Date.now(), code: 'audit_timeout', requestId: request.id, attemptId };
|
|
205
258
|
}
|
|
206
259
|
return live;
|
|
207
260
|
});
|
|
@@ -4,7 +4,7 @@ import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/accepta
|
|
|
4
4
|
import { purposeFailureMode } from '../codex-plugins/simy/hooks/purpose-search.mjs';
|
|
5
5
|
import { realpath } from 'node:fs/promises';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { TERMINAL as TERMINAL_RUNS, advanceDeliveryControlRevision, canUpgradeDelivery, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord, recordProviderArchive } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
7
|
+
import { TERMINAL as TERMINAL_RUNS, advanceDeliveryControlRevision, deliveryDecisionRevision, canUpgradeDelivery, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord, recordProviderArchive } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
8
8
|
import { listCodexSessions, codexThreadMetadata, openCodexAppServer, readCodexSession, readCodexArchiveStates, CodexAppServerError } from './codex-app-server.js';
|
|
9
9
|
import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
|
|
10
10
|
import { loadPurposeRuleSet } from './purpose-instruction.js';
|
|
@@ -554,3 +554,24 @@ export async function continueSession(options) {
|
|
|
554
554
|
export async function answerSession({ root, provider, session, taskId, expectedRunId, expectedRevision, questionId, answer, now = Date.now() }) {
|
|
555
555
|
return controlDelivery({ root, provider, session, taskId, expectedRunId, expectedRevision, questionId, answer, action: 'session_answer', now });
|
|
556
556
|
}
|
|
557
|
+
|
|
558
|
+
// Explicit user resume observes the existing native conversation. A model
|
|
559
|
+
// selection changed in Codex is configuration, not a different task or authority.
|
|
560
|
+
export async function resumeSession({ openClient = openCodexAppServer, ...options }) {
|
|
561
|
+
const record = await readRecord(options.root, options.provider, options.session);
|
|
562
|
+
if (options.provider !== 'codex' || record?.run?.managed?.resumeChannel !== 'codex_app_server')
|
|
563
|
+
return controlDelivery({ ...options, action: 'resume' });
|
|
564
|
+
if (record.run.managed.taskId !== options.taskId) throw new Error('Managed task identity changed; refresh its status.');
|
|
565
|
+
const link = await requireProviderLink(options);
|
|
566
|
+
const client = await openClient({ isolateConnectors: true });
|
|
567
|
+
let metadata;
|
|
568
|
+
try { metadata = await readCodexSession(client, options.session); }
|
|
569
|
+
finally { await client.close(); }
|
|
570
|
+
const cwd = await realpath(metadata.cwd);
|
|
571
|
+
if (cwd !== record.cwd || !metadata.model) throw new Error('Original Codex workspace or model is unavailable; inspect before resuming.');
|
|
572
|
+
await requireProviderLink({ ...options, expectedEpoch: link.epoch });
|
|
573
|
+
return controlDelivery({ ...options, action: 'resume',
|
|
574
|
+
expectedRunId: options.expectedRunId ?? record.run.id,
|
|
575
|
+
expectedRevision: options.expectedRevision ?? deliveryDecisionRevision(record.run),
|
|
576
|
+
resumeObservation: { session: metadata.session, cwd, model: metadata.model, source: 'codex_app_server', providerEpoch: link.epoch } });
|
|
577
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { isAbsolute, relative, join } from 'node:path';
|
|
2
|
+
import { requireProviderLink } from '../provider-links.js';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { realpath, open } from 'node:fs/promises';
|
|
5
|
+
import { taskDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
6
|
+
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { constants } from 'node:fs';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
export const authorityDigest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
13
|
+
|
|
14
|
+
// New explicit delegation is separate from historical enrollment. It never
|
|
15
|
+
// authorizes connectors or overrides a subsequently narrowed native policy.
|
|
16
|
+
export async function delegatedNativeSandbox(record, kind, root, { signal } = {}) {
|
|
17
|
+
if (!['read', 'local_edit', 'local_test'].includes(kind)) return null;
|
|
18
|
+
const grant = record.run?.managed?.nativeDelegation;
|
|
19
|
+
if (!grant) return null;
|
|
20
|
+
const { digest, ...body } = grant;
|
|
21
|
+
if (digest !== authorityDigest(body) || body.schema !== 1 || body.source !== 'explicit_native_redelegation' ||
|
|
22
|
+
body.taskId !== record.run.managed.taskId || body.runId !== record.run.id || body.session !== record.session ||
|
|
23
|
+
body.cwd !== record.cwd || body.executionCwd !== taskDirectory(record) ||
|
|
24
|
+
body.consentEpoch !== (record.providerConsentEpoch ?? null) || !body.consentRef ||
|
|
25
|
+
body.observation?.type !== 'sdk_rollout_turn_context' || body.observation.session !== record.session ||
|
|
26
|
+
body.observation.policyDigest !== authorityDigest(body.policy)) return null;
|
|
27
|
+
try { if (await realpath(body.observation.cwd) !== record.cwd) return null; } catch { return null; }
|
|
28
|
+
try { await requireProviderLink({ root, provider: record.provider, expectedEpoch: body.providerEpoch }); } catch { return null; }
|
|
29
|
+
// A local-only renewal is narrower than the observed native ceiling.
|
|
30
|
+
// Network/connector use needs separate existing authority.
|
|
31
|
+
const gitRoots = await authorizedWorktreeGitRoots(body.executionCwd, body.cwd, [body.policy], { signal });
|
|
32
|
+
return { type: 'workspaceWrite', writableRoots: [body.executionCwd, ...gitRoots], networkAccess: false,
|
|
33
|
+
excludeTmpdirEnvVar: true, excludeSlashTmp: true };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function canWriteWorkspace(sandbox, cwd, sessionCwd) {
|
|
37
|
+
if (sandbox?.type === 'dangerFullAccess') return true;
|
|
38
|
+
if (sandbox?.type !== 'workspaceWrite' || !Array.isArray(sandbox.writableRoots)) return false;
|
|
39
|
+
// Codex workspaceWrite always includes its verified thread cwd. The roots
|
|
40
|
+
// array lists *additional* writable directories and is usually empty.
|
|
41
|
+
return [sessionCwd, ...sandbox.writableRoots].some(root => {
|
|
42
|
+
if (typeof root !== 'string' || !isAbsolute(root)) return false;
|
|
43
|
+
const path = relative(root, cwd);
|
|
44
|
+
return !isAbsolute(path) && path !== '..' && !path.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
// Linked worktrees keep their index and object/ref store outside their checkout.
|
|
50
|
+
// Only the original repository's registered link can supply additional roots,
|
|
51
|
+
// and every native ceiling must already cover them. This is not a new grant.
|
|
52
|
+
export async function authorizedWorktreeGitRoots(cwd, sessionCwd, policies, { signal } = {}) {
|
|
53
|
+
if (!policies?.length) return [];
|
|
54
|
+
try {
|
|
55
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
|
|
56
|
+
const git = async (where, args) => (await exec('git', ['-C', where, ...args], {
|
|
57
|
+
env, timeout: 5000, maxBuffer: 64 * 1024, windowsHide: true, signal,
|
|
58
|
+
})).stdout.trim();
|
|
59
|
+
const checkout = await realpath(cwd), original = await realpath(sessionCwd);
|
|
60
|
+
const top = await realpath(await git(checkout, ['rev-parse', '--show-toplevel']));
|
|
61
|
+
if (top !== checkout) return [];
|
|
62
|
+
const gitDir = await realpath(await git(checkout, ['rev-parse', '--absolute-git-dir']));
|
|
63
|
+
const common = await realpath(await git(checkout, ['rev-parse', '--path-format=absolute', '--git-common-dir']));
|
|
64
|
+
// Ordinary checkouts need no extra worktree administration outside cwd.
|
|
65
|
+
if (gitDir === common) return [];
|
|
66
|
+
const originalCommon = await realpath(await git(original, ['rev-parse', '--path-format=absolute', '--git-common-dir']));
|
|
67
|
+
if (common !== originalCommon || !canWriteWorkspace({ type: 'workspaceWrite', writableRoots: [] }, gitDir, join(common, 'worktrees'))) return [];
|
|
68
|
+
// A forged .git pointer must not expose another checkout's administration.
|
|
69
|
+
const file = await open(join(gitDir, 'gitdir'), constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
|
|
70
|
+
let backlink;
|
|
71
|
+
try {
|
|
72
|
+
const stat = await file.stat();
|
|
73
|
+
if (!stat.isFile() || stat.size > 8192 || signal?.aborted) return [];
|
|
74
|
+
const buffer = Buffer.alloc(8193);
|
|
75
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
76
|
+
if (bytesRead > 8192 || signal?.aborted) return [];
|
|
77
|
+
backlink = buffer.subarray(0, bytesRead).toString('utf8').trim();
|
|
78
|
+
} finally { await file.close(); }
|
|
79
|
+
if (await realpath(backlink) !== await realpath(join(checkout, '.git'))) return [];
|
|
80
|
+
const roots = [...new Set([gitDir, common])];
|
|
81
|
+
// workspace-write protects .git implicitly. An enclosing cwd/root does not
|
|
82
|
+
// authorize making Git metadata writable; require an exact explicit root.
|
|
83
|
+
if (!roots.every(path => policies.every(policy => policy?.type === 'dangerFullAccess' || policy?.type === 'workspaceWrite' && policy.writableRoots?.some(root => root === path)))) return [];
|
|
84
|
+
return roots;
|
|
85
|
+
} catch { return []; }
|
|
86
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recallPerspectives, perspectiveInstruction } from '../codex-plugins/simy/hooks/perspective-memory.mjs';
|
|
1
2
|
import { loadHypothesisKnowledge, hypothesisKnowledgeInstruction } from '../sqm/hypothesis-knowledge.js';
|
|
2
3
|
import { selectiveRuleReview, applicableRuleIds } from '../codex-plugins/simy/hooks/purpose-rule-policy.mjs';
|
|
3
4
|
import { searchInstruction } from '../codex-plugins/simy/hooks/purpose-search.mjs';
|
|
@@ -38,12 +39,13 @@ export async function loadPurposeRuleSet() {
|
|
|
38
39
|
if (!rules.length || new Set(rules.map(rule => rule.id)).size !== rules.length) throw new Error('Purpose rule index is empty or ambiguous.');
|
|
39
40
|
return { digest: createHash('sha256').update(JSON.stringify(sources)).digest('hex'), rules };
|
|
40
41
|
}
|
|
41
|
-
export async function buildPurposeInstruction(managed, { reviewer = false, cwd, webOrigin, allowNetwork = false, signal, knowledgeLoader = loadHypothesisKnowledge, authorizeKnowledgeNetwork } = {}) {
|
|
42
|
+
export async function buildPurposeInstruction(managed, { reviewer = false, cwd, webOrigin, allowNetwork = false, signal, knowledgeLoader = loadHypothesisKnowledge, authorizeKnowledgeNetwork, root: memoryRoot, record, memoryLoader = recallPerspectives } = {}) {
|
|
42
43
|
if (!managed?.purpose?.enabled) return '';
|
|
43
44
|
const ruleSet = await loadPurposeRuleSet();
|
|
44
45
|
if (ruleSet.digest !== managed.purpose.ruleDigest) throw Object.assign(new Error('Purpose workflow changed; refresh its rule inventory before dispatch.'), { code: 'purpose_rules_changed' });
|
|
45
46
|
if (reviewer && selectiveRuleReview(managed)) return `${llmDecisionInstruction}\nIndependently verify the original acceptance criteria and applicable canonical obligations from actual evidence. Consult the inventory as needed after forming an LLM judgment; do not enumerate every ID or impose core controls as universal checklist gates. Report only rules actually evaluated; omitted inventory entries are not passed or not_applicable. Known applicable obligations ${JSON.stringify(applicableRuleIds(managed))} must be independently assessed and cannot be discarded by worker claims. Do not copy worker dispositions as proof. Failed, unavailable or missing evidence remains unverified with a concrete acquisition/repair finding. No unrelated rule coverage obligation is implied.\nruleSet: ${JSON.stringify(ruleSet.rules.map(rule => ({ ...rule, path: fileURLToPath(new URL(rule.file, root)) })))}`;
|
|
46
47
|
if (reviewer) return `${llmDecisionInstruction}\nCanonical rule inventory. Review applicability and evidence for EVERY ID, including HC-01 through HC-102; these controls are requirements, not proof that their mechanisms exist. Read the referenced sections and acceptance details as needed. User instructions retain priority. Do not impose unrelated development/release controls on other task types. Core controls ${CORE_CONTROL_IDS.join(', ')} apply to every autonomous task; inspect their actual bounded behavior, including conditional duties whose trigger did not occur, rather than marking the entire control not_applicable. Return rules:[{id,evidenceRefs:[{ref,target,observation}],status:"satisfied|failed|unverified|not_applicable",basis:"inspected evidence or specific applicability reason"}]. Keep basis concise with source locators; stored receipts are limited to 160 UTF-8 bytes and basisTruncated means re-read the original evidence. Never copy worker dispositions as independent proof. Missing capability is unverified and requires an acquisition/repair finding, not a human certification request.\nruleSet: ${JSON.stringify(ruleSet.rules.map(rule => ({ ...rule, path: fileURLToPath(new URL(rule.file, root)) })))}`;
|
|
47
48
|
const knowledge = await knowledgeLoader({ cwd, webOrigin, allowNetwork, signal, ...(authorizeKnowledgeNetwork ? { authorizeNetwork: authorizeKnowledgeNetwork } : {}) });
|
|
48
|
-
|
|
49
|
+
const memory = await memoryLoader({ root: memoryRoot, record, signal });
|
|
50
|
+
return `${purposeContract(managed)}\n${perspectiveInstruction(memory)}\n${hypothesisKnowledgeInstruction(knowledge, { flexible: selectiveRuleReview(managed) })}\n${searchInstruction(managed)}\nForm a grounded candidate judgment with LLM reasoning first; consult the applicable canonical rule sections using read tools to challenge that judgment before its effects. This ruleSet is authoritative workflow material; user instructions retain priority. ${selectiveRuleReview(managed) ? "Review applicable rules and known obligations only; do not require full inventory coverage." : "Use the purpose contract for full initial rule coverage and changed dispositions on subsequent search turns."} Missing sources or unavailable tools remain unverified; do not fabricate coverage. Repository AGENTS.md and explicit user constraints also apply.\nruleSet: ${JSON.stringify(ruleSet.rules.map(rule => ({ ...rule, path: fileURLToPath(new URL(rule.file, root)) })))}\nPrior rule dispositions (provider reported, re-evaluate applicability): ${JSON.stringify(managed.purpose.version === 2 ? managed.purpose.lastReview?.rules || [] : [])}\nLast purpose review (provider reported): ${JSON.stringify(managed.purpose.lastReview ? { gap: managed.purpose.lastReview.gap, hypothesis: managed.purpose.lastReview.hypothesis, noChangeAssessment: managed.purpose.lastReview.noChangeAssessment, observations: managed.purpose.lastReview.observations, unresolvedRules: managed.purpose.lastReview.rules.filter(rule => ['failed', 'unverified'].includes(rule.status)) } : null)}\nRecovery context: ${JSON.stringify(managed.purpose.lastRecovery || null)}`;
|
|
49
51
|
}
|
|
@@ -23,15 +23,17 @@ function auditReinspectionEligible(run, now) {
|
|
|
23
23
|
export async function observePurposeTurn(record, { openClient = openCodexAppServer } = {}) {
|
|
24
24
|
const client = await openClient();
|
|
25
25
|
try {
|
|
26
|
-
const
|
|
27
|
-
|
|
26
|
+
const wait=record.run.managed.nativeWait || record.run.managed.purpose?.nativeBoundary;
|
|
27
|
+
if (wait && !wait.session) return {state:'unknown'};
|
|
28
|
+
const session=wait?.session || record.session;
|
|
29
|
+
const cwd=session===record.session?record.cwd:wait?.cwd;
|
|
28
30
|
if(!cwd)return {state:'unknown'};
|
|
29
31
|
const metadata = await readCodexSession(client, session);
|
|
30
32
|
if (await realpath(metadata.cwd) !== cwd || metadata.model !== record.run.managed.resumeModel) return { state: 'unknown' };
|
|
31
33
|
const result = await client.request('thread/turns/list', { threadId: session, limit: 1, sortDirection: 'desc', itemsView: 'summary' });
|
|
32
34
|
const turn = result?.data?.[0];
|
|
33
35
|
if (!turn?.id || !['completed', 'failed', 'interrupted', 'inProgress'].includes(turn.status)) return { state: 'unknown' };
|
|
34
|
-
return { state: turn.status === 'inProgress' ? 'running' : 'idle', turnId: turn.id, status: turn.status };
|
|
36
|
+
return { state: turn.status === 'inProgress' ? 'running' : 'idle', turnId: turn.id, status: turn.status, session, cwd };
|
|
35
37
|
} finally { await client.close(); }
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -50,7 +52,7 @@ export async function observeUpgradeMetadata(record, { openClient = openCodexApp
|
|
|
50
52
|
finally { await client.close(); }
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
export async function reconcilePurposeTasks({ root, signal, prepareCandidate = preparePurposeCandidate, now = Date.now(), observeTurn = observePurposeTurn, observeAuthentication = observeCodexAuthentication, readRecords = () => listRecords(root), observeMetadata = observeUpgradeMetadata }) {
|
|
55
|
+
export async function reconcilePurposeTasks({ root, signal, prepareCandidate = preparePurposeCandidate, now = Date.now(), observeTurn = observePurposeTurn, observeAuthentication = observeCodexAuthentication, readRecords = () => listRecords(root), observeMetadata = observeUpgradeMetadata, writeRecord = updateRecord }) {
|
|
54
56
|
const started = Date.now();
|
|
55
57
|
let rules;
|
|
56
58
|
const deferred = [];
|
|
@@ -101,11 +103,30 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
|
|
|
101
103
|
if (!latest || !automaticContinuationAllowed(latest) || latest.run?.id !== run.id || latest.autoContinueEpoch !== record.autoContinueEpoch || ['paused', 'stopped'].includes(latest.run.state)) throw new Error('Purpose observation authorization changed.');
|
|
102
104
|
};
|
|
103
105
|
const observe = async read => { await authorized(); const result = await read(); await authorized(); return result; };
|
|
104
|
-
const persist = mutate =>
|
|
106
|
+
const persist = mutate => writeRecord(root, record.provider, record.session, async current => {
|
|
105
107
|
try { await requireProviderLink({ root, provider: record.provider, expectedEpoch: link.epoch }); } catch { return current; }
|
|
106
108
|
if (!current || !automaticContinuationAllowed(current) || current.autoContinueEpoch !== record.autoContinueEpoch) return current;
|
|
107
109
|
return mutate(current);
|
|
108
110
|
});
|
|
111
|
+
// Recheck the current budget and boundaries under the existing record lock.
|
|
112
|
+
// Audit usage can change without changing the run revision or updatedAt.
|
|
113
|
+
const nativeCurrent = current => {
|
|
114
|
+
const latest = current?.run, managed = latest?.managed;
|
|
115
|
+
return latest?.id === run.id && latest.revision === run.revision && latest.state === run.state && latest.updatedAt === run.updatedAt &&
|
|
116
|
+
!['paused', 'stopped', 'completed', 'budget_exhausted'].includes(latest.state) && !managed.resumeLease &&
|
|
117
|
+
!managed.pendingApproval && !managed.liveWorkspaceLease && !managed.outcomeAudit?.lease &&
|
|
118
|
+
!['pending', 'transferred', 'pending_received'].includes(managed.handoff?.status) && !managed.rejectedActionOverflow &&
|
|
119
|
+
!managed.rejectedActionDigests?.includes(createHash('sha256').update(JSON.stringify(managed.decision?.action ?? null)).digest('hex')) &&
|
|
120
|
+
!managedBudgetReason(latest, Date.now()) &&
|
|
121
|
+
JSON.stringify(managed.nativeWait) === JSON.stringify(run.managed.nativeWait) &&
|
|
122
|
+
JSON.stringify(managed.purpose.nativeBoundary) === JSON.stringify(run.managed.purpose.nativeBoundary);
|
|
123
|
+
};
|
|
124
|
+
const nativeSessionMatches = observed => {
|
|
125
|
+
const wait = run.managed.nativeWait || run.managed.purpose.nativeBoundary;
|
|
126
|
+
const boundary = run.managed.purpose.nativeBoundary;
|
|
127
|
+
return Boolean(observed.session && observed.session === (wait ? wait.session : record.session) &&
|
|
128
|
+
(!boundary || boundary.session === observed.session));
|
|
129
|
+
};
|
|
109
130
|
rules ||= await loadPurposeRuleSet();
|
|
110
131
|
if (run.managed.purpose.ruleDigest !== rules.digest && run.state !== 'auto_resuming') {
|
|
111
132
|
record = await persist(current => {
|
|
@@ -122,15 +143,19 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
|
|
|
122
143
|
}
|
|
123
144
|
if (run.managed.purpose.nativeBoundary && !run.managed.resumeLease && now >= (run.managed.purpose.nativeObservedAt || 0) + 60_000) {
|
|
124
145
|
let native;try {native=await observe(()=>observeTurn(record));} catch {native={state:'unknown'};}
|
|
146
|
+
if (!nativeSessionMatches(native)) continue;
|
|
147
|
+
let persisted = false;
|
|
125
148
|
await persist(current=>{
|
|
126
|
-
if(current
|
|
149
|
+
if(!nativeCurrent(current)) return current;
|
|
150
|
+
persisted = true;
|
|
127
151
|
const purpose=current.run.managed.purpose;purpose.nativeObservedAt=now;purpose.nativeObservation=native;
|
|
128
152
|
if(native.status==='completed' && native.turnId===purpose.nativeBoundary?.turnId && purpose.nativeBoundary.state!=='writer_completed_requires_verification') {
|
|
129
153
|
purpose.nativeBoundary={...purpose.nativeBoundary,state:'writer_completed_requires_verification',writer:'completed',completedTurnId:native.turnId,observedAt:now};
|
|
130
|
-
queuePurposeVerification(current.run,'Original pending turn completed; independently inspect its actual result and reconcile effects without replaying the operation.',now);
|
|
131
|
-
} else if (native.status==='completed' && native.turnId && native.turnId!==purpose.nativeBoundary?.turnId && native.turnId!==purpose.lastOwnedTurnId) {delete purpose.nativeBoundary;current.run.managed.nativeWait=null;purpose.nextSearchAt=0;}
|
|
154
|
+
queuePurposeVerification(current.run,'Original pending turn completed; independently inspect its actual result and reconcile effects without replaying the operation.',Date.now());
|
|
155
|
+
} else if (native.session===record.session && native.status==='completed' && native.turnId && native.turnId!==purpose.nativeBoundary?.turnId && native.turnId!==purpose.lastOwnedTurnId) {delete purpose.nativeBoundary;current.run.managed.nativeWait=null;purpose.nextSearchAt=0;}
|
|
132
156
|
return current;
|
|
133
157
|
});
|
|
158
|
+
if (!persisted) continue;
|
|
134
159
|
record=await readRecord(root,record.provider,record.session);run=record.run;
|
|
135
160
|
}
|
|
136
161
|
if (searchEnabled(run.managed) && run.managed.purpose.nativeBoundary && run.state==='blocked' && !run.managed.resumeLease &&
|
|
@@ -200,25 +225,31 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
|
|
|
200
225
|
if (now < (run.managed.purpose.lastObservedAt || 0) + 60_000) continue;
|
|
201
226
|
let observed;
|
|
202
227
|
try { observed = await observe(() => observeTurn(record)); } catch { observed = { state: 'unavailable' }; }
|
|
228
|
+
if (!nativeSessionMatches(observed)) continue;
|
|
229
|
+
let persisted = false;
|
|
203
230
|
await persist(current => {
|
|
204
|
-
if (current
|
|
231
|
+
if (!nativeCurrent(current)) return current;
|
|
232
|
+
persisted = true;
|
|
205
233
|
current.run.managed.purpose.lastObservedAt = now;
|
|
206
234
|
current.run.managed.purpose.observationState = observed.state;
|
|
207
|
-
if (!current.run.managed.nativeWait?.turnId && observed.turnId) current.run.managed.nativeWait = { turnId: observed.turnId, observedAt: now };
|
|
235
|
+
if (!current.run.managed.nativeWait?.turnId && observed.turnId) current.run.managed.nativeWait = { turnId: observed.turnId, session: observed.session, cwd: observed.cwd, observedAt: now };
|
|
208
236
|
return current;
|
|
209
237
|
});
|
|
238
|
+
if (!persisted) continue;
|
|
210
239
|
// A failed/interrupted owned turn or merely elapsed time is not a user
|
|
211
240
|
// answer. A subsequent completed original turn permits diagnosis only.
|
|
212
|
-
if (run.managed.nativeWait?.turnId && observed.status === 'completed' && observed.turnId !== run.managed.nativeWait.turnId) {
|
|
213
|
-
|
|
214
|
-
|
|
241
|
+
if (observed.session === record.session && run.managed.nativeWait?.turnId && observed.status === 'completed' && observed.turnId !== run.managed.nativeWait.turnId && observed.turnId !== run.managed.purpose.lastOwnedTurnId) {
|
|
242
|
+
let cleared = false;
|
|
243
|
+
const saved = await persist(current => {
|
|
244
|
+
if (nativeCurrent(current)) {
|
|
215
245
|
current.run.managed.nativeWait = null;
|
|
216
246
|
delete current.run.managed.purpose.nativeBoundary;
|
|
247
|
+
cleared = true;
|
|
217
248
|
}
|
|
218
249
|
return current;
|
|
219
250
|
});
|
|
220
|
-
|
|
221
|
-
|
|
251
|
+
if (!cleared) continue;
|
|
252
|
+
record = saved; run = saved.run;
|
|
222
253
|
obstacle = { type: 'native_wait_updated', detail: observed.turnId };
|
|
223
254
|
} else if (searchEnabled(run.managed)) {
|
|
224
255
|
obstacle = { type: 'native_wait_diagnosis', detail: run.managed.nativeWait?.turnId || 'writer_unknown' };
|
|
@@ -275,9 +306,10 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
|
|
|
275
306
|
const key = createHash('sha256').update(JSON.stringify([run.managed.verifiedRevision, obstacle, obstacle.type === 'external_dependency' ? run.managed.purpose.dependencyChecks || 0 : null])).digest('hex');
|
|
276
307
|
await persist(current => {
|
|
277
308
|
if (current?.run?.id !== run.id || current.run.revision !== run.revision || current.run.state !== run.state || current.run.updatedAt !== run.updatedAt) return current;
|
|
309
|
+
if (['native_wait_updated', 'native_wait_diagnosis'].includes(obstacle.type) && !nativeCurrent(current)) return current;
|
|
278
310
|
if (searchEnabled(current.run.managed) && ['original_context_updated', 'native_wait_updated'].includes(obstacle.type)) current.run.managed.purpose.nextSearchAt = 0;
|
|
279
311
|
const queued = queuePurposeDiagnosis(current.run,
|
|
280
|
-
`Reassess the original purpose after ${obstacle.type}: ${obstacle.detail}. Gather evidence before deciding that a person is needed. Preserve unresolved approvals/dependencies; choose independent work or a safe alternative where possible.`, key, now);
|
|
312
|
+
`Reassess the original purpose after ${obstacle.type}: ${obstacle.detail}. Gather evidence before deciding that a person is needed. Preserve unresolved approvals/dependencies; choose independent work or a safe alternative where possible.`, key, Date.now());
|
|
281
313
|
if (queued && obstacle.type === 'external_dependency') {
|
|
282
314
|
const purpose = current.run.managed.purpose;
|
|
283
315
|
purpose.dependencyChecks = (purpose.dependencyChecks || 0) + 1;
|