@awak-app/simy-cli 0.6.23 → 0.6.26
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 +53 -0
- package/package.json +3 -3
- package/src/cli-contract.js +9 -1
- package/src/codex-plugins/simy/hooks/acceptance-policy.mjs +5 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +533 -74
- package/src/codex-plugins/simy/hooks/purpose-cycle.mjs +63 -0
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +154 -0
- package/src/codex-plugins/simy/hooks/purpose-rule-policy.mjs +34 -0
- package/src/codex-plugins/simy/hooks/purpose-rule-review.mjs +65 -0
- package/src/codex-plugins/simy/hooks/purpose-search.mjs +123 -0
- package/src/codex-plugins/simy/hooks/purpose-workspace.mjs +97 -0
- package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +19 -5
- package/src/codex-plugins/simy/skills/sqm-loop/SKILL.md +19 -4
- package/src/delivery-hooks/command.js +48 -0
- package/src/delivery-hooks/consistency-monitor.js +126 -0
- package/src/delivery-hooks/desktop-runtime.js +70 -0
- package/src/delivery-hooks/desktop.js +13 -3
- package/src/delivery-hooks/legacy-hook-routing.js +61 -0
- package/src/delivery-hooks/local-runtime.js +123 -0
- package/src/delivery-hooks/local-supervisor.js +25 -0
- package/src/delivery-hooks/runtime-watchdog.js +101 -0
- package/src/delivery-hooks/supervisor.js +90 -22
- package/src/desktop-host.js +24 -1
- package/src/durable-local-task-worker.js +57 -12
- package/src/fp-narrative-image.js +180 -0
- package/src/index.js +1 -1
- package/src/local-task.js +1 -0
- package/src/orchestrator/audit.js +1 -1
- package/src/orchestrator/charter-repair.js +93 -0
- package/src/orchestrator/charter-validation.js +14 -6
- package/src/orchestrator/checklist-continuation.js +1 -1
- package/src/orchestrator/contract.js +1 -3
- package/src/orchestrator/delivery/WORKFLOW.md +33 -60
- package/src/orchestrator/delivery/controls/harness-controls.json +1326 -0
- package/src/orchestrator/delivery/references/delivery-levels.md +17 -44
- package/src/orchestrator/delivery/references/pr-closure.md +2 -3
- package/src/orchestrator/delivery/references/state-and-receipts.md +14 -5
- package/src/orchestrator/delivery/references/ux-specification.md +1 -2
- package/src/orchestrator/delivery-level.js +4 -5
- package/src/orchestrator/independent-audit.js +9 -5
- package/src/orchestrator/instruction.js +1 -0
- package/src/orchestrator/loop.js +55 -18
- package/src/orchestrator/risk.js +1 -1
- package/src/orchestrator/ux-specification.js +2 -2
- package/src/runner.js +4 -0
- package/src/session-supervision/analysis-manager.js +2 -2
- package/src/session-supervision/automatic-continuation.js +6 -5
- package/src/session-supervision/autonomous-upgrade.js +55 -0
- package/src/session-supervision/codex-app-server.js +200 -24
- package/src/session-supervision/consistency-auditor.js +113 -0
- package/src/session-supervision/index.js +38 -11
- package/src/session-supervision/purpose-candidate.js +44 -0
- package/src/session-supervision/purpose-instruction.js +47 -0
- package/src/session-supervision/purpose-observer.js +172 -0
- package/src/session-supervision/purpose-recovery.js +264 -0
- package/src/session-supervision/purpose-verification.js +154 -0
- package/src/sqm/agent-skill.js +6 -3
- package/src/sqm/artifact-paths.js +39 -0
- package/src/sqm/claude-plugin.js +7 -0
- package/src/sqm/command.js +95 -27
- package/src/sqm/history-store.js +3 -3
- package/src/sqm/repository.js +19 -5
- package/src/sqm/workflow-update.js +14 -0
- package/src/workflow-reporting.js +2 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { mkdir, realpath } from 'node:fs/promises';
|
|
5
|
+
import { join, relative, isAbsolute } from 'node:path';
|
|
6
|
+
import { workspaceIdentity } from './purpose-verification.js';
|
|
7
|
+
const exec=promisify(execFile);
|
|
8
|
+
|
|
9
|
+
// A candidate never applies itself to the original. Arbitrary user/provider
|
|
10
|
+
// writers do not participate in our lease, so a check-then-copy is not a CAS.
|
|
11
|
+
export async function preparePurposeCandidate(record, { root, authorize, signal, execute=exec } = {}) {
|
|
12
|
+
const original=await realpath(record.run.executionCwd || record.cwd);
|
|
13
|
+
await authorize();signal?.throwIfAborted();
|
|
14
|
+
await execute('git',['rev-parse','--show-toplevel'],{cwd:original,signal,timeout:5000}).then(({stdout})=>{
|
|
15
|
+
if (stdout.trim()!==original) throw new Error('Candidate requires the task repository root.');
|
|
16
|
+
});
|
|
17
|
+
const parent=join(original,'.simy-purpose-candidates');
|
|
18
|
+
await authorize();signal?.throwIfAborted();
|
|
19
|
+
await mkdir(parent,{recursive:true,mode:0o700});
|
|
20
|
+
if(await realpath(parent)!==parent) throw new Error('Candidate root changed to a symlink.');
|
|
21
|
+
const gitIdentity=async()=>{const {stdout}=await execute('git',['rev-parse','HEAD','--symbolic-full-name','HEAD'],{cwd:original,signal,timeout:5000,maxBuffer:4096});return stdout.trim();};
|
|
22
|
+
const identity=await gitIdentity(), revision=identity.split('\n')[0];
|
|
23
|
+
if(!/^[a-f0-9]{40}$/.test(revision)) throw new Error('Candidate base revision unavailable.');
|
|
24
|
+
const before=await workspaceIdentity(original,parent,undefined,{signal});
|
|
25
|
+
const {stdout:patch}=await execute('git',['diff','--binary',revision],{cwd:original,signal,timeout:10000,maxBuffer:8*1024*1024});
|
|
26
|
+
// Untracked paths are not silently lost: any untracked source is a capability
|
|
27
|
+
// gap until a safe snapshot mechanism can include it with provenance.
|
|
28
|
+
const {stdout:untracked}=await execute('git',['ls-files','--others','--exclude-standard'],{cwd:original,signal,timeout:5000,maxBuffer:1024*1024});
|
|
29
|
+
if(untracked.trim().split('\n').some(path=>path && !path.startsWith('.simy-purpose-candidates/'))) throw new Error('Untracked task files require an explicit snapshot before candidate execution.');
|
|
30
|
+
if(identity!==await gitIdentity() || before!==await workspaceIdentity(original,parent,undefined,{signal})) throw new Error('Original changed during candidate snapshot.');
|
|
31
|
+
const directory=join(parent,randomUUID());
|
|
32
|
+
await authorize();signal?.throwIfAborted();
|
|
33
|
+
await execute('git',['worktree','add','--detach',directory,revision],{cwd:original,signal,timeout:10000,maxBuffer:4096});
|
|
34
|
+
if(await realpath(directory)!==directory || isAbsolute(relative(parent,directory)) || relative(parent,directory).startsWith('..')) throw new Error('Candidate directory identity changed.');
|
|
35
|
+
// Only tracked working differences enter this isolated checkout. A failed
|
|
36
|
+
// apply leaves the candidate retained and cannot overwrite original work.
|
|
37
|
+
await authorize();signal?.throwIfAborted();
|
|
38
|
+
if(patch) await new Promise((resolve,reject)=>{
|
|
39
|
+
const child=execFile('git',['apply','--binary','-'],{cwd:directory,signal,timeout:10000,maxBuffer:4096},error=>error?reject(error):resolve());
|
|
40
|
+
child.stdin.end(patch);
|
|
41
|
+
});
|
|
42
|
+
await authorize();signal?.throwIfAborted();
|
|
43
|
+
return {id:directory.split('/').at(-1),directory,original,sourceDigest:before,baseRevision:revision,sourceGitIdentity:identity,status:'ready',application:'exclusive_or_CAS_required',createdAt:Date.now()};
|
|
44
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { selectiveRuleReview, applicableRuleIds } from '../codex-plugins/simy/hooks/purpose-rule-policy.mjs';
|
|
2
|
+
import { searchInstruction } from '../codex-plugins/simy/hooks/purpose-search.mjs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { purposeContract, llmDecisionInstruction } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
|
|
7
|
+
import { CORE_CONTROL_IDS } from '../codex-plugins/simy/hooks/purpose-rule-review.mjs';
|
|
8
|
+
const root = new URL('../orchestrator/delivery/', import.meta.url);
|
|
9
|
+
// The existing packaged workflow and references remain the source of truth.
|
|
10
|
+
// Index all sections; do not curate away inconvenient rules or claim execution.
|
|
11
|
+
export async function loadPurposeRuleSet() {
|
|
12
|
+
const files = ['WORKFLOW.md', '../../workflow-reporting.md', ...(await readdir(new URL('references/', root))).filter(name => name.endsWith('.md')).sort().map(name => `references/${name}`)];
|
|
13
|
+
const rules = [];
|
|
14
|
+
const sources = [['llm-first-decision-policy', llmDecisionInstruction]];
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
const content = await readFile(new URL(file, root), 'utf8');
|
|
17
|
+
sources.push([file, content]);
|
|
18
|
+
let fenced = false;
|
|
19
|
+
for (const [index, line] of content.split('\n').entries()) {
|
|
20
|
+
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
|
|
21
|
+
if (fenced || !/^#{1,4}\s+/.test(line)) continue;
|
|
22
|
+
const title = line.replace(/^#+\s+/, '');
|
|
23
|
+
const id = createHash('sha256').update(`${file}\0${title}`).digest('hex').slice(0, 12);
|
|
24
|
+
rules.push({ id, title, file, line: index + 1 });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const catalogFile = 'controls/harness-controls.json';
|
|
28
|
+
const content = await readFile(new URL(catalogFile, root), 'utf8');
|
|
29
|
+
const catalog = JSON.parse(content);
|
|
30
|
+
if (catalog.schemaVersion !== 1 || catalog.controls?.length !== 102 || catalog.controls.some((rule, index) =>
|
|
31
|
+
rule.id !== `HC-${String(index + 1).padStart(2, '0')}` || !rule.title || !rule.acceptance)) throw new Error('Historical control catalog is incomplete.');
|
|
32
|
+
sources.push([catalogFile, content]);
|
|
33
|
+
rules.push(...catalog.controls.map(rule => ({ id: rule.id, title: rule.title, file: catalogFile,
|
|
34
|
+
acceptance: rule.acceptance, acceptanceDetails: rule.acceptanceDetails, group: rule.group,
|
|
35
|
+
implementationOwner: rule.owner, machineValidator: 'captured_evidence_reference_coverage', semanticValidator: 'independent_domain_review', recoveryAction: 'acquire_missing_evidence_or_repair_owner',
|
|
36
|
+
provenance: `${catalog.source.repository}@${catalog.source.revision}:${catalog.source.files[0].path}:${rule.sourceLine}`, enforcement: 'independent_domain_review', decisionGuidance: 'Apply as a critique lens after LLM judgment; reversible choices are delegated to AI. Preserve independent evidence and actual authority.' })));
|
|
37
|
+
if (!rules.length || new Set(rules.map(rule => rule.id)).size !== rules.length) throw new Error('Purpose rule index is empty or ambiguous.');
|
|
38
|
+
return { digest: createHash('sha256').update(JSON.stringify(sources)).digest('hex'), rules };
|
|
39
|
+
}
|
|
40
|
+
export async function buildPurposeInstruction(managed, { reviewer = false } = {}) {
|
|
41
|
+
if (!managed?.purpose?.enabled) return '';
|
|
42
|
+
const ruleSet = await loadPurposeRuleSet();
|
|
43
|
+
if (ruleSet.digest !== managed.purpose.ruleDigest) throw Object.assign(new Error('Purpose workflow changed; refresh its rule inventory before dispatch.'), { code: 'purpose_rules_changed' });
|
|
44
|
+
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)) })))}`;
|
|
45
|
+
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)) })))}`;
|
|
46
|
+
return `${purposeContract(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)}`;
|
|
47
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { request as httpsRequest } from 'node:https';
|
|
3
|
+
import { BlockList } from 'node:net';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { evidenceExcerpt } from '../codex-plugins/simy/hooks/purpose-evidence.mjs';
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
const failure = (code, message) => Object.assign(new Error(message), { code });
|
|
10
|
+
const blocked = new BlockList();
|
|
11
|
+
for (const [address, prefix] of [['0.0.0.0',8],['10.0.0.0',8],['100.64.0.0',10],['127.0.0.0',8],['169.254.0.0',16],['172.16.0.0',12],['192.0.0.0',24],['192.0.2.0',24],['192.168.0.0',16],['198.18.0.0',15],['198.51.100.0',24],['203.0.113.0',24],['224.0.0.0',4],['240.0.0.0',4]]) blocked.addSubnet(address, prefix);
|
|
12
|
+
|
|
13
|
+
export function normalizeObservation(value) {
|
|
14
|
+
if (!value || typeof value !== 'object') throw new Error('Observation recipe required.');
|
|
15
|
+
let fields;
|
|
16
|
+
if (value.fields !== undefined) {
|
|
17
|
+
if (!Array.isArray(value.fields) || !value.fields.length || value.fields.length > 4 || new Set(value.fields).size !== value.fields.length ||
|
|
18
|
+
value.fields.some(field => typeof field !== 'string' || !field.startsWith('/') || field.length > 200 || /~(?![01])/u.test(field))) throw failure('unsupported_recipe', 'Use up to four JSON pointers to required result fields.');
|
|
19
|
+
fields = { fields: value.fields };
|
|
20
|
+
}
|
|
21
|
+
if (value.type === 'https_get') {
|
|
22
|
+
const url = new URL(value.url);
|
|
23
|
+
if (url.protocol !== 'https:' || url.port && url.port !== '443' || url.username || url.password || url.search || url.hash || url.href.length > 2000) throw new Error('Public observation requires HTTPS without credentials, query or fragment.');
|
|
24
|
+
return { type: value.type, url: url.href, ...fields };
|
|
25
|
+
}
|
|
26
|
+
if (value.type === 'github_repository' && typeof value.path === 'string' && !value.path.split('/').some(part => ['.', '..'].includes(part)) &&
|
|
27
|
+
/^repos\/[\w.-]+\/[\w.-]+\/(?:pulls\/[1-9]\d*(?:\/files)?|issues\/[1-9]\d*|actions\/runs\/[1-9]\d*|commits\/[a-f0-9]{7,40}\/(?:check-runs|status))$/.test(value.path)) return { type: value.type, path: value.path, ...fields };
|
|
28
|
+
throw new Error('Unsupported observation; use a public HTTPS result or a current-repository GitHub read endpoint.');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function readPublicResult(url, { signal, resolve = lookup, request = httpsRequest } = {}) {
|
|
32
|
+
const target = new URL(normalizeObservation({ type: 'https_get', url }).url);
|
|
33
|
+
const addresses = await new Promise((done, fail) => {
|
|
34
|
+
const aborted = () => finish(signal.reason || new Error('Observation cancelled.'));
|
|
35
|
+
const timer = setTimeout(() => finish(new Error('Observation DNS timed out.')), 5000);
|
|
36
|
+
const finish = (error, value) => { clearTimeout(timer); signal?.removeEventListener('abort', aborted); error ? fail(error) : done(value); };
|
|
37
|
+
signal?.addEventListener('abort', aborted, { once: true });
|
|
38
|
+
if (signal?.aborted) return aborted();
|
|
39
|
+
Promise.resolve().then(() => resolve(target.hostname, { family: 4, all: true })).then(value => finish(null, value), finish);
|
|
40
|
+
});
|
|
41
|
+
signal?.throwIfAborted();
|
|
42
|
+
if (!addresses.length || addresses.some(item => blocked.check(item.address, 'ipv4'))) throw failure('non_public_target', 'Non-public observation target.');
|
|
43
|
+
// Pin the validated address. Redirects are observations, never followed to a
|
|
44
|
+
// new authority; no cookies, credentials, request body or worker headers.
|
|
45
|
+
return new Promise((resolveResult, reject) => {
|
|
46
|
+
const req = request(target, { method: 'GET', signal, family: 4,
|
|
47
|
+
lookup: (_host, options, callback) => callback(null, ...(options.all ? [[addresses[0]]] : [addresses[0].address, 4])),
|
|
48
|
+
headers: { accept: 'application/json,text/plain,text/html', 'accept-encoding': 'identity', 'user-agent': 'SIMY-read-only-observer' } }, response => {
|
|
49
|
+
const chunks = []; let size = 0;
|
|
50
|
+
response.on('data', chunk => { size += chunk.length; if (size > 1024 * 1024) req.destroy(new Error('Observation exceeds one MiB.')); else chunks.push(chunk); });
|
|
51
|
+
response.on('error', reject);
|
|
52
|
+
response.on('end', () => resolveResult({ status: response.statusCode, body: Buffer.concat(chunks).toString('utf8'), etag: typeof response.headers?.etag === 'string' ? response.headers.etag : null }));
|
|
53
|
+
});
|
|
54
|
+
const deadline = setTimeout(() => req.destroy(new Error('Observation timed out.')), 15_000);
|
|
55
|
+
req.on('close', () => clearTimeout(deadline));
|
|
56
|
+
req.on('error', reject); req.end();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function repositoryIdentity(cwd, { signal, execute = exec } = {}) {
|
|
61
|
+
const { stdout: remote } = await execute('git', ['remote', 'get-url', 'origin'], { cwd, signal, timeout: 5000, maxBuffer: 4096 });
|
|
62
|
+
const match = remote.trim().match(/^(?:git@github\.com:|https:\/\/github\.com\/)([\w.-]+)\/([\w.-]+?)(?:\.git)?$/);
|
|
63
|
+
if (!match) throw new Error('No supported repository origin.');
|
|
64
|
+
return `${match[1]}/${match[2]}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function readRepositoryResult(path, { cwd, signal, repository, execute = exec, publicRead = readPublicResult } = {}) {
|
|
68
|
+
path = normalizeObservation({ type: 'github_repository', path }).path;
|
|
69
|
+
if (!repository || !/^[\w.-]+\/[\w.-]+$/.test(repository) || !path.startsWith(`repos/${repository}/`) ||
|
|
70
|
+
await repositoryIdentity(cwd, {signal,execute}) !== repository) throw failure('repository_scope', 'Observation is outside the controller-enrolled repository.');
|
|
71
|
+
try {
|
|
72
|
+
const { stdout } = await execute('gh', ['api', '--hostname', 'github.com', '--method', 'GET', path], { cwd, signal, timeout: 20_000, maxBuffer: 1024 * 1024 });
|
|
73
|
+
return { status: 200, body: stdout };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
// A missing optional CLI still permits a public read of the same resource.
|
|
76
|
+
// Never discover credentials or broaden the enrolled repository scope.
|
|
77
|
+
if (error.code !== 'ENOENT') throw error;
|
|
78
|
+
return publicRead(`https://api.github.com/${path}`, { signal });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resultExcerpt(body, fields) {
|
|
83
|
+
if (!fields) return evidenceExcerpt(body, 1000);
|
|
84
|
+
let parsed;
|
|
85
|
+
try { parsed = JSON.parse(body); } catch { throw failure('invalid_json', 'Required result fields need a JSON response.'); }
|
|
86
|
+
// Resolve only own properties, with no evaluation or inherited-property access.
|
|
87
|
+
// Bound each field separately so a large first value cannot hide later results.
|
|
88
|
+
const selected = fields.map(path => {
|
|
89
|
+
let value = parsed, found = true;
|
|
90
|
+
for (const key of path.slice(1).split('/').map(part => part.replace(/~1/g, '/').replace(/~0/g, '~'))) {
|
|
91
|
+
if (!value || typeof value !== 'object' || !Object.hasOwn(value, key)) { found = false; break; }
|
|
92
|
+
value = value[key];
|
|
93
|
+
}
|
|
94
|
+
// Include the path in redaction: selecting a credential value must not
|
|
95
|
+
// remove the sensitive key which normally triggers redaction.
|
|
96
|
+
return { path, found, ...(found ? { value: evidenceExcerpt({ [path]: value }, 500) } : {}) };
|
|
97
|
+
});
|
|
98
|
+
return evidenceExcerpt({ selected }, 4000);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const httpOutcome = status => status >= 200 && status < 300 ? 'success' : status === 404 ? 'not_found' :
|
|
102
|
+
[401, 403].includes(status) ? 'authentication_or_permission' : status === 429 ? 'rate_limit' :
|
|
103
|
+
status >= 300 && status < 400 ? 'redirect' : status >= 500 ? 'server_error' : 'http_error';
|
|
104
|
+
|
|
105
|
+
export async function collectPurposeObservations(managed, { cwd, signal, authorize, publicRead = readPublicResult, repositoryRead = readRepositoryResult, now, clock, networkAllowed = false } = {}) {
|
|
106
|
+
const completedAt = clock || (now === undefined ? Date.now : () => now);
|
|
107
|
+
now ??= completedAt();
|
|
108
|
+
const ledger = managed.purpose.evidence;
|
|
109
|
+
const findings = (ledger?.findings || []).filter(item => item.status === 'open' && item.observation)
|
|
110
|
+
.map(finding => { let recipe, invalid = false; try { recipe = normalizeObservation(finding.observation); } catch { recipe = { type: 'unsupported', findingId: finding.id }; invalid = true; } const id = createHash('sha256').update(JSON.stringify(invalid ? finding.observation : recipe)).digest('hex'); const prior = ledger.observationAttempts?.[id] || ledger.observations?.findLast(item => item.id === id); return { finding, recipe, id, prior, invalid }; })
|
|
111
|
+
.filter(item => item && (!item.prior || now - item.prior.at >= (item.prior.status === 'observed' ? 60_000 : 300_000)))
|
|
112
|
+
.sort((a, b) => (a.prior?.at || 0) - (b.prior?.at || 0)).slice(0, 4);
|
|
113
|
+
const receipts = [];
|
|
114
|
+
for (const {finding, recipe, id, invalid} of findings) {
|
|
115
|
+
if (receipts.some(item => item.id === id)) continue;
|
|
116
|
+
await authorize(); signal?.throwIfAborted();
|
|
117
|
+
const startedAt = completedAt();
|
|
118
|
+
let receipt;
|
|
119
|
+
try {
|
|
120
|
+
if (invalid) throw failure('unsupported_recipe', 'Unsupported observation recipe.');
|
|
121
|
+
if (!networkAllowed) throw failure('network_disabled', 'Original provider does not allow network access.');
|
|
122
|
+
const result = recipe.type === 'https_get' ? await publicRead(recipe.url, { signal }) : await repositoryRead(recipe.path, { cwd, signal, repository: ledger.baseline?.repository });
|
|
123
|
+
receipt = { id, findingId: finding.id, recipe, source: 'controller_read_only_observer', status: 'observed', httpStatus: result.status, outcome: httpOutcome(result.status),
|
|
124
|
+
digest: createHash('sha256').update(result.body).digest('hex'), observation: resultExcerpt(result.body, result.status >= 200 && result.status < 300 ? recipe.fields : null), startedAt, at: completedAt(), targetRevision: result.revision || null, etag: result.etag || null };
|
|
125
|
+
} catch (error) {
|
|
126
|
+
signal?.throwIfAborted();
|
|
127
|
+
const outcome = ['unsupported_recipe', 'network_disabled', 'non_public_target', 'repository_scope', 'invalid_json', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOENT'].includes(error.code) ? error.code : 'read_failed';
|
|
128
|
+
receipt = { id, findingId: finding.id, recipe, source: 'controller_read_only_observer', status: 'unavailable', startedAt, at: completedAt(),
|
|
129
|
+
outcome,
|
|
130
|
+
reason: 'The scoped read failed. Investigate the endpoint or choose another supported observation; no permission was granted and no write was attempted.' };
|
|
131
|
+
}
|
|
132
|
+
await authorize(); receipts.push(receipt);
|
|
133
|
+
}
|
|
134
|
+
return receipts;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A long independent review must not repeatedly expire its own starting
|
|
138
|
+
// evidence. Re-read the same recipes under the same authority before settling.
|
|
139
|
+
export async function refreshPurposeObservations(managed, prior, { collect = collectPurposeObservations, ...options } = {}) {
|
|
140
|
+
const selected = new Map();
|
|
141
|
+
for (const item of prior) if (item.source === 'controller_read_only_observer' && item.status === 'observed' && item.httpStatus >= 200 && item.httpStatus < 300) {
|
|
142
|
+
if (!selected.has(item.id) || selected.get(item.id).at <= item.at) selected.set(item.id, item);
|
|
143
|
+
}
|
|
144
|
+
const sources = [...selected.values()];
|
|
145
|
+
const observations = [];
|
|
146
|
+
for (let start = 0; start < Math.min(sources.length, 20); start += 4) {
|
|
147
|
+
const evidence = { ...managed.purpose.evidence, observations: [], observationAttempts: {},
|
|
148
|
+
findings: sources.slice(start, start + 4).map(item => ({ id: item.findingId, status: 'open', observation: item.recipe })) };
|
|
149
|
+
observations.push(...await collect({ ...managed, purpose: { ...managed.purpose, evidence } }, options));
|
|
150
|
+
}
|
|
151
|
+
const comparable = item => {
|
|
152
|
+
if (!item || item.status !== 'observed' || item.httpStatus < 200 || item.httpStatus >= 300) return null;
|
|
153
|
+
if (item.recipe?.fields && !item.observation?.truncated && !item.observation?.redacted) {
|
|
154
|
+
try {
|
|
155
|
+
const values = JSON.parse(item.observation.text).selected;
|
|
156
|
+
if (Array.isArray(values) && values.length === item.recipe.fields.length && values.every(row => row.found === false || row.value && !row.value.truncated && !row.value.redacted)) return JSON.stringify([item.httpStatus, values]);
|
|
157
|
+
} catch { /* Incomplete projections require the full-response identity. */ }
|
|
158
|
+
}
|
|
159
|
+
return /^[a-f0-9]{64}$/.test(item.digest) ? `${item.httpStatus}:${item.digest}` : null;
|
|
160
|
+
};
|
|
161
|
+
for (const after of observations) {
|
|
162
|
+
const before = sources.find(item => item.id === after.id);
|
|
163
|
+
if (before && comparable(before) && comparable(before) === comparable(after)) {
|
|
164
|
+
after.revalidation = { priorAt: before.at, priorDigest: before.digest, comparedAt: after.at, unchanged: true };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const times = observations.map(item => item.at);
|
|
168
|
+
return { observations, window: { source: 'controller_refresh_batch', startedAt: times.length ? Math.min(...observations.map(item => item.startedAt ?? item.at)) : null, finishedAt: times.length ? Math.max(...times) : null }, temporalSkewMs: times.length ? Math.max(...times) - Math.min(...times) : 0, atomic: false, changed: sources.length > 20 || sources.some(before => {
|
|
169
|
+
const after = observations.find(item => item.id === before.id);
|
|
170
|
+
return !comparable(before) || comparable(before) !== comparable(after);
|
|
171
|
+
}) };
|
|
172
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { preparePurposeCandidate } from './purpose-candidate.js';
|
|
2
|
+
import { requireProviderLink } from '../provider-links.js';
|
|
3
|
+
import { searchEnabled, purposeFailureMode } from '../codex-plugins/simy/hooks/purpose-search.mjs';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { listRecords, readRecord, updateRecord, automaticContinuationAllowed, queuePurposeDiagnosis, queuePurposeVerification } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
6
|
+
import { openCodexAppServer, readCodexSession, observeCodexAuthentication } from './codex-app-server.js';
|
|
7
|
+
import { realpath } from 'node:fs/promises';
|
|
8
|
+
import { loadPurposeRuleSet } from './purpose-instruction.js';
|
|
9
|
+
import { purposeEnabled } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
|
|
10
|
+
import { upgradeAutonomousRecord, upgradeEligible } from './autonomous-upgrade.js';
|
|
11
|
+
import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/acceptance-policy.mjs';
|
|
12
|
+
|
|
13
|
+
export async function observePurposeTurn(record, { openClient = openCodexAppServer } = {}) {
|
|
14
|
+
const client = await openClient();
|
|
15
|
+
try {
|
|
16
|
+
const metadata = await readCodexSession(client, record.session);
|
|
17
|
+
if (await realpath(metadata.cwd) !== record.cwd || metadata.model !== record.run.managed.resumeModel) return { state: 'unknown' };
|
|
18
|
+
const result = await client.request('thread/turns/list', { threadId: record.session, limit: 1, sortDirection: 'desc', itemsView: 'summary' });
|
|
19
|
+
const turn = result?.data?.[0];
|
|
20
|
+
if (!turn?.id || !['completed', 'failed', 'interrupted', 'inProgress'].includes(turn.status)) return { state: 'unknown' };
|
|
21
|
+
return { state: turn.status === 'inProgress' ? 'running' : 'idle', turnId: turn.id, status: turn.status };
|
|
22
|
+
} finally { await client.close(); }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function observeUpgradeMetadata(record, { openClient = openCodexAppServer } = {}) {
|
|
26
|
+
const client = await openClient();
|
|
27
|
+
try {
|
|
28
|
+
const meta = await readCodexSession(client, record.session);
|
|
29
|
+
let isRunning = meta.isRunning;
|
|
30
|
+
if (isRunning === undefined) {
|
|
31
|
+
const latest = await client.request('thread/turns/list', { threadId: record.session, limit: 1, sortDirection: 'desc', itemsView: 'summary' });
|
|
32
|
+
const turn = latest?.data?.[0];
|
|
33
|
+
if (turn?.id && ['completed', 'failed', 'interrupted', 'inProgress'].includes(turn.status)) isRunning = turn.status === 'inProgress';
|
|
34
|
+
}
|
|
35
|
+
return { session: record.session, cwd: await realpath(meta.cwd), model: meta.model, isRunning };
|
|
36
|
+
}
|
|
37
|
+
finally { await client.close(); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function reconcilePurposeTasks({ root, signal, prepareCandidate = preparePurposeCandidate, now = Date.now(), observeTurn = observePurposeTurn, observeAuthentication = observeCodexAuthentication, readRecords = () => listRecords(root), observeMetadata = observeUpgradeMetadata }) {
|
|
41
|
+
let rules;
|
|
42
|
+
const deferred = [];
|
|
43
|
+
|
|
44
|
+
for (let record of await readRecords()) {
|
|
45
|
+
try {
|
|
46
|
+
let run = record.run;
|
|
47
|
+
if (upgradeEligible(record) && (run.managed.controllerContractVersion !== 2 || run.state === 'paused' || run.state === 'verification_pending')) {
|
|
48
|
+
let metadata, migrationLink;
|
|
49
|
+
try { migrationLink = await requireProviderLink({ root, provider: record.provider }); metadata = await observeMetadata(record); }
|
|
50
|
+
catch { metadata = null; }
|
|
51
|
+
if (metadata) {
|
|
52
|
+
rules ||= await loadPurposeRuleSet();
|
|
53
|
+
record = await updateRecord(root, record.provider, record.session, async current => {
|
|
54
|
+
await requireProviderLink({ root, provider: record.provider, expectedEpoch: migrationLink.epoch });
|
|
55
|
+
if (current?.run?.id !== run.id || current.run.revision !== run.revision || current.run.state !== run.state || current.autoContinueEpoch !== record.autoContinueEpoch) return current;
|
|
56
|
+
upgradeAutonomousRecord(current, { rules, now, metadata }); return current;
|
|
57
|
+
});
|
|
58
|
+
run = record.run;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Existing ON consent enables the new diagnosis mode without renewing any
|
|
62
|
+
// task identity, acceptance condition, permission, budget or active lease.
|
|
63
|
+
if (record.provider === 'codex' && record.autoContinue === true && automaticContinuationAllowed(record) &&
|
|
64
|
+
run?.managed?.resumeChannel === 'codex_app_server' && (!run.managed.purpose || run.managed.purpose.failureBudgetMode !== purposeFailureMode(record)) &&
|
|
65
|
+
['active', 'blocked', 'recovery_required', 'verification_pending'].includes(run.state) && !run.managed.resumeLease) {
|
|
66
|
+
let migrationLink;
|
|
67
|
+
try { migrationLink = await requireProviderLink({ root, provider: record.provider }); } catch { continue; }
|
|
68
|
+
rules ||= await loadPurposeRuleSet();
|
|
69
|
+
record = await updateRecord(root, record.provider, record.session, async current => {
|
|
70
|
+
try { await requireProviderLink({ root, provider: record.provider, expectedEpoch: migrationLink.epoch }); } catch { return current; }
|
|
71
|
+
if (current?.run?.id !== run.id || current.run.revision !== run.revision || current.run.state !== run.state ||
|
|
72
|
+
current.autoContinue !== true || current.autoContinueEpoch !== record.autoContinueEpoch || !automaticContinuationAllowed(current) ||
|
|
73
|
+
current.run.managed.resumeLease) return current;
|
|
74
|
+
current.run.managed.purpose = { ...current.run.managed.purpose, enabled: true, version: 2, decisionPolicy: 'llm-first-hc102', failureBudgetMode: purposeFailureMode(current),
|
|
75
|
+
ruleDigest: rules.digest, ruleIds: rules.rules.map(rule => rule.id), recoveries: current.run.managed.purpose?.recoveries || [] };
|
|
76
|
+
return current;
|
|
77
|
+
});
|
|
78
|
+
run = record?.run;
|
|
79
|
+
}
|
|
80
|
+
if (!purposeEnabled(run) || run.managed.resumeChannel !== 'codex_app_server') continue;
|
|
81
|
+
if (!automaticContinuationAllowed(record)) continue;
|
|
82
|
+
let link;
|
|
83
|
+
try { link = await requireProviderLink({ root, provider: record.provider }); } catch { continue; }
|
|
84
|
+
const authorized = async () => {
|
|
85
|
+
await requireProviderLink({ root, provider: record.provider, expectedEpoch: link.epoch });
|
|
86
|
+
const latest = await readRecord(root, record.provider, record.session);
|
|
87
|
+
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.');
|
|
88
|
+
};
|
|
89
|
+
const observe = async read => { await authorized(); const result = await read(); await authorized(); return result; };
|
|
90
|
+
const persist = mutate => updateRecord(root, record.provider, record.session, async current => {
|
|
91
|
+
try { await requireProviderLink({ root, provider: record.provider, expectedEpoch: link.epoch }); } catch { return current; }
|
|
92
|
+
if (!current || !automaticContinuationAllowed(current) || current.autoContinueEpoch !== record.autoContinueEpoch) return current;
|
|
93
|
+
return mutate(current);
|
|
94
|
+
});
|
|
95
|
+
rules ||= await loadPurposeRuleSet();
|
|
96
|
+
if (run.managed.purpose.ruleDigest !== rules.digest && run.state !== 'auto_resuming') {
|
|
97
|
+
record = await persist(current => {
|
|
98
|
+
if (!purposeEnabled(current?.run) || current.run.state === 'auto_resuming') return current;
|
|
99
|
+
current.run.managed.purpose.ruleDigest = rules.digest;
|
|
100
|
+
current.run.managed.purpose.ruleIds = rules.rules.map(rule => rule.id);
|
|
101
|
+
delete current.run.managed.purpose.lastReview;
|
|
102
|
+
if (current.run.managed.purpose.search) delete current.run.managed.purpose.search.review;
|
|
103
|
+
if (current.run.state === 'verification_pending') queuePurposeDiagnosis(current.run,
|
|
104
|
+
'Canonical controls changed. Recheck affected obligations against the existing result without repeating completed effects or asking for human rule certification.', `rules:${rules.digest}`, now);
|
|
105
|
+
return current;
|
|
106
|
+
});
|
|
107
|
+
run = record.run;
|
|
108
|
+
}
|
|
109
|
+
if (run.managed.purpose.nativeBoundary && !run.managed.resumeLease && now >= (run.managed.purpose.nativeObservedAt || 0) + 60_000) {
|
|
110
|
+
let native;try {native=await observe(()=>observeTurn(record));} catch {native={state:'unknown'};}
|
|
111
|
+
await persist(current=>{
|
|
112
|
+
if(current?.run?.id !== run.id || current.run.revision !== run.revision) return current;
|
|
113
|
+
const purpose=current.run.managed.purpose;purpose.nativeObservedAt=now;purpose.nativeObservation=native;
|
|
114
|
+
if(native.status==='completed' && native.turnId===purpose.nativeBoundary?.turnId && purpose.nativeBoundary.state!=='writer_completed_requires_verification') {
|
|
115
|
+
purpose.nativeBoundary={...purpose.nativeBoundary,state:'writer_completed_requires_verification',writer:'completed',completedTurnId:native.turnId,observedAt:now};
|
|
116
|
+
queuePurposeVerification(current.run,'Original pending turn completed; independently inspect its actual result and reconcile effects without replaying the operation.',now);
|
|
117
|
+
} 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;}
|
|
118
|
+
return current;
|
|
119
|
+
});
|
|
120
|
+
record=await readRecord(root,record.provider,record.session);run=record.run;
|
|
121
|
+
}
|
|
122
|
+
if (searchEnabled(run.managed) && run.managed.purpose.nativeBoundary && run.state==='blocked' && !run.managed.resumeLease &&
|
|
123
|
+
['local_edit','local_test'].includes(run.managed.decision?.action.kind) && run.managed.decision.nativeIsolationEligible === true && !run.managed.rejectedActionOverflow && !run.managed.rejectedActionDigests?.includes(createHash('sha256').update(JSON.stringify(run.managed.decision.action)).digest('hex')) &&
|
|
124
|
+
run.managed.plan?.alternatives.find(row=>row.id===run.managed.decision.selectedOptionId)?.independent &&
|
|
125
|
+
(!run.managed.purpose.candidate || run.managed.purpose.candidate.status==='ready' && run.managed.purpose.candidate.purposeRevision===run.managed.purpose.evidence.purposeRevision) && now >= (run.managed.purpose.nextCandidateAt || 0)) {
|
|
126
|
+
let candidate;
|
|
127
|
+
try {
|
|
128
|
+
const authorizeCandidate=async()=>{
|
|
129
|
+
await authorized();
|
|
130
|
+
const sandbox=run.managed.providerSandbox;
|
|
131
|
+
if(!['workspaceWrite','dangerFullAccess'].includes(sandbox?.type)) throw new Error('Original local write permission unavailable.');
|
|
132
|
+
const client=await openCodexAppServer();
|
|
133
|
+
try {const result=await client.request('config/read',{cwd:record.cwd,includeLayers:false});
|
|
134
|
+
if(!['workspace-write','danger-full-access'].includes(result?.config?.sandbox_mode)) throw new Error('Current provider write permission unavailable.');
|
|
135
|
+
} finally {await client.close();}
|
|
136
|
+
};
|
|
137
|
+
if(run.managed.purpose.candidate){
|
|
138
|
+
await authorizeCandidate();signal?.throwIfAborted();
|
|
139
|
+
candidate=run.managed.purpose.candidate;
|
|
140
|
+
} else candidate=await prepareCandidate(record,{root,signal,authorize:authorizeCandidate});
|
|
141
|
+
} catch { /* Retain native wait and investigate the missing isolation capability. */ }
|
|
142
|
+
await persist(current=>{
|
|
143
|
+
if(current?.run?.id!==run.id || current.run.revision!==run.revision || current.run.state!=='blocked') return current;
|
|
144
|
+
current.run.managed.purpose.nextCandidateAt=now+60000;
|
|
145
|
+
if(!candidate){current.run.managed.purpose.candidateGap='An authorized stable Git snapshot/current sandbox could not be confirmed. Continue read-only diagnosis.';return current;}
|
|
146
|
+
current.run.managed.purpose.candidate={...candidate,decisionId:current.run.managed.decision.id,purposeRevision:current.run.managed.purpose.evidence.purposeRevision};
|
|
147
|
+
if(!current.run.managed.purpose.evidence.units.some(unit=>unit.id===`candidate-apply:${candidate.id}:revision:${current.run.managed.purpose.evidence.purposeRevision}`)) current.run.managed.purpose.evidence.units.push({id:`candidate-apply:${candidate.id}:revision:${current.run.managed.purpose.evidence.purposeRevision}`,required:true,criterionIds:current.run.managed.acceptanceCriteria.map(row=>row.id),
|
|
148
|
+
dependsOn:[],readResources:[candidate.directory],writeResources:[candidate.original],requiredCapabilities:['exclusive_or_CAS_application'],requiredPermissions:['task'],
|
|
149
|
+
status:'waiting',evidenceRefs:[],purposeRevision:current.run.managed.purpose.evidence.purposeRevision});
|
|
150
|
+
current.run.managed.decision.outcome='execute';current.run.managed.pendingApproval=null;current.run.managed.autoRecovery=true;
|
|
151
|
+
current.run.state='recovery_required';current.run.recoveryCause='session_supervision';current.run.managed.nextAttemptAt=now;
|
|
152
|
+
return current;
|
|
153
|
+
});
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
// An active writer is handled exclusively by the provider lock/watchdog.
|
|
157
|
+
// Never use elapsed silence alone to assume the provider has exited.
|
|
158
|
+
let obstacle = null;
|
|
159
|
+
if (run.state === 'blocked' && run.blockerSource === 'connection' && run.managed.authenticationWait) {
|
|
160
|
+
if (now < run.managed.authenticationWait.nextCheckAt || now >= run.deadline) continue;
|
|
161
|
+
let authentication;
|
|
162
|
+
try { authentication = await observe(() => observeAuthentication()); } catch { authentication = 'unknown'; }
|
|
163
|
+
await persist(current => {
|
|
164
|
+
if (current?.run?.id !== run.id || current.run.state !== 'blocked' || current.run.revision !== run.revision || current.run.updatedAt !== run.updatedAt) return current;
|
|
165
|
+
current.run.managed.authenticationWait.nextCheckAt = now + 60_000;
|
|
166
|
+
if (authentication === 'available') {
|
|
167
|
+
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);
|
|
168
|
+
if (queued) delete current.run.managed.authenticationWait;
|
|
169
|
+
}
|
|
170
|
+
return current;
|
|
171
|
+
});
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (run.state === 'active' && !run.managed.resumeLease && now >= (run.managed.purpose.lastObservedAt || 0) + 60_000) {
|
|
175
|
+
let observed;
|
|
176
|
+
try { observed = await observe(() => observeTurn(record)); } catch { observed = { state: 'unavailable' }; }
|
|
177
|
+
await persist(current => {
|
|
178
|
+
if (current?.run?.id !== run.id || current.run.state !== 'active' || current.run.revision !== run.revision) return current;
|
|
179
|
+
current.run.managed.purpose.lastObservedAt = now;
|
|
180
|
+
current.run.managed.purpose.observationState = observed.state;
|
|
181
|
+
return current;
|
|
182
|
+
});
|
|
183
|
+
if (observed.state === 'idle') obstacle = { type: 'idle_without_checkpoint', detail: observed.turnId };
|
|
184
|
+
} else if (run.state === 'blocked' && ['missing_information', 'authorization', 'external_dependency'].includes(run.blocker)) {
|
|
185
|
+
if (run.blockerSource === 'provider' || run.managed.attempts?.at(-1)?.errorCode === 'provider_approval') {
|
|
186
|
+
if (now < (run.managed.purpose.lastObservedAt || 0) + 60_000) continue;
|
|
187
|
+
let observed;
|
|
188
|
+
try { observed = await observe(() => observeTurn(record)); } catch { observed = { state: 'unavailable' }; }
|
|
189
|
+
await persist(current => {
|
|
190
|
+
if (current?.run?.id !== run.id || current.run.state !== 'blocked' || current.run.revision !== run.revision) return current;
|
|
191
|
+
current.run.managed.purpose.lastObservedAt = now;
|
|
192
|
+
current.run.managed.purpose.observationState = observed.state;
|
|
193
|
+
if (!current.run.managed.nativeWait?.turnId && observed.turnId) current.run.managed.nativeWait = { turnId: observed.turnId, observedAt: now };
|
|
194
|
+
return current;
|
|
195
|
+
});
|
|
196
|
+
// A failed/interrupted owned turn or merely elapsed time is not a user
|
|
197
|
+
// answer. A subsequent completed original turn permits diagnosis only.
|
|
198
|
+
if (run.managed.nativeWait?.turnId && observed.status === 'completed' && observed.turnId !== run.managed.nativeWait.turnId) {
|
|
199
|
+
await persist(current => {
|
|
200
|
+
if (current?.run?.id === run.id && current.run.revision === run.revision) {
|
|
201
|
+
current.run.managed.nativeWait = null;
|
|
202
|
+
delete current.run.managed.purpose.nativeBoundary;
|
|
203
|
+
}
|
|
204
|
+
return current;
|
|
205
|
+
});
|
|
206
|
+
run.managed.nativeWait = null;
|
|
207
|
+
delete run.managed.purpose.nativeBoundary;
|
|
208
|
+
obstacle = { type: 'native_wait_updated', detail: observed.turnId };
|
|
209
|
+
} else if (searchEnabled(run.managed)) {
|
|
210
|
+
obstacle = { type: 'native_wait_diagnosis', detail: run.managed.nativeWait?.turnId || 'writer_unknown' };
|
|
211
|
+
} else continue;
|
|
212
|
+
} else if (now >= (run.managed.purpose.lastObservedAt || 0) + 60_000) {
|
|
213
|
+
let observed;
|
|
214
|
+
try { observed = await observe(() => observeTurn(record)); } catch { observed = { state: 'unavailable' }; }
|
|
215
|
+
const previousTurn = run.managed.purpose.lastObservedTurnId || run.managed.purpose.lastOwnedTurnId;
|
|
216
|
+
await persist(current => {
|
|
217
|
+
if (current?.run?.id !== run.id || current.run.state !== 'blocked' || current.run.revision !== run.revision) return current;
|
|
218
|
+
current.run.managed.purpose.lastObservedAt = now;
|
|
219
|
+
current.run.managed.purpose.observationState = observed.state;
|
|
220
|
+
if (observed.status === 'completed') current.run.managed.purpose.lastObservedTurnId = observed.turnId;
|
|
221
|
+
return current;
|
|
222
|
+
});
|
|
223
|
+
// A fresh completed original-chat turn can contain an answer or new
|
|
224
|
+
// evidence. It permits research, not automatic permission approval.
|
|
225
|
+
// Never treat our own diagnostic checkpoint as an external update.
|
|
226
|
+
if (previousTurn && observed.status === 'completed' && observed.turnId !== previousTurn &&
|
|
227
|
+
observed.turnId !== run.managed.purpose.lastOwnedTurnId) obstacle = { type: 'original_context_updated', detail: observed.turnId };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!obstacle && run.blocker === 'external_dependency' && now < Math.max((run.managed.dependencyWait?.createdAt || run.updatedAt) + 60_000, run.managed.purpose.nextDependencyCheckAt || 0)) continue;
|
|
231
|
+
obstacle ||= { type: run.blocker, detail: run.managed.understandingQuestion?.text || run.managed.userQuestion ||
|
|
232
|
+
run.managed.dependencyWait?.condition || run.managed.pendingApproval?.summary || run.reason };
|
|
233
|
+
} else if (run.state === 'verification_pending') {
|
|
234
|
+
if (searchEnabled(run.managed) && (run.managed.purpose.search?.review?.passed || run.managed.purpose.search?.review?.needsHuman) && effectiveAcceptanceCriteria(run.managed).filter(c => !run.managed.verified[c.id]).every(c => c.check.type === 'manual')) continue;
|
|
235
|
+
obstacle = { type: 'verification', detail: run.managed.acceptanceCriteria.filter(c => !run.managed.verified[c.id]).map(c => c.id).join(',') };
|
|
236
|
+
} else if (run.state === 'recovery_required' && ['provider_error', 'process_exit', 'shutdown_cancelled', 'interrupted_owner'].includes(run.recoveryCause) &&
|
|
237
|
+
!run.managed.autoRecovery && now >= run.updatedAt + 15_000) {
|
|
238
|
+
const code = run.managed.attempts?.at(-1)?.errorCode;
|
|
239
|
+
if (code && !['checkpoint', 'checkpoint_rejected', 'closed', 'timeout', 'unavailable', 'turn_failed', 'provider_error', ...(searchEnabled(run.managed) ? ['permission_narrowed', 'manual_provider_action', 'unsupported'] : [])].includes(code)) continue;
|
|
240
|
+
obstacle = { type: 'execution_error', detail: code || run.recoveryCause };
|
|
241
|
+
}
|
|
242
|
+
if (!obstacle) continue;
|
|
243
|
+
const key = createHash('sha256').update(JSON.stringify([run.managed.verifiedRevision, obstacle, obstacle.type === 'external_dependency' ? run.managed.purpose.dependencyChecks || 0 : null])).digest('hex');
|
|
244
|
+
await persist(current => {
|
|
245
|
+
if (current?.run?.id !== run.id || current.run.revision !== run.revision || current.run.state !== run.state || current.run.updatedAt !== run.updatedAt) return current;
|
|
246
|
+
if (searchEnabled(current.run.managed) && ['original_context_updated', 'native_wait_updated'].includes(obstacle.type)) current.run.managed.purpose.nextSearchAt = 0;
|
|
247
|
+
const queued = queuePurposeDiagnosis(current.run,
|
|
248
|
+
`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);
|
|
249
|
+
if (queued && obstacle.type === 'external_dependency') {
|
|
250
|
+
const purpose = current.run.managed.purpose;
|
|
251
|
+
purpose.dependencyChecks = (purpose.dependencyChecks || 0) + 1;
|
|
252
|
+
purpose.nextDependencyCheckAt = now + Math.min(15 * 60_000, 60_000 * 2 ** Math.min(4, purpose.dependencyChecks));
|
|
253
|
+
}
|
|
254
|
+
return current;
|
|
255
|
+
});
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (!/^Delivery state (?:is busy; retry the command\.|lock owner is unavailable; inspect the lock before retrying\.)$/.test(error.message)) throw error;
|
|
258
|
+
// One unavailable record cannot prevent independent conversations from
|
|
259
|
+
// progressing. Never delete or reclaim an ambiguous writer lock.
|
|
260
|
+
deferred.push({ provider: record.provider, session: record.session, reason: 'record_busy' });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return { deferred };
|
|
264
|
+
}
|