@awak-app/simy-cli 0.7.9 → 0.7.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/package.json +4 -3
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +12 -2
- package/src/codex-plugins/simy/hooks/hypothesis-sheet.mjs +1 -1
- package/src/codex-plugins/simy/hooks/purpose-cycle.mjs +6 -5
- package/src/codex-plugins/simy/hooks/recovery-incident.mjs +191 -0
- package/src/codex-plugins/simy/hooks/scene-reasoning.mjs +22 -0
- package/src/delivery-hooks/command.js +34 -1
- package/src/delivery-hooks/desktop.js +1 -1
- package/src/delivery-hooks/diagnosis-sheet.js +2 -2
- package/src/delivery-hooks/diagnosis.js +5 -3
- package/src/delivery-hooks/local-supervisor.js +7 -1
- package/src/delivery-hooks/recovery.js +33 -0
- package/src/delivery-hooks/supervisor-owner.js +74 -0
- package/src/delivery-hooks/supervisor-service.js +65 -0
- package/src/delivery-hooks/supervisor-watchdog.js +151 -0
- package/src/delivery-hooks/supervisor.js +26 -0
- package/src/desktop-host.js +36 -7
- package/src/direct-executor.js +2 -1
- package/src/orchestrator/instruction.js +4 -4
- package/src/orchestrator/loop.js +1 -1
- package/src/orchestrator/problem-solving.js +2 -2
- package/src/provider-stream.js +3 -0
- package/src/session-supervision/claude-desktop-lineage.js +19 -5
- package/src/session-supervision/claude-sessions.js +16 -10
- package/src/session-supervision/codex-app-server.js +17 -6
- package/src/session-supervision/continuation-analysis.js +3 -2
- package/src/session-supervision/index.js +25 -14
- package/src/session-supervision/pull-requests.js +52 -0
- package/src/session-supervision/purpose-instruction.js +10 -2
- package/src/session-supervision/purpose-recovery.js +7 -4
- package/src/session-supervision/work-state.js +84 -0
- package/src/sqm/claude-plugin.js +2 -0
- package/src/sqm/local-tests.js +1 -0
- package/src/sqm/review-worker.js +3 -0
|
@@ -174,20 +174,20 @@ export function buildCodingInstruction(
|
|
|
174
174
|
section(
|
|
175
175
|
"problem_solving_protocol",
|
|
176
176
|
[
|
|
177
|
-
"
|
|
177
|
+
"Use the shared scene reasoning cycle for implementation and retries. Let the current issue determine useful perspectives and the smallest next check; do not require a separate reasoning workflow.",
|
|
178
178
|
attemptNumber > 1
|
|
179
179
|
? "This is a retry. Repair clear failures directly; compare plausible causes when needed. Reasoning notes are advisory, not a prerequisite to editing."
|
|
180
180
|
: "For the first attempt, investigate material uncertainty before a risky operation; use a reasonable assumption for low-impact reversible choices and verify as you proceed.",
|
|
181
181
|
attemptNumber > 1
|
|
182
|
-
? "
|
|
183
|
-
: "
|
|
182
|
+
? "When useful, briefly note what changed the interpretation or next check; an extra reasoning explanation is not required to perform the repair."
|
|
183
|
+
: "Use existing result fields for actual checks and outcomes; reasoning notes and the hypothesis sheet are optional.",
|
|
184
184
|
].join("\n"),
|
|
185
185
|
),
|
|
186
186
|
section(
|
|
187
187
|
"execution_contract",
|
|
188
188
|
[
|
|
189
189
|
"Read the repository instructions and current git state before editing.",
|
|
190
|
-
"
|
|
190
|
+
"Use the shared scene reasoning cycle to investigate uncertainty that affects the selected operation. If that operation cannot safely proceed, retain its blocker and choose independent authorized work or a smaller useful check. Do not claim unresolved outcomes are complete.",
|
|
191
191
|
"Preserve user changes and keep the diff scoped to the requirement.",
|
|
192
192
|
"Run relevant tests, inspect the final diff, commit, push, and create or update a PR.",
|
|
193
193
|
`The PR must target ${charter.base_branch}.`,
|
package/src/orchestrator/loop.js
CHANGED
|
@@ -631,7 +631,7 @@ async function handleFailedAudit({
|
|
|
631
631
|
state.ai_strategy_diagnoses.push(key);
|
|
632
632
|
state.ai_strategy_diagnoses = state.ai_strategy_diagnoses.slice(-32);
|
|
633
633
|
findings.push({ passed: false, code: 'AI_STRATEGY_CHANGE_REQUIRED', severity: 'blocker', target: 'retry_loop',
|
|
634
|
-
explanation: 'Investigate the repeated failure with
|
|
634
|
+
explanation: 'Investigate the repeated failure with the shared scene reasoning cycle: understand this scene, derive and select useful perspectives, then connect the interpretation to the next check. Choose a different reversible method before another attempt; do not repeat uncertain effects or ask a person to certify technical correctness.', repairability: 'auto', blocker_kind: 'machine' });
|
|
635
635
|
snapshot.final_audit = { ...(snapshot.final_audit || {}), passed: false, findings, requires_human: false, auto_repairable: true };
|
|
636
636
|
appendEvent(snapshot, 're_instructing', 'AI diagnosis is required before treating technical repetition as an execution blocker.', { code: 'ai_strategy_change', attempt_number: attemptNumber });
|
|
637
637
|
await publish(snapshot, 're_instructing', onUpdate);
|
|
@@ -94,9 +94,9 @@ export function evaluateRetryProblemSolving(attempt, previousAttempt) {
|
|
|
94
94
|
export function problemSolvingResultContract() {
|
|
95
95
|
return [
|
|
96
96
|
hypothesisSheetInstruction,
|
|
97
|
-
"problem_solving must be an object
|
|
97
|
+
"problem_solving must be an object; {} is valid when no optional reasoning notes are useful. When recording notes, use hypotheses, selected_hypothesis_id, evidence_checked, strategy, and changed_from_previous.",
|
|
98
98
|
"Each hypothesis must use {id, statement}; evidence_checked must list concrete observations or commands.",
|
|
99
|
-
"
|
|
99
|
+
"Use the shared scene reasoning cycle on every attempt. Record useful assumptions or interpretations briefly when helpful; changed_from_previous may be null.",
|
|
100
100
|
"On retry, use reasoning proportional to the uncertainty. If the repair is clear, make it and verify it; do not invent a fixed number of hypotheses or change a working strategy merely to satisfy a form. For persistent unexplained failures, compare plausible causes and inspect discriminating evidence. These reasoning notes are advisory; unchanged failures are still subject to the retry circuit.",
|
|
101
101
|
].join("\n");
|
|
102
102
|
}
|
package/src/provider-stream.js
CHANGED
|
@@ -206,6 +206,9 @@ function formatClaudeToolResults(prefix, content) {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
function contentLines(prefix, label, value) {
|
|
209
|
+
// Native providers wrap machine results in assistant events too. Keep the
|
|
210
|
+
// result available to the parser without flooding the user transcript.
|
|
211
|
+
if (label === "assistant" && STRUCTURED_MARKERS.some(marker => String(value || "").trimStart().startsWith(marker))) return [];
|
|
209
212
|
return String(value || "")
|
|
210
213
|
.split(/\r?\n/)
|
|
211
214
|
.map((line) => cleanLine(line))
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizePullRequests } from './pull-requests.js';
|
|
1
2
|
import { constants } from 'node:fs';
|
|
2
3
|
import { lstat, open, readdir } from 'node:fs/promises';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
@@ -70,12 +71,14 @@ async function metadata(path, desktopId, sessions) {
|
|
|
70
71
|
let value = null;
|
|
71
72
|
if (item.sessionId === desktopId && UUID.test(item.cliSessionId || '') && typeof item.cwd === 'string' && isAbsolute(item.cwd)) {
|
|
72
73
|
value = { desktopId, session: item.cliSessionId, cwd: resolve(item.cwd),
|
|
74
|
+
...(complete ? { providerArchived: typeof item.isArchived === 'boolean' ? item.isArchived : typeof item.archived === 'boolean' ? item.archived : undefined,
|
|
75
|
+
pullRequests: normalizePullRequests(item.pullRequests || item.gitHubPullRequests || item.githubPullRequests || item.githubPRs || item.gitHubPRs || item.gitHubPR || item.pullRequest || (item.prUrl ? { url: item.prUrl, state: item.prStatus } : null)) } : {}),
|
|
73
76
|
parentDesktopId: complete && LOCAL.test(item.spawnedFrom?.sessionId || '') ? item.spawnedFrom.sessionId : null };
|
|
74
77
|
}
|
|
75
78
|
const after = await lstat(path, { bigint: true });
|
|
76
79
|
if (fingerprint(after) !== key || fingerprint(await handle.stat({ bigint: true })) !== key || after.isSymbolicLink()) return null;
|
|
77
80
|
if (!complete && !value) return null;
|
|
78
|
-
// Cache only identity
|
|
81
|
+
// Cache only identity, lineage, archive and PR summaries. Desktop files also contain private settings;
|
|
79
82
|
// none of those fields or conversation text leave this bounded reader.
|
|
80
83
|
cache.delete(path); cache.set(path, { key, value, complete });
|
|
81
84
|
while (cache.size > MAX_FILES) cache.delete(cache.keys().next().value);
|
|
@@ -86,7 +89,7 @@ async function metadata(path, desktopId, sessions) {
|
|
|
86
89
|
|
|
87
90
|
// Desktop spawn parents use local_* IDs, whereas transcripts and SIMY use CLI
|
|
88
91
|
// UUIDs. Resolve them within the same account AND organization partition.
|
|
89
|
-
export async function readClaudeDesktopLineage({ home = homedir(), root = claudeDesktopSessionRoot(home), signal, sessions = null } = {}) {
|
|
92
|
+
export async function readClaudeDesktopLineage({ home = homedir(), root = claudeDesktopSessionRoot(home), signal, sessions = null, includeState = false } = {}) {
|
|
90
93
|
if (signal?.aborted) return new Map();
|
|
91
94
|
const files = [];
|
|
92
95
|
for (const account of await directories(root)) {
|
|
@@ -122,12 +125,23 @@ export async function readClaudeDesktopLineage({ home = homedir(), root = claude
|
|
|
122
125
|
parentSession: { provider: 'claude', session: parent.session },
|
|
123
126
|
source: 'claude_desktop_spawned_from' });
|
|
124
127
|
}
|
|
128
|
+
if (includeState) for (const partition of partitions.values()) for (const item of partition.values()) {
|
|
129
|
+
if (claims.get(item.session) !== 1 || sessions && !sessions.has(item.session)) continue;
|
|
130
|
+
lineage.set(item.session, { cwd: item.cwd, ...lineage.get(item.session),
|
|
131
|
+
providerArchived: item.providerArchived, pullRequests: item.pullRequests || [], observedAt: Date.now() });
|
|
132
|
+
}
|
|
125
133
|
// Reject cyclic Desktop metadata rather than inventing a root or parent.
|
|
126
134
|
for (const session of [...lineage.keys()]) {
|
|
127
135
|
const seen = new Set(); let cursor = session;
|
|
128
136
|
while (lineage.has(cursor)) {
|
|
129
|
-
if (seen.has(cursor)) {
|
|
130
|
-
|
|
137
|
+
if (seen.has(cursor)) {
|
|
138
|
+
for (const id of seen) {
|
|
139
|
+
if (!includeState) lineage.delete(id);
|
|
140
|
+
else { const { parentSession, parentCwd, source, ...state } = lineage.get(id); lineage.set(id, state); }
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
seen.add(cursor); cursor = lineage.get(cursor).parentSession?.session;
|
|
131
145
|
}
|
|
132
146
|
}
|
|
133
147
|
return lineage;
|
|
@@ -138,7 +152,7 @@ export async function readClaudeDesktopLineage({ home = homedir(), root = claude
|
|
|
138
152
|
// discovery and unrelated provider controls must not wait for the entire scan.
|
|
139
153
|
export async function observeClaudeDesktopLineage(options = {}, { read = readClaudeDesktopLineage, budgetMs = 1500 } = {}) {
|
|
140
154
|
if (options.signal?.aborted) return new Map();
|
|
141
|
-
const root = options.root || claudeDesktopSessionRoot(options.home);
|
|
155
|
+
const root = (options.root || claudeDesktopSessionRoot(options.home)) + (options.includeState ? ':state' : ':lineage');
|
|
142
156
|
let entry = observations.get(root);
|
|
143
157
|
if (!entry) {
|
|
144
158
|
entry = { value: new Map(), pending: null };
|
|
@@ -6,7 +6,7 @@ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
|
6
6
|
import { boundConversation, CONVERSATION_LIMITS, textContent } from './conversation-context.js';
|
|
7
7
|
import { sessionTitle } from './session-titles.js';
|
|
8
8
|
import { RECENT_SESSION_LIMIT, recentSessionWindow } from './discovery-window.js';
|
|
9
|
-
import { observeClaudeDesktopLineage } from './claude-desktop-lineage.js';
|
|
9
|
+
import { observeClaudeDesktopLineage, readClaudeDesktopLineage } from './claude-desktop-lineage.js';
|
|
10
10
|
|
|
11
11
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
12
|
const MAX_PROJECTS = 256;
|
|
@@ -186,17 +186,19 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
|
|
|
186
186
|
}));
|
|
187
187
|
}
|
|
188
188
|
if (signal?.aborted) return [];
|
|
189
|
-
const
|
|
190
|
-
|
|
189
|
+
const desktopOptions = { home, signal, includeState: true, sessions: new Set(descriptors.map(item => item.session)) };
|
|
190
|
+
const desktopLineage = await (session ? readClaudeDesktopLineage(desktopOptions) : observeClaudeDesktopLineage(desktopOptions));
|
|
191
191
|
const decorateLineage = candidate => {
|
|
192
192
|
const origin = desktopLineage.get(candidate.session);
|
|
193
|
-
if (origin?.cwd === candidate.cwd
|
|
194
|
-
return { ...candidate,
|
|
193
|
+
if (origin?.cwd === candidate.cwd)
|
|
194
|
+
return { ...candidate, ...(typeof origin.providerArchived === 'boolean' ? { archived: origin.providerArchived } : {}), pullRequests: origin.pullRequests || [],
|
|
195
|
+
...(!candidate.parentSession && origin.parentSession ? { parentSession: origin.parentSession, parentSessionSource: origin.source } : {}) };
|
|
195
196
|
return candidate;
|
|
196
197
|
};
|
|
197
|
-
const selected = session ? descriptors :
|
|
198
|
-
limit: maximum, priority: item => priorities.has(item.session),
|
|
199
|
-
|
|
198
|
+
const selected = session ? descriptors : [
|
|
199
|
+
...recentSessionWindow(descriptors.filter(item => desktopLineage.get(item.session)?.providerArchived !== true), { limit: maximum, priority: item => priorities.has(item.session) }),
|
|
200
|
+
...recentSessionWindow(descriptors.filter(item => desktopLineage.get(item.session)?.providerArchived === true), { limit: maximum, priority: item => priorities.has(item.session) }),
|
|
201
|
+
];
|
|
200
202
|
const filesToInspect = selected.slice(0, MAX_TRANSCRIPTS);
|
|
201
203
|
let nextFile = 0;
|
|
202
204
|
// Catalog prefixes are bounded independently of full transcript size. Use a
|
|
@@ -218,7 +220,7 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
|
|
|
218
220
|
const attempted = new Set(result.keys());
|
|
219
221
|
for (const child of result.values()) {
|
|
220
222
|
const origin = desktopLineage.get(child.session);
|
|
221
|
-
if (!origin || child.parentSessionSource !== origin.source || [...result.values()].some(item => item.session === origin.parentSession.session && item.cwd === origin.parentCwd)) continue;
|
|
223
|
+
if (!origin?.parentSession || child.parentSessionSource !== origin.source || [...result.values()].some(item => item.session === origin.parentSession.session && item.cwd === origin.parentCwd)) continue;
|
|
222
224
|
for (const file of descriptors.filter(item => item.session === origin.parentSession.session)) {
|
|
223
225
|
const key = `${file.session}\0${origin.parentCwd}`;
|
|
224
226
|
if (attempted.has(key) || attempted.size >= MAX_TRANSCRIPTS || signal?.aborted) break;
|
|
@@ -243,7 +245,10 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
|
|
|
243
245
|
}
|
|
244
246
|
}
|
|
245
247
|
const sorted = [...result.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.session.localeCompare(b.session));
|
|
246
|
-
return session ? sorted :
|
|
248
|
+
return session ? sorted : [
|
|
249
|
+
...recentSessionWindow(sorted.filter(item => item.archived !== true), { limit: maximum, priority: item => priorities.has(item.session) }),
|
|
250
|
+
...recentSessionWindow(sorted.filter(item => item.archived === true), { limit: maximum, priority: item => priorities.has(item.session) }),
|
|
251
|
+
];
|
|
247
252
|
}
|
|
248
253
|
|
|
249
254
|
export async function listClaudeSessions({ home, limit, prioritySessions, signal } = {}) {
|
|
@@ -256,6 +261,7 @@ export async function readClaudeSession(session, { home } = {}) {
|
|
|
256
261
|
const matches = await collect({ home, limit: RECENT_SESSION_LIMIT, session: id });
|
|
257
262
|
const selected = typeof session === 'object' && typeof session.cwd === 'string'
|
|
258
263
|
? matches.filter(item => item.cwd === session.cwd) : matches;
|
|
264
|
+
if (selected.length === 1 && selected[0].archived === true) throw Object.assign(new Error('Restore the original provider conversation before continuing.'), { code: 'session_archived' });
|
|
259
265
|
return selected.length === 1 ? selected[0] : null;
|
|
260
266
|
}
|
|
261
267
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizePullRequests } from './pull-requests.js';
|
|
1
2
|
import { delegatedNativeSandbox, canWriteWorkspace, authorizedWorktreeGitRoots } from './native-delegation.js';
|
|
2
3
|
import { flexibleReasoning } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
|
|
3
4
|
import { collectPurposeObservations, refreshPurposeObservations, repositoryIdentity } from './purpose-observer.js';
|
|
@@ -202,13 +203,19 @@ export function codexThreadMetadata(thread) {
|
|
|
202
203
|
if (thread.parentThreadId || thread.source?.subAgent || thread.source?.subagent) return null;
|
|
203
204
|
return {
|
|
204
205
|
provider: 'codex', session: thread.id, cwd: thread.cwd,
|
|
206
|
+
pullRequests: normalizePullRequests(thread.pullRequests || thread.artifacts?.filter?.(item => item.type === 'pull_request')),
|
|
205
207
|
...(typeof thread.forkedFromId === 'string' && /^[0-9a-f-]{36}$/i.test(thread.forkedFromId) && thread.forkedFromId !== thread.id
|
|
206
208
|
? { parentSession: { provider: 'codex', session: thread.forkedFromId } } : {}),
|
|
207
209
|
title: typeof thread.name === 'string' && thread.name.trim() ? thread.name.slice(0, 200) : '',
|
|
208
210
|
source: typeof thread.source === 'string' ? thread.source : thread.source?.type || 'unknown',
|
|
209
211
|
lastActivityAt: Number.isFinite(thread.updatedAt) ? thread.updatedAt * 1000 : null,
|
|
210
212
|
model: typeof thread.model === 'string' ? thread.model : null,
|
|
211
|
-
|
|
213
|
+
// Preserve native wait flags for presentation without changing isRunning,
|
|
214
|
+
// which also protects the provider writer lock. Unknown flags fail closed.
|
|
215
|
+
observation: { source: 'codex_app_server', observedAt: Date.now(),
|
|
216
|
+
...(thread.status?.type === 'active' ? { activeFlags: Array.isArray(thread.status.activeFlags)
|
|
217
|
+
&& thread.status.activeFlags.every(flag => ['waitingOnApproval', 'waitingOnUserInput'].includes(flag))
|
|
218
|
+
? [...new Set(thread.status.activeFlags)] : null } : {}) },
|
|
212
219
|
...(thread.status?.type === 'active' || thread.status?.type === 'idle' ? { isRunning: thread.status.type === 'active' } : {}),
|
|
213
220
|
availability: 'unknown',
|
|
214
221
|
};
|
|
@@ -617,7 +624,7 @@ async function frozenNativeSandbox(record, root, signal) {
|
|
|
617
624
|
return policy;
|
|
618
625
|
}
|
|
619
626
|
|
|
620
|
-
export async function executeCodexAppServer(record, { webOrigin, openClient = openCodexAppServer, signal, authorizePreparation = async () => {}, beforeDispatch = async () => {}, onActivity = () => {}, onUsage = () => {}, onEvidence = async () => {}, observeExternal = collectPurposeObservations, root, auditOnly = false, opinionOnly = false, peerAction, runPeerReview = runStructuredProviderReview } = {}) {
|
|
627
|
+
export async function executeCodexAppServer(record, { webOrigin, openClient = openCodexAppServer, signal, authorizePreparation = async () => {}, beforeDispatch = async () => {}, onStarted = async () => {}, onActivity = () => {}, onUsage = () => {}, onEvidence = async () => {}, observeExternal = collectPurposeObservations, root, auditOnly = false, opinionOnly = false, peerAction, runPeerReview = runStructuredProviderReview } = {}) {
|
|
621
628
|
const run = record.run, decision = run.managed?.decision;
|
|
622
629
|
if(run.managed?.enrollment?.nativeAuthorityDigest && run.managed.enrollment.nativeAuthorityDigest!==createHash('sha256').update(JSON.stringify([run.managed.providerSandbox,run.managed.providerSandboxSource])).digest('hex'))throw new CodexAppServerError('permission_narrowed','Frozen native enrollment authority changed; do not dispatch with the altered ceiling.');
|
|
623
630
|
if (!decision || !run.managed.autonomyPolicy) throw new CodexAppServerError('decision', 'An assessed action is required before Codex dispatch.');
|
|
@@ -672,7 +679,7 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
672
679
|
};
|
|
673
680
|
const beforeEvidence = searchEnabled(run.managed) && !reviewing ? await captureWorkspace() : null;
|
|
674
681
|
let evidenceQueue = Promise.resolve(), evidenceFailure = null;
|
|
675
|
-
let activeTurnId = null, turnCompleted = false, turnStart = null;
|
|
682
|
+
let activeTurnId = null, turnCompleted = false, turnStart = null, startReceipt = null;
|
|
676
683
|
let usage = null, lastTotal = null;
|
|
677
684
|
try {
|
|
678
685
|
// An unavailable login is a connection dependency, not an action approval.
|
|
@@ -789,7 +796,7 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
789
796
|
}
|
|
790
797
|
if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Worker dispatch was cancelled during preparation.');
|
|
791
798
|
const done = new Promise((resolve, reject) => {
|
|
792
|
-
let turnId = null, completion = null, settled = false;
|
|
799
|
+
let turnId = null, completion = null, settled = false, startRecorded = false;
|
|
793
800
|
const messages = new Map();
|
|
794
801
|
const activeTools = new Set();
|
|
795
802
|
const earlyActivity = [];
|
|
@@ -819,7 +826,7 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
819
826
|
const cancelled = () => finish(new CodexAppServerError('cancelled', 'Codex turn was cancelled.'));
|
|
820
827
|
const closed = () => finish(new CodexAppServerError('closed', 'Codex app-server closed before turn completion.'));
|
|
821
828
|
const complete = () => {
|
|
822
|
-
if (!completion || !turnId || completion.turn?.id !== turnId) return;
|
|
829
|
+
if (!completion || !turnId || !startRecorded || completion.turn?.id !== turnId) return;
|
|
823
830
|
turnCompleted = true;
|
|
824
831
|
if (completion.turn.status !== 'completed') return finish(new CodexAppServerError('turn_failed', 'Codex turn did not complete.'));
|
|
825
832
|
const message = messages.get(turnId);
|
|
@@ -846,7 +853,10 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
846
853
|
client.child.once('close', closed);
|
|
847
854
|
turnStart = client.request('turn/start', { threadId: targetSession, input: [{ type: 'text', text: `${prompt}\n${purposeInstruction}` }], cwd: isolated ? executionCwd : record.cwd,
|
|
848
855
|
model: run.managed.resumeModel, ...(isolated ? {sandboxPolicy} : {}) })
|
|
849
|
-
.then(response => { turnId = response?.turn?.id; activeTurnId = turnId; if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
|
|
856
|
+
.then(async response => { turnId = response?.turn?.id; activeTurnId = turnId; if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
|
|
857
|
+
startReceipt = Promise.resolve().then(() => onStarted({ turnId, session: targetSession }));
|
|
858
|
+
await startReceipt;
|
|
859
|
+
startRecorded = true;
|
|
850
860
|
for (const params of earlyUsage) if (params.turnId === turnId) observeUsage(params);
|
|
851
861
|
for (const params of earlyActivity) if (params.turnId === turnId) observeActivity(params.method, params);
|
|
852
862
|
complete();
|
|
@@ -912,6 +922,7 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
912
922
|
await evidenceQueue;
|
|
913
923
|
throw error;
|
|
914
924
|
} finally {
|
|
925
|
+
if (startReceipt) await startReceipt.catch(() => {});
|
|
915
926
|
if (turnStart && !client.closed) {
|
|
916
927
|
// Only the controller-owned target may be interrupted. Isolated reads
|
|
917
928
|
// never load or change the original conversation's policy defaults.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withSceneReasoning } from '../codex-plugins/simy/hooks/scene-reasoning.mjs';
|
|
1
2
|
import { readCodexConversation } from './codex-app-server.js';
|
|
2
3
|
import { readClaudeConversation } from './claude-sessions.js';
|
|
3
4
|
import { boundConversation, boundProviderGoal, providerGoalEvidence, sanitizedText } from './conversation-context.js';
|
|
@@ -5,7 +6,7 @@ import { runStructuredProviderReview } from '../sqm/review-worker.js';
|
|
|
5
6
|
import { inspectProviderStatus } from '../desktop-status.js';
|
|
6
7
|
import { createHash } from 'node:crypto';
|
|
7
8
|
|
|
8
|
-
export const CONTINUATION_ANALYSIS_VERSION =
|
|
9
|
+
export const CONTINUATION_ANALYSIS_VERSION = 6;
|
|
9
10
|
const BLOCKER_TYPES = ['approval', 'missing_information', 'external_dependency', 'execution_failure'];
|
|
10
11
|
const STATES = ['continue', 'no_action', 'needs_user', 'waiting', 'uncertain'];
|
|
11
12
|
const string = maximum => ({ type: 'string', maxLength: maximum });
|
|
@@ -228,7 +229,7 @@ export async function analyzeContinuation(candidate, {
|
|
|
228
229
|
await beforeProviderDispatch();
|
|
229
230
|
if (signal?.aborted) return uncertain('Conversation analysis was cancelled.', 'cancelled');
|
|
230
231
|
result = await runProvider(candidate.provider, {
|
|
231
|
-
system: SYSTEM, prompt: `Classify this conversation evidence only (older history omitted: ${context.truncated}):\n${JSON.stringify(context.providerGoal ? { originalProviderGoal: context.providerGoal, messages: context.messages } : context.messages)}`, output_schema: OUTPUT_SCHEMA,
|
|
232
|
+
system: withSceneReasoning(SYSTEM), prompt: `Classify this conversation evidence only (older history omitted: ${context.truncated}):\n${JSON.stringify(context.providerGoal ? { originalProviderGoal: context.providerGoal, messages: context.messages } : context.messages)}`, output_schema: OUTPUT_SCHEMA,
|
|
232
233
|
}, { ...providerOptions, signal, readOnlyAnalysis: true, beforeDispatch: beforeProviderDispatch });
|
|
233
234
|
} catch (error) {
|
|
234
235
|
if (error?.code === 'desktop_access_required') return uncertain('SIMY organization access is required before checking this conversation.', 'desktop_access_required');
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { sessionWorkState, compareSessionWork } from './work-state.js';
|
|
2
|
+
import { normalizePullRequests } from './pull-requests.js';
|
|
1
3
|
import { completionClaimUnbound } from '../codex-plugins/simy/hooks/completion-binding.mjs';
|
|
2
4
|
import { pauseOrigin } from './autonomous-upgrade.js';
|
|
3
5
|
import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/acceptance-policy.mjs';
|
|
@@ -123,7 +125,8 @@ function navigationParent(item, record) {
|
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
|
|
126
|
-
inspectProcess = processIdentity, now
|
|
128
|
+
inspectProcess = processIdentity, now: requestedNow, signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false } = {}) {
|
|
129
|
+
const now = requestedNow ?? Date.now();
|
|
127
130
|
const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
|
|
128
131
|
assertActive();
|
|
129
132
|
const connected = new Set(connectedProviders);
|
|
@@ -174,11 +177,13 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
174
177
|
for (let index = 0; index < records.length; index++) {
|
|
175
178
|
assertActive();
|
|
176
179
|
const record = records[index];
|
|
177
|
-
|
|
178
|
-
const
|
|
180
|
+
const claudeItem = record.provider === 'claude' ? claude.find(item => item.session === record.session && item.cwd === record.cwd) : null;
|
|
181
|
+
const archiveState = record.provider === 'codex' ? archiveStates.get(record.session) ?? (activeCodex.has(record.session) ? false : undefined) : claudeItem?.archived;
|
|
182
|
+
if (typeof archiveState !== 'boolean') continue;
|
|
183
|
+
const isArchived = archiveState;
|
|
179
184
|
if (isArchived) await recordProviderArchive(root, record.provider, record.session, true, now);
|
|
180
185
|
if (isArchived && record.providerArchived === true) continue;
|
|
181
|
-
if (!isArchived && !(record.providerArchived === true &&
|
|
186
|
+
if (!isArchived && !(record.providerArchived === true && archiveState === false)) continue;
|
|
182
187
|
try { records[index] = await updateRecord(root, record.provider, record.session, current => {
|
|
183
188
|
if (!current) return current;
|
|
184
189
|
current.providerArchived = isArchived;
|
|
@@ -204,7 +209,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
204
209
|
const recordsByKey = new Map(records.map(record => [`${record.provider}:${record.session}`, record]));
|
|
205
210
|
for (const item of [...discovered, ...claude, ...archivedCodex]) {
|
|
206
211
|
if (includeArchived && item.archived === true) { byKey.set(`${item.provider}:${item.session}`, item); continue; }
|
|
207
|
-
if ((item.provider !== 'codex' || archiveStates.get(item.session) !== true) && recordsByKey.get(`${item.provider}:${item.session}`)?.providerArchived !== true) byKey.set(`${item.provider}:${item.session}`, item);
|
|
212
|
+
if (item.archived !== true && (item.provider !== 'codex' || archiveStates.get(item.session) !== true) && recordsByKey.get(`${item.provider}:${item.session}`)?.providerArchived !== true) byKey.set(`${item.provider}:${item.session}`, item);
|
|
208
213
|
}
|
|
209
214
|
const requiredAncestors = new Set();
|
|
210
215
|
for (const item of byKey.values()) {
|
|
@@ -224,7 +229,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
224
229
|
const key = `${record.provider}:${record.session}`;
|
|
225
230
|
// A hook observation can belong to an AI-created worker. Only explicit
|
|
226
231
|
// supervision may restore a conversation absent from provider discovery.
|
|
227
|
-
if (!byKey.has(key) && (retainedSession(record) || requiredAncestors.has(key) && (record.observedAt || record.admission?.validatedAt))) byKey.set(key, {
|
|
232
|
+
if (!byKey.has(key) && (retainedSession(record) || includeArchived && record.listHidden === true || requiredAncestors.has(key) && (record.observedAt || record.admission?.validatedAt))) byKey.set(key, {
|
|
228
233
|
provider: record.provider, session: record.session, cwd: record.cwd, title: '', archived, source: record.admission?.source || 'hook',
|
|
229
234
|
lastActivityAt: record.observedAt || record.admission?.validatedAt || record.run?.updatedAt || null,
|
|
230
235
|
observation: { source: record.admission?.source || `${record.provider}_hook`, observedAt: record.observedAt || record.admission?.validatedAt || null },
|
|
@@ -236,11 +241,11 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
236
241
|
const catalog = [...byKey.values()].map(item => {
|
|
237
242
|
const record = recordsByKey.get(`${item.provider}:${item.session}`);
|
|
238
243
|
const parentSession = navigationParent(item, record);
|
|
239
|
-
return { ...item, ...(parentSession ? { parentSession } : {}) };
|
|
244
|
+
return { ...item, localHidden: record?.listHidden === true, ...(parentSession ? { parentSession } : {}) };
|
|
240
245
|
});
|
|
241
|
-
const items = [...recentSessionWindow(catalog.filter(item => item.archived !== true), {
|
|
246
|
+
const items = [...recentSessionWindow(catalog.filter(item => item.archived !== true && !item.localHidden), {
|
|
242
247
|
priority: item => retainedSession(recordsByKey.get(`${item.provider}:${item.session}`)),
|
|
243
|
-
}), ...recentSessionWindow(catalog.filter(item => item.archived === true))];
|
|
248
|
+
}), ...recentSessionWindow(catalog.filter(item => item.archived === true || item.localHidden), { priority: item => item.localHidden })];
|
|
244
249
|
// Include archived ancestors of active rows even when archive visibility is off.
|
|
245
250
|
const catalogByKey = new Map(catalog.map(item => [`${item.provider}:${item.session}`, item]));
|
|
246
251
|
const includedKeys = new Set(items.map(item => `${item.provider}:${item.session}`));
|
|
@@ -295,14 +300,14 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
295
300
|
const continuationIssue = liveHook && run ? !run.managed && !upgradeable
|
|
296
301
|
? { code: 'existing_loop', reason: 'This conversation already has a Delivery Loop. Use its existing controls.' }
|
|
297
302
|
: TERMINAL_RUNS.has(run.state) ? { code: 'task_settled', reason: 'This supervised task has settled. Review its existing status before continuing.' } : null : null;
|
|
298
|
-
let isRunning = item.isRunning;
|
|
303
|
+
let isRunning = item.isRunning, observation = item.observation;
|
|
299
304
|
const hookIsNewer = Number.isFinite(record?.runtime?.observedAt) && Number.isFinite(item.lastActivityAt) && record.runtime.observedAt > item.lastActivityAt;
|
|
300
305
|
if ((isRunning === undefined || isRunning === false && hookIsNewer) && record?.runtime?.process?.identity && record.cwd === item.cwd) {
|
|
301
306
|
try {
|
|
302
307
|
// A hook newer than the persisted provider activity may describe a
|
|
303
308
|
// new turn. Require its same live process; older hooks cannot override
|
|
304
309
|
// a more recent completed provider turn.
|
|
305
|
-
if (record.runtime.isRunning && await inspectProcess(record.runtime.process.pid) === record.runtime.process.identity) isRunning = true;
|
|
310
|
+
if (record.runtime.isRunning && await inspectProcess(record.runtime.process.pid) === record.runtime.process.identity) { isRunning = true; observation = { ...item.observation, observedAt: record.runtime.observedAt }; }
|
|
306
311
|
else if (isRunning === undefined && !record.runtime.isRunning) isRunning = false;
|
|
307
312
|
} catch { /* Unverified process leaves runtime status unknown. */ }
|
|
308
313
|
}
|
|
@@ -314,12 +319,14 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
314
319
|
...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
|
|
315
320
|
...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
|
|
316
321
|
tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
|
|
322
|
+
providerArchived: archived, localHidden: record?.listHidden === true,
|
|
317
323
|
archived,
|
|
324
|
+
pullRequests: normalizePullRequests(item.pullRequests),
|
|
318
325
|
autoContinue: !archived && record?.autoContinue === true,
|
|
319
326
|
autoContinueEpoch: record?.autoContinue === true ? record.autoContinueEpoch : null,
|
|
320
327
|
autoContinueAvailability: availability,
|
|
321
328
|
...autoContinueState(record, isRunning, availability),
|
|
322
|
-
lastActivityAt: item.lastActivityAt, observation
|
|
329
|
+
lastActivityAt: item.lastActivityAt, observation, availability: item.availability,
|
|
323
330
|
taskId: run?.managed?.taskId || record?.associationTaskId || null,
|
|
324
331
|
binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null },
|
|
325
332
|
originalSessionTarget: originalSessionTarget(item.provider, item.session),
|
|
@@ -343,8 +350,12 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
343
350
|
}
|
|
344
351
|
}));
|
|
345
352
|
assertActive();
|
|
346
|
-
|
|
347
|
-
|
|
353
|
+
// Provider reads stamp observations after discovery starts. Compare them at
|
|
354
|
+
// projection time, while preserving an explicitly supplied verification clock.
|
|
355
|
+
const stateNow = requestedNow ?? Date.now();
|
|
356
|
+
for (const candidate of projected) candidate.workState = sessionWorkState(candidate, recordsByKey.get(`${candidate.provider}:${candidate.session}`)?.run, { now: stateNow });
|
|
357
|
+
projected.sort(compareSessionWork);
|
|
358
|
+
return { sessions: includeArchived ? projected : projected.filter(item => !item.archived && !item.localHidden), providers: { codex: { discovery: !connected.has('codex') ? 'disconnected' : codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: !connected.has('claude') ? 'disconnected' : claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
|
|
348
359
|
}
|
|
349
360
|
|
|
350
361
|
// Saving consent needs an exact admitted identity, not another provider RPC.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execute = promisify(execFile);
|
|
4
|
+
const PR_URL = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)\/?$/;
|
|
5
|
+
export function normalizePullRequests(value) {
|
|
6
|
+
const rows = Array.isArray(value) ? value : value ? [value] : [];
|
|
7
|
+
const unique = new Map();
|
|
8
|
+
for (const row of rows.slice(0, 32)) {
|
|
9
|
+
const url = typeof row === 'string' ? row : row?.url;
|
|
10
|
+
if (!PR_URL.test(url || '')) continue;
|
|
11
|
+
const state = String(row.state || row.status || '').toUpperCase();
|
|
12
|
+
unique.set(url, { url, state: row.merged === true || row.mergedAt ? 'MERGED' : ['OPEN', 'CLOSED', 'MERGED'].includes(state) ? state : 'UNKNOWN',
|
|
13
|
+
reviewDecision: ['CHANGES_REQUESTED', 'APPROVED', 'REVIEW_REQUIRED'].includes(row.reviewDecision) ? row.reviewDecision : null,
|
|
14
|
+
checkedAt: Number.isFinite(row.checkedAt) ? row.checkedAt : null });
|
|
15
|
+
}
|
|
16
|
+
return [...unique.values()];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// One cache per host, bounded concurrency, no network work on the 3s status tick.
|
|
20
|
+
export function createPullRequestObserver({ read = async url => {
|
|
21
|
+
const match = PR_URL.exec(url);
|
|
22
|
+
const { stdout } = await execute('gh', ['pr', 'view', match[3], '--repo', `${match[1]}/${match[2]}`, '--json', 'url,state,reviewDecision,mergedAt'], { timeout: 2000, maxBuffer: 64 * 1024, windowsHide: true });
|
|
23
|
+
return JSON.parse(stdout);
|
|
24
|
+
}, now = Date.now, ttlMs = 60_000, budgetMs = 3500 } = {}) {
|
|
25
|
+
const cache = new Map();
|
|
26
|
+
let cursor = 0;
|
|
27
|
+
return async (sessions, { signal } = {}) => {
|
|
28
|
+
const catalog = [...new Set(sessions.flatMap(item => (item.pullRequests || []).map(pr => pr.url)))];
|
|
29
|
+
// Continue after the last attempted URL, including when slow reads consume
|
|
30
|
+
// the budget. Restarting at row zero can starve the tail indefinitely.
|
|
31
|
+
const start = catalog.length ? cursor % catalog.length : 0;
|
|
32
|
+
const urls = [...catalog.slice(start), ...catalog.slice(0, start)].slice(0, 128);
|
|
33
|
+
let next = 0; const started = now();
|
|
34
|
+
await Promise.all(Array.from({ length: Math.min(4, urls.length) }, async () => {
|
|
35
|
+
while (next < urls.length && !signal?.aborted && now() - started < budgetMs) {
|
|
36
|
+
const url = urls[next++], previous = cache.get(url);
|
|
37
|
+
if (previous && now() - previous.at < ttlMs) continue;
|
|
38
|
+
try { const rows = normalizePullRequests(await read(url));
|
|
39
|
+
if (rows[0]?.url !== url) throw new Error('PR identity mismatch');
|
|
40
|
+
cache.set(url, { at: now(), value: { ...rows[0], checkedAt: now(), fetchFailed: false } });
|
|
41
|
+
} catch { cache.set(url, { at: now(), value: { ...(previous?.value || { url, state: 'UNKNOWN' }), fetchFailed: true } }); }
|
|
42
|
+
}
|
|
43
|
+
}));
|
|
44
|
+
cursor = catalog.length ? (start + next) % catalog.length : 0;
|
|
45
|
+
while (cache.size > 512) cache.delete(cache.keys().next().value);
|
|
46
|
+
return sessions.map(item => ({ ...item, pullRequests: (item.pullRequests || []).map(pr => {
|
|
47
|
+
const observed = cache.get(pr.url)?.value;
|
|
48
|
+
const value = observed ? { ...pr, ...observed, ...(observed.state === 'UNKNOWN' ? { state: pr.state } : {}) } : pr;
|
|
49
|
+
return { ...value, stale: !Number.isFinite(value.checkedAt) || now() - value.checkedAt > ttlMs };
|
|
50
|
+
}) }));
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { withSceneReasoning } from '../codex-plugins/simy/hooks/scene-reasoning.mjs';
|
|
2
|
+
import { currentRecoveryIncident, unresolvedRecoveryContext } from '../codex-plugins/simy/hooks/recovery-incident.mjs';
|
|
1
3
|
import { recallPerspectives, perspectiveInstruction } from '../codex-plugins/simy/hooks/perspective-memory.mjs';
|
|
2
4
|
import { loadHypothesisKnowledge, hypothesisKnowledgeInstruction } from '../sqm/hypothesis-knowledge.js';
|
|
3
5
|
import { selectiveRuleReview, applicableRuleIds } from '../codex-plugins/simy/hooks/purpose-rule-policy.mjs';
|
|
@@ -40,12 +42,18 @@ export async function loadPurposeRuleSet() {
|
|
|
40
42
|
return { digest: createHash('sha256').update(JSON.stringify(sources)).digest('hex'), rules };
|
|
41
43
|
}
|
|
42
44
|
export async function buildPurposeInstruction(managed, { reviewer = false, cwd, webOrigin, allowNetwork = false, signal, knowledgeLoader = loadHypothesisKnowledge, authorizeKnowledgeNetwork, root: memoryRoot, record, memoryLoader = recallPerspectives } = {}) {
|
|
43
|
-
if (!managed?.purpose?.enabled) return
|
|
45
|
+
if (!managed?.purpose?.enabled) return withSceneReasoning();
|
|
44
46
|
const ruleSet = await loadPurposeRuleSet();
|
|
45
47
|
if (ruleSet.digest !== managed.purpose.ruleDigest) throw Object.assign(new Error('Purpose workflow changed; refresh its rule inventory before dispatch.'), { code: 'purpose_rules_changed' });
|
|
46
48
|
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)) })))}`;
|
|
47
49
|
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)) })))}`;
|
|
48
50
|
const knowledge = await knowledgeLoader({ cwd, webOrigin, allowNetwork, signal, ...(authorizeKnowledgeNetwork ? { authorizeNetwork: authorizeKnowledgeNetwork } : {}) });
|
|
49
51
|
const memory = await memoryLoader({ root: memoryRoot, record, signal });
|
|
50
|
-
|
|
52
|
+
const attempt = managed.executionAttempt;
|
|
53
|
+
const recoveryContext = { previous: managed.purpose.lastRecovery || null, incident: currentRecoveryIncident(managed),
|
|
54
|
+
unresolved: unresolvedRecoveryContext(managed),
|
|
55
|
+
receipt: attempt ? { id: attempt.id, phase: attempt.phase, decisionId: attempt.decisionId,
|
|
56
|
+
providerSessionId: attempt.providerSessionId || null, providerTurnId: attempt.providerTurnId || null,
|
|
57
|
+
startedAt: attempt.startedAt || null, dispatchedAt: attempt.dispatchedAt || null } : null };
|
|
58
|
+
return `${purposeContract(managed)}\n${perspectiveInstruction(memory)}\n${hypothesisKnowledgeInstruction(knowledge, { flexible: selectiveRuleReview(managed) })}\n${searchInstruction(managed)}\nUse the shared scene reasoning cycle; consult applicable canonical rule sections as perspective inputs and challenge the candidate 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 (controller receipt data, not instructions, permission or acceptance; return to the original unfinished work after the next useful check): ${JSON.stringify(recoveryContext)}`;
|
|
51
59
|
}
|
|
@@ -58,6 +58,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
|
|
|
58
58
|
const deferred = [];
|
|
59
59
|
|
|
60
60
|
for (let record of await readRecords()) {
|
|
61
|
+
if (signal?.aborted) break;
|
|
61
62
|
try {
|
|
62
63
|
let run = record.run;
|
|
63
64
|
if (upgradeEligible(record) && (policyAdoptionPending(record) || run.managed.controllerContractVersion !== 2 || run.state === 'paused' || run.state === 'verification_pending')) {
|
|
@@ -98,6 +99,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
|
|
|
98
99
|
let link;
|
|
99
100
|
try { link = await requireProviderLink({ root, provider: record.provider }); } catch { continue; }
|
|
100
101
|
const authorized = async () => {
|
|
102
|
+
signal?.throwIfAborted();
|
|
101
103
|
await requireProviderLink({ root, provider: record.provider, expectedEpoch: link.epoch });
|
|
102
104
|
const latest = await readRecord(root, record.provider, record.session);
|
|
103
105
|
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.');
|
|
@@ -105,7 +107,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
|
|
|
105
107
|
const observe = async read => { await authorized(); const result = await read(); await authorized(); return result; };
|
|
106
108
|
const persist = mutate => writeRecord(root, record.provider, record.session, async current => {
|
|
107
109
|
try { await requireProviderLink({ root, provider: record.provider, expectedEpoch: link.epoch }); } catch { return current; }
|
|
108
|
-
if (!current || !automaticContinuationAllowed(current) || current.autoContinueEpoch !== record.autoContinueEpoch) return current;
|
|
110
|
+
if (signal?.aborted || !current || !automaticContinuationAllowed(current) || current.autoContinueEpoch !== record.autoContinueEpoch) return current;
|
|
109
111
|
return mutate(current);
|
|
110
112
|
});
|
|
111
113
|
// Recheck the current budget and boundaries under the existing record lock.
|
|
@@ -203,7 +205,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
|
|
|
203
205
|
if (current?.run?.id !== run.id || current.run.state !== 'blocked' || current.run.revision !== run.revision || current.run.updatedAt !== run.updatedAt) return current;
|
|
204
206
|
current.run.managed.authenticationWait.nextCheckAt = now + 60_000;
|
|
205
207
|
if (authentication === 'available') {
|
|
206
|
-
const queued = queuePurposeDiagnosis(current.run, 'Configured provider login is available again. Inspect the original purpose and any interrupted action before choosing authorized work.', `authentication-restored:${run.managed.authenticationWait.since}`, now);
|
|
208
|
+
const queued = queuePurposeDiagnosis(current.run, 'Configured provider login is available again. Inspect the original purpose and any interrupted action before choosing authorized work.', `authentication-restored:${run.managed.authenticationWait.since}`, now, 'authentication');
|
|
207
209
|
if (queued) delete current.run.managed.authenticationWait;
|
|
208
210
|
}
|
|
209
211
|
return current;
|
|
@@ -219,7 +221,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
|
|
|
219
221
|
current.run.managed.purpose.observationState = observed.state;
|
|
220
222
|
return current;
|
|
221
223
|
});
|
|
222
|
-
if (observed.state === 'idle') obstacle = { type: 'idle_without_checkpoint', detail: observed.turnId };
|
|
224
|
+
if (observed.state === 'idle' && (!observed.session || observed.session === record.session)) obstacle = { type: 'idle_without_checkpoint', detail: observed.turnId };
|
|
223
225
|
} else if (run.state === 'blocked' && ['missing_information', 'authorization', 'external_dependency'].includes(run.blocker)) {
|
|
224
226
|
if (run.blockerSource === 'provider' || run.managed.attempts?.at(-1)?.errorCode === 'provider_approval') {
|
|
225
227
|
if (now < (run.managed.purpose.lastObservedAt || 0) + 60_000) continue;
|
|
@@ -309,7 +311,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
|
|
|
309
311
|
if (['native_wait_updated', 'native_wait_diagnosis'].includes(obstacle.type) && !nativeCurrent(current)) return current;
|
|
310
312
|
if (searchEnabled(current.run.managed) && ['original_context_updated', 'native_wait_updated'].includes(obstacle.type)) current.run.managed.purpose.nextSearchAt = 0;
|
|
311
313
|
const queued = queuePurposeDiagnosis(current.run,
|
|
312
|
-
`Reassess the original purpose after ${obstacle.type}: ${obstacle.detail}.
|
|
314
|
+
`Reassess the original purpose after ${obstacle.type}: ${obstacle.detail}. Use the shared scene reasoning cycle to select the next useful check. Preserve unresolved approvals/dependencies; choose independent work or a safe alternative where possible, then return to the original unfinished work.`, key, now, obstacle.type);
|
|
313
315
|
if (queued && obstacle.type === 'external_dependency') {
|
|
314
316
|
const purpose = current.run.managed.purpose;
|
|
315
317
|
purpose.dependencyChecks = (purpose.dependencyChecks || 0) + 1;
|
|
@@ -318,6 +320,7 @@ export async function reconcilePurposeTasks({ root, signal, prepareCandidate = p
|
|
|
318
320
|
return current;
|
|
319
321
|
});
|
|
320
322
|
} catch (error) {
|
|
323
|
+
if (signal?.aborted) break;
|
|
321
324
|
if (!/^Delivery state (?:is busy; retry the command\.|lock owner is unavailable; inspect the lock before retrying\.)$/.test(error.message)) throw error;
|
|
322
325
|
// One unavailable record cannot prevent independent conversations from
|
|
323
326
|
// progressing. Never delete or reclaim an ambiguous writer lock.
|