chati-dev 4.5.27 → 4.5.29
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/bin/chati.js +151 -123
- package/framework/agents/build/dev.md +4 -4
- package/framework/agents/deploy/devops.md +3 -3
- package/framework/agents/discover/brief.md +18 -6
- package/framework/agents/discover/brownfield-wu.md +3 -3
- package/framework/agents/discover/greenfield-wu.md +4 -4
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +4 -4
- package/framework/agents/plan/phases.md +3 -3
- package/framework/agents/plan/tasks.md +47 -19
- package/framework/agents/plan/ux-brand-architect.md +1 -1
- package/framework/agents/plan/ux.md +26 -2
- package/framework/agents/quality/qa-implementation.md +8 -8
- package/framework/agents/quality/qa-planning.md +58 -4
- package/framework/agents/quality/qa-visual.md +4 -4
- package/framework/config.yaml +2 -2
- package/framework/constitution.md +12 -1
- package/framework/context/governance.md +2 -1
- package/framework/context/root.md +3 -1
- package/framework/domains/constitution.yaml +1 -1
- package/framework/domains/global.yaml +1 -1
- package/framework/domains/workflows/standard-flow.yaml +7 -8
- package/framework/hooks/advance-trigger.js +3 -2
- package/framework/hooks/brief-validator.js +1 -1
- package/framework/hooks/post-dev.js +1 -1
- package/framework/hooks/team-quality-gate.js +9 -2
- package/framework/i18n/en.yaml +1 -0
- package/framework/i18n/es.yaml +1 -0
- package/framework/i18n/fr.yaml +1 -0
- package/framework/i18n/pt.yaml +1 -0
- package/framework/manifest.json +83 -83
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +14 -6
- package/framework/orchestrator/chati.md +61 -23
- package/framework/rules/human-writing-style.md +7 -0
- package/framework/schemas/session.schema.json +25 -2
- package/framework/schemas/task.schema.json +26 -0
- package/framework/tasks/orchestrator-handoff.md +10 -10
- package/framework/tasks/orchestrator-resume.md +3 -3
- package/framework/workflows/brownfield-fullstack.yaml +6 -3
- package/framework/workflows/brownfield-service.yaml +5 -3
- package/framework/workflows/brownfield-ui.yaml +5 -3
- package/framework/workflows/greenfield-fullstack.yaml +6 -3
- package/framework/workflows/standard-flow.yaml +8 -3
- package/node_modules/@chati/core/native/browser-proof-wire.h +29 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.c +36 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.h +32 -0
- package/node_modules/@chati/core/native/darwin-browser-proof.h +86 -0
- package/node_modules/@chati/core/native/darwin-code-identity.h +35 -0
- package/node_modules/@chati/core/native/darwin-launch-messages.h +37 -0
- package/node_modules/@chati/core/native/darwin-launcher.c +312 -0
- package/node_modules/@chati/core/native/descriptor-fs.c +407 -0
- package/node_modules/@chati/core/native/execution-admission.h +59 -0
- package/node_modules/@chati/core/native/execution-gate.c +41 -0
- package/node_modules/@chati/core/native/linux-process.c +41 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/darwin-launcher +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/execution-gate +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/execution-gate +0 -0
- package/node_modules/@chati/core/native/process-info-pipe.c +91 -0
- package/node_modules/@chati/core/package.json +2 -1
- package/node_modules/@chati/core/scripts/build-native.js +36 -0
- package/node_modules/@chati/core/src/darwin-browser-proof.js +87 -0
- package/node_modules/@chati/core/src/darwin-host-boot.js +58 -0
- package/node_modules/@chati/core/src/darwin-process.js +79 -0
- package/node_modules/@chati/core/src/darwin-supervisor-channels.js +82 -0
- package/node_modules/@chati/core/src/darwin-terminal-receipt.js +111 -0
- package/node_modules/@chati/core/src/descriptor-fs.js +222 -0
- package/node_modules/@chati/core/src/execution-gate.js +8 -0
- package/node_modules/@chati/core/src/host-boot-marker.js +45 -0
- package/node_modules/@chati/core/src/index.js +18 -0
- package/node_modules/@chati/core/src/linux-host-boot.js +218 -0
- package/node_modules/@chati/core/src/linux-process.js +133 -0
- package/node_modules/@chati/planning/src/browser-verification-plan.js +71 -0
- package/node_modules/@chati/planning/src/index.js +33 -0
- package/node_modules/@chati/planning/src/planning-material.js +64 -0
- package/node_modules/@chati/planning/src/verification-plan.js +57 -0
- package/node_modules/@chati/rail/package.json +1 -0
- package/node_modules/@chati/rail/src/execution-restoration.js +92 -0
- package/node_modules/@chati/rail/src/index.js +812 -167
- package/node_modules/@chati/rail/src/provider-processing.js +118 -0
- package/node_modules/@chati/rail/src/terminal-containment.js +23 -0
- package/node_modules/@chati/release-lane/src/index.js +40 -43
- package/node_modules/@chati/review-council/src/index.js +223 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +67 -58
- package/package-artifact-manifest.json +1 -1
- package/package-artifact-manifest.sig +1 -1
- package/package.json +3 -2
- package/scripts/verify-real-harness-e2e.js +20 -8
- package/src/installer/core.js +147 -107
- package/src/installer/package-artifact.js +7 -0
- package/src/installer/preflight.js +13 -0
- package/src/installer/provider-overlay.js +1 -2
- package/src/installer/templates.js +58 -0
- package/src/installer-v2/catalog-client.js +31 -1
- package/src/installer-v2/clickup-connection-storage.js +92 -0
- package/src/installer-v2/clickup-mcp-client.js +155 -0
- package/src/installer-v2/clickup-oauth-loopback.js +96 -0
- package/src/installer-v2/clickup-oauth.js +233 -0
- package/src/installer-v2/index.js +141 -15
- package/src/installer-v2/installation-authority.js +98 -4
- package/src/installer-v2/provider-executable.js +137 -20
- package/src/installer-v2/wizard-installation.js +11 -0
- package/src/orchestrator/agent-selector.js +39 -1
- package/src/orchestrator/browser-artifact-store.js +131 -0
- package/src/orchestrator/cli.js +762 -205
- package/src/orchestrator/clickup-mcp-observer.js +226 -0
- package/src/orchestrator/clickup-runtime.js +45 -24
- package/src/orchestrator/council-gate-runtime.js +903 -0
- package/src/orchestrator/evidence-authority-enrollment.js +75 -0
- package/src/orchestrator/evidence-authority-request.js +46 -0
- package/src/orchestrator/handoff-engine.js +4 -11
- package/src/orchestrator/pipeline-manager.js +42 -4
- package/src/orchestrator/planning-contract-revision.js +711 -0
- package/src/orchestrator/planning-reconciliation.js +302 -0
- package/src/orchestrator/planning-runtime.js +476 -29
- package/src/orchestrator/rail-adjudication-evidence.js +15 -24
- package/src/orchestrator/rail-human-effort-authority.js +46 -0
- package/src/orchestrator/rail-human-effort.js +149 -0
- package/src/orchestrator/rail-runtime.js +291 -47
- package/src/orchestrator/rail-verification.js +278 -0
- package/src/orchestrator/review-runtime.js +37 -2
- package/src/orchestrator/runtime-installation-v2.js +143 -24
- package/src/orchestrator/session-manager.js +1457 -29
- package/src/tasks/handoff.js +57 -33
- package/src/terminal/adapters/claude-adapter.js +3 -0
- package/src/terminal/adapters/codex-adapter.js +13 -0
- package/src/terminal/adapters/grok-adapter.js +1 -0
- package/src/terminal/agent-attempt-budget.js +35 -0
- package/src/terminal/browser-endpoint-proxy.js +174 -0
- package/src/terminal/browser-observer-driver.js +94 -0
- package/src/terminal/browser-observer-supervisor.js +73 -0
- package/src/terminal/browser-observer.js +95 -0
- package/src/terminal/browser-process-facts.js +75 -0
- package/src/terminal/browser-runtime.js +131 -0
- package/src/terminal/darwin-authority-policy.js +213 -0
- package/src/terminal/darwin-browser-application.js +116 -0
- package/src/terminal/darwin-browser-observer.js +57 -0
- package/src/terminal/darwin-execution.js +185 -0
- package/src/terminal/executable-snapshot.js +202 -0
- package/src/terminal/handoff-parser.js +48 -2
- package/src/terminal/isolation.js +14 -13
- package/src/terminal/prompt-builder.js +176 -15
- package/src/terminal/provider-preflight.js +47 -27
- package/src/terminal/rail-execution-worktree.js +22 -6
- package/src/terminal/rail-process-observer.js +10 -0
- package/src/terminal/rail-prompts.js +21 -3
- package/src/terminal/rail-readonly-workspace.js +54 -5
- package/src/terminal/rail-worktree-checkpoint.js +315 -0
- package/src/terminal/rail-worktree-restoration.js +485 -0
- package/src/terminal/run-agent.js +164 -42
- package/src/terminal/run-council-gate.js +338 -0
- package/src/terminal/run-planning-reconciliation.js +49 -0
- package/src/terminal/run-rail-adjudication.js +29 -7
- package/src/terminal/run-rail-recovery.js +97 -0
- package/src/terminal/run-rail-review.js +36 -15
- package/src/terminal/run-rail-rework.js +26 -15
- package/src/terminal/run-rail-task.js +22 -9
- package/src/terminal/runtime-native-helper.js +85 -0
- package/src/terminal/spawner.js +899 -142
- package/src/terminal/verification-process-facts.js +49 -0
- package/src/upgrade/entrypoints.js +68 -0
- package/src/upgrade/generation.js +194 -0
- package/src/upgrade/maintenance.js +79 -0
- package/src/upgrade/paths.js +81 -0
- package/src/upgrade/upgrade.js +267 -0
- package/src/utils/execution-role.js +15 -0
- package/src/utils/handoff-paths.js +59 -0
- package/src/wizard/i18n.js +1 -0
- package/src/wizard/index.js +17 -5
- package/src/wizard/questions.js +24 -12
|
@@ -9,16 +9,35 @@ import * as yaml from 'js-yaml';
|
|
|
9
9
|
import {
|
|
10
10
|
closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync,
|
|
11
11
|
openSync, readSync, realpathSync, renameSync, rmSync, rmdirSync,
|
|
12
|
-
unlinkSync, writeFileSync, readFileSync,
|
|
12
|
+
unlinkSync, writeFileSync, readFileSync, readdirSync,
|
|
13
13
|
} from 'fs';
|
|
14
14
|
import { basename, join, dirname, resolve } from 'path';
|
|
15
15
|
import { randomUUID } from 'crypto';
|
|
16
16
|
import { hostname } from 'node:os';
|
|
17
|
+
import { canonicalize, canonicalSerialize } from '@chati/core';
|
|
18
|
+
import { isPlanningQaOutcomeValid } from '../terminal/handoff-parser.js';
|
|
19
|
+
import { getQaThreshold, resetPipelineTo, MAX_CORRECTION_CYCLES } from './pipeline-manager.js';
|
|
20
|
+
import { AGENT_PIPELINE, assertV2PlanningPredecessors } from './agent-selector.js';
|
|
21
|
+
import { pinCouncilQaRevalidation, pinPlanningQaSubject, pinPlanningQaReport } from './council-gate-runtime.js';
|
|
22
|
+
import { pinPlanningReconciliation, readPlanningReconciliationDecision,
|
|
23
|
+
readAttestedPlanningReconciliationDecision } from './planning-reconciliation.js';
|
|
24
|
+
import { PLANNING_CONTRACT_REVISION_OWNERS, PLANNING_CONTRACT_REVISION_TARGET, normalizePlanningContractDirection,
|
|
25
|
+
pinPlanningContractRevision, pinPlanningContractRevisionOwnerBaseline, pinPlanningContractRevisionOwnerOutput,
|
|
26
|
+
readAttestedPlanningContractRevision, planningContractRevisionHistory, planningRevisionResultDigest,
|
|
27
|
+
planningRevisionTerminalDigest, derivePlanningCorrectionSource,
|
|
28
|
+
authenticatePlanningContractRevisionHistory } from './planning-contract-revision.js';
|
|
29
|
+
import { resolveRuntimeInvocationV2 } from './runtime-installation-v2.js';
|
|
17
30
|
|
|
18
31
|
const SESSION_FILE = '.chati/session.yaml';
|
|
19
32
|
const SESSION_MAX_BYTES = 4 * 1024 * 1024;
|
|
20
33
|
const SESSION_LOCK_OWNER_MAX_BYTES = 16 * 1024;
|
|
21
34
|
const SESSION_LOCK_STALE_AFTER_MS = 30_000;
|
|
35
|
+
// Concurrent fsync-backed writers can legitimately queue longer than two
|
|
36
|
+
// seconds. Bound the wait without stealing live locks or shortening stale leases.
|
|
37
|
+
const SESSION_LOCK_WAIT_MS = 10_000;
|
|
38
|
+
const RELAY_HISTORY_MAX_TURNS = 64;
|
|
39
|
+
const RELAY_HISTORY_MAX_BYTES = 1024 * 1024;
|
|
40
|
+
const INITIAL_TASK_CONTEXT_MAX_BYTES = 256 * 1024;
|
|
22
41
|
const SESSION_TEST_HOOKS = Symbol.for('chati.session-manager.security-test-hooks');
|
|
23
42
|
const UNSAFE_SESSION_LOCK_OWNER = Symbol('unsafe-session-lock-owner');
|
|
24
43
|
|
|
@@ -142,6 +161,7 @@ function readRegularFileBounded(opened) {
|
|
|
142
161
|
function writeFileAtomic(path, content, encoding = 'utf-8', {
|
|
143
162
|
expectedDestinationIdentity,
|
|
144
163
|
expectedDirectoryIdentity,
|
|
164
|
+
beforeCommit,
|
|
145
165
|
} = {}) {
|
|
146
166
|
if (Buffer.byteLength(String(content), encoding) > SESSION_MAX_BYTES) {
|
|
147
167
|
throw new Error('session.yaml exceeds the 4 MiB safety limit');
|
|
@@ -187,6 +207,7 @@ function writeFileAtomic(path, content, encoding = 'utf-8', {
|
|
|
187
207
|
throw new Error('Session temporary changed before atomic rename');
|
|
188
208
|
}
|
|
189
209
|
runSecurityTestHook('beforeAtomicRenameCommit', { path, tmp });
|
|
210
|
+
beforeCommit?.();
|
|
190
211
|
assertDirectoryIdentity(directoryPath, directory.stat);
|
|
191
212
|
assertPathIdentity(destination, expectedDestination, { allowMissing: true });
|
|
192
213
|
renameSync(tmp, destination);
|
|
@@ -352,7 +373,7 @@ function recoverAbandonedSessionLock(lockPath) {
|
|
|
352
373
|
finally { if (electionFd !== undefined) closeSync(electionFd); }
|
|
353
374
|
}
|
|
354
375
|
|
|
355
|
-
function waitForSessionWriteLock(sessionPath, maxWaitMs =
|
|
376
|
+
function waitForSessionWriteLock(sessionPath, maxWaitMs = SESSION_LOCK_WAIT_MS) {
|
|
356
377
|
const startedAt = Date.now();
|
|
357
378
|
const lockPath = `${sessionPath}.lock`;
|
|
358
379
|
const parentPath = dirname(lockPath);
|
|
@@ -641,6 +662,10 @@ const DEFAULT_SESSION = {
|
|
|
641
662
|
// 2026-04-18: execution_profile + profile_transitions were folded into
|
|
642
663
|
// execution_mode (set in DEFAULT_SESSION above as 'interactive'/'autonomous').
|
|
643
664
|
providers_enabled: [],
|
|
665
|
+
// Content-addressed planning council outcomes. Each value points only to
|
|
666
|
+
// normalized evidence stored under .chati/v2/council/, never raw provider
|
|
667
|
+
// transcripts or credentials.
|
|
668
|
+
council_gates: {},
|
|
644
669
|
// BUILD → DEPLOY preview state (user approves before devops ships).
|
|
645
670
|
preview: {
|
|
646
671
|
status: 'inactive',
|
|
@@ -1008,6 +1033,88 @@ export function updateSession(projectDir, updates) {
|
|
|
1008
1033
|
});
|
|
1009
1034
|
}
|
|
1010
1035
|
|
|
1036
|
+
/** Entries that can be stored RAIL handoffs. Hidden files dropped by the OS
|
|
1037
|
+
* or editors (.DS_Store, swap files) are not handoffs and must not seal
|
|
1038
|
+
* Planning corrections; anything else remains fail-closed. */
|
|
1039
|
+
function storedRailHandoffEntries(handoffs) {
|
|
1040
|
+
return readdirSync(handoffs).filter((name) => !name.startsWith('.'));
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** Preview a negative Planning correction retarget, never an approval/reset of RAIL. */
|
|
1044
|
+
export function inspectPlanningCorrectionRollback(projectDir, session, targetAgent) {
|
|
1045
|
+
const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
|
|
1046
|
+
if (session.mode !== 'plan' || session.rail_handoff_id || session.rail_handoff_path || session.rail_state) {
|
|
1047
|
+
fail('PLANNING_CORRECTION_SEALED', 'Only unsealed Planning corrections may be retargeted.');
|
|
1048
|
+
}
|
|
1049
|
+
const handoffs = join(projectDir, '.chati/v2/rail/handoffs');
|
|
1050
|
+
try {
|
|
1051
|
+
const stat = lstatSync(handoffs);
|
|
1052
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || storedRailHandoffEntries(handoffs).length > 0) {
|
|
1053
|
+
fail('PLANNING_CORRECTION_SEALED', 'Stored RAIL handoffs prohibit Planning rollback.');
|
|
1054
|
+
}
|
|
1055
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
1056
|
+
const targets = ['detail', 'architect', 'ux'];
|
|
1057
|
+
const current = session.current_agent;
|
|
1058
|
+
if (!targets.includes(targetAgent) || !targets.includes(current)
|
|
1059
|
+
|| targets.indexOf(targetAgent) >= targets.indexOf(current)
|
|
1060
|
+
|| session.agents?.[targetAgent]?.status !== 'completed'
|
|
1061
|
+
|| !session.completed_agents?.includes(targetAgent)) {
|
|
1062
|
+
fail('INVALID_QA_CORRECTION_TARGET', 'The target must be a completed upstream Planning agent.');
|
|
1063
|
+
}
|
|
1064
|
+
const targetIndex = AGENT_PIPELINE.findIndex(agent => agent.name === targetAgent);
|
|
1065
|
+
const affectedAgents = AGENT_PIPELINE.slice(targetIndex)
|
|
1066
|
+
.filter(agent => agent.phase === 'plan' && Object.hasOwn(session.agents || {}, agent.name))
|
|
1067
|
+
.map(agent => agent.name);
|
|
1068
|
+
for (const name of affectedAgents) {
|
|
1069
|
+
const agent = session.agents[name];
|
|
1070
|
+
if (agent.execution_claim_id || agent.execution_claim_state
|
|
1071
|
+
|| ['in_progress', 'awaiting_advance', 'waiting_for_user'].includes(agent.status)) {
|
|
1072
|
+
fail('PLANNING_CORRECTION_IN_FLIGHT', `Agent ${name} must settle its claim, result or interaction first.`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
if (session.pending_interaction) {
|
|
1076
|
+
fail('PLANNING_CORRECTION_IN_FLIGHT', 'A pending interaction must be settled before rollback.');
|
|
1077
|
+
}
|
|
1078
|
+
const cycle = session.correction_cycles?.[`qa-planning->${current}`];
|
|
1079
|
+
if (session.last_handoff !== 'qa-planning' || session.agents[current].status !== 'pending_rework'
|
|
1080
|
+
|| session.agents['qa-planning']?.status !== 'pending'
|
|
1081
|
+
|| !Number.isSafeInteger(cycle) || cycle < 1 || cycle > 2
|
|
1082
|
+
|| !session.decision_trail?.some(entry => entry.resolved === false)) {
|
|
1083
|
+
fail('PLANNING_CORRECTION_NOT_PENDING', 'Retargeting requires an unstarted, bounded negative QA correction with preserved findings.');
|
|
1084
|
+
}
|
|
1085
|
+
return { impact: 'high', affectedAgents, requiresConfirmation: true,
|
|
1086
|
+
recommendation: `Reopen ${targetAgent} and invalidate ${affectedAgents.length} Planning statuses; preserve artifacts, findings and the recorded correction cycle.` };
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/** Reuse the pipeline reset under the canonical writer lock; reserve nothing. */
|
|
1090
|
+
export function rollbackPlanningCorrection(projectDir, targetAgent) {
|
|
1091
|
+
let impact;
|
|
1092
|
+
let failureCode;
|
|
1093
|
+
const saved = mutateSession(projectDir, session => {
|
|
1094
|
+
try { impact = inspectPlanningCorrectionRollback(projectDir, session, targetAgent); }
|
|
1095
|
+
catch (error) { failureCode = error.code; throw error; }
|
|
1096
|
+
const previousAgents = Object.fromEntries(impact.affectedAgents.map(name => [name, { ...session.agents[name] }]));
|
|
1097
|
+
const reset = resetPipelineTo({
|
|
1098
|
+
agents: structuredClone(session.agents), completedAgents: [...session.completed_agents], history: [],
|
|
1099
|
+
}, targetAgent);
|
|
1100
|
+
for (const name of impact.affectedAgents) {
|
|
1101
|
+
session.agents[name] = { ...session.agents[name], status: name === targetAgent ? 'pending_rework' : reset.agents[name].status,
|
|
1102
|
+
score: null, criteria_count: 0, started_at: null, completed_at: null, startedAt: null, completedAt: null };
|
|
1103
|
+
}
|
|
1104
|
+
session.completed_agents = reset.completedAgents;
|
|
1105
|
+
session.current_agent = targetAgent;
|
|
1106
|
+
session.pipeline_position = reset.completedAgents.length;
|
|
1107
|
+
session.deviations = [...(session.deviations || []), {
|
|
1108
|
+
type: 'rollback', details: { targetAgent, source: 'pending_qa_correction' },
|
|
1109
|
+
previous_agents: previousAgents, applied_at: new Date().toISOString(),
|
|
1110
|
+
changes: impact.affectedAgents.map(name => `Reset agent: ${name}`),
|
|
1111
|
+
}];
|
|
1112
|
+
return session;
|
|
1113
|
+
});
|
|
1114
|
+
if (!saved.saved) return { ...saved, applied: false, code: failureCode || 'SESSION_WRITE_FAILED' };
|
|
1115
|
+
return { ...saved, applied: true, impact, changes: impact.affectedAgents.map(name => `Reset agent: ${name}`) };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1011
1118
|
/**
|
|
1012
1119
|
* Atomically transition conversation ownership and lifecycle state. Returning
|
|
1013
1120
|
* the prior values lets callers record timeline evidence without performing a
|
|
@@ -1202,7 +1309,7 @@ export function updateContextUsage(projectDir, {
|
|
|
1202
1309
|
};
|
|
1203
1310
|
}
|
|
1204
1311
|
|
|
1205
|
-
function mutateSession(projectDir, mutator) {
|
|
1312
|
+
function mutateSession(projectDir, mutator, { beforeCommit } = {}) {
|
|
1206
1313
|
let sessionPath;
|
|
1207
1314
|
try {
|
|
1208
1315
|
sessionPath = validateSessionPath(projectDir, { requireFile: true });
|
|
@@ -1222,10 +1329,11 @@ function mutateSession(projectDir, mutator) {
|
|
|
1222
1329
|
writeFileAtomic(sessionPath, yamlContent, 'utf-8', {
|
|
1223
1330
|
expectedDestinationIdentity: record.identity,
|
|
1224
1331
|
expectedDirectoryIdentity: record.directoryIdentity,
|
|
1332
|
+
beforeCommit,
|
|
1225
1333
|
});
|
|
1226
1334
|
outcome = { saved: true, session: nextSession };
|
|
1227
1335
|
} catch (err) {
|
|
1228
|
-
outcome = { saved: false, error: `Failed to update session: ${err.message}
|
|
1336
|
+
outcome = { saved: false, error: `Failed to update session: ${err.message}`, ...(err.code ? { code: err.code } : {}) };
|
|
1229
1337
|
} finally {
|
|
1230
1338
|
if (releaseLock) {
|
|
1231
1339
|
try {
|
|
@@ -1296,7 +1404,39 @@ export function recordModelSelection(projectDir, selection) {
|
|
|
1296
1404
|
});
|
|
1297
1405
|
}
|
|
1298
1406
|
|
|
1299
|
-
|
|
1407
|
+
export function validateInitialTaskContext(context) {
|
|
1408
|
+
if (context !== undefined && context !== null && (typeof context !== 'string'
|
|
1409
|
+
|| Buffer.byteLength(context, 'utf8') > INITIAL_TASK_CONTEXT_MAX_BYTES)) {
|
|
1410
|
+
throw Object.assign(new Error('Initial task context must be text of at most 256 KiB; no content was truncated.'),
|
|
1411
|
+
{ code: 'INITIAL_TASK_CONTEXT_INVALID' });
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function validateRelayHistory(history, question = '', initialTaskContext = undefined) {
|
|
1416
|
+
validateInitialTaskContext(initialTaskContext);
|
|
1417
|
+
if (!Array.isArray(history) || history.some((entry) => !entry
|
|
1418
|
+
|| typeof entry.interaction_id !== 'string'
|
|
1419
|
+
|| !['user_answer', 'controller_instruction'].includes(entry.kind)
|
|
1420
|
+
|| typeof entry.response !== 'string'
|
|
1421
|
+
|| (entry.kind === 'user_answer' && typeof entry.question !== 'string'))) {
|
|
1422
|
+
throw new Error('Invalid retained interactive history');
|
|
1423
|
+
}
|
|
1424
|
+
if (history.length > RELAY_HISTORY_MAX_TURNS
|
|
1425
|
+
|| Buffer.byteLength(JSON.stringify({ history, question, initialTaskContext })) > RELAY_HISTORY_MAX_BYTES) {
|
|
1426
|
+
throw new Error('Interactive history exceeds its safety limit; no content was truncated');
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/** Public diagnostics must not disclose retained answers or in-flight input. */
|
|
1431
|
+
export function summarizePendingInteraction(pending) {
|
|
1432
|
+
if (!pending) return null;
|
|
1433
|
+
const metadata = { ...pending };
|
|
1434
|
+
for (const key of ['history', 'relay_response', 'relay_response_kind', 'relay_next_task_id', 'initial_task_context']) delete metadata[key];
|
|
1435
|
+
return { ...metadata, history_turn_count: Array.isArray(pending.history) ? pending.history.length : 0,
|
|
1436
|
+
history_complete: pending.history_complete === true };
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/** Persist an interactive question and atomically commit only its owned relay. */
|
|
1300
1440
|
export function recordPendingInteraction(projectDir, interaction) {
|
|
1301
1441
|
const sessionProjectDir = findSessionProjectDir(projectDir);
|
|
1302
1442
|
if (!sessionProjectDir) return { saved: false, error: 'Session file not found in working directory or its parents' };
|
|
@@ -1314,7 +1454,31 @@ export function recordPendingInteraction(projectDir, interaction) {
|
|
|
1314
1454
|
catalog_snapshot_ref: interaction.catalogSnapshotRef || null,
|
|
1315
1455
|
};
|
|
1316
1456
|
const sameRelay = previous?.agent === interaction.agent && previous?.task_id === taskId;
|
|
1317
|
-
const relayAdvanced =
|
|
1457
|
+
const relayAdvanced = interaction.advanceRelay === true;
|
|
1458
|
+
if (relayAdvanced && (!previous || previous.status !== 'relay_in_flight'
|
|
1459
|
+
|| previous.agent !== interaction.agent
|
|
1460
|
+
|| previous.interaction_id !== interaction.interactionId
|
|
1461
|
+
|| !interaction.claimId || previous.relay_claim_id !== interaction.claimId
|
|
1462
|
+
|| previous.relay_next_task_id !== taskId
|
|
1463
|
+
|| typeof previous.relay_response !== 'string')) {
|
|
1464
|
+
throw new Error('Pending interaction commit rejected: relay ownership mismatch');
|
|
1465
|
+
}
|
|
1466
|
+
if (!relayAdvanced && previous && (!sameRelay || previous.status !== 'waiting_for_user')) {
|
|
1467
|
+
throw new Error('Pending interaction cannot replace another active interaction');
|
|
1468
|
+
}
|
|
1469
|
+
const expectedBinding = previous?.escalation_execution_binding || previous?.execution_binding;
|
|
1470
|
+
if (previous && Object.entries(binding).some(([key, value]) => value !== (expectedBinding?.[key] || null))) {
|
|
1471
|
+
throw new Error('Pending interaction commit rejected: execution binding mismatch');
|
|
1472
|
+
}
|
|
1473
|
+
const history = previous?.history || [];
|
|
1474
|
+
const nextHistory = relayAdvanced ? [...history, {
|
|
1475
|
+
interaction_id: previous.interaction_id,
|
|
1476
|
+
kind: previous.relay_response_kind,
|
|
1477
|
+
...(previous.relay_response_kind === 'user_answer' ? { question: previous.question } : {}),
|
|
1478
|
+
response: previous.relay_response,
|
|
1479
|
+
}] : history;
|
|
1480
|
+
const initialTaskContext = previous ? previous.initial_task_context : interaction.initialTaskContext;
|
|
1481
|
+
validateRelayHistory(nextHistory, interaction.question, initialTaskContext);
|
|
1318
1482
|
const interactionId = (!sameRelay || relayAdvanced || !previous?.interaction_id)
|
|
1319
1483
|
? randomUUID()
|
|
1320
1484
|
: previous.interaction_id;
|
|
@@ -1323,6 +1487,23 @@ export function recordPendingInteraction(projectDir, interaction) {
|
|
|
1323
1487
|
session.current_execution_binding = binding;
|
|
1324
1488
|
session.agents = session.agents || {};
|
|
1325
1489
|
const existingAgent = session.agents[interaction.agent] || {};
|
|
1490
|
+
let qaBaseline;
|
|
1491
|
+
let revisionBaseline;
|
|
1492
|
+
if (hasPlanningQaRecovery(existingAgent, interaction.agent)) {
|
|
1493
|
+
qaBaseline = assertPlanningQaReportBaseline(existingAgent, { binding, taskId,
|
|
1494
|
+
...(previous ? { pending: previous } : { claimId: existingAgent.execution_claim_id }) });
|
|
1495
|
+
if (!previous && existingAgent.execution_claim_state !== 'running') {
|
|
1496
|
+
throw Object.assign(new Error('Only a started QA may carry its report baseline into an interaction.'), { code: 'COUNCIL_QA_REPORT_BASELINE_INVALID' });
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
if (existingAgent.planning_contract_revision_id) {
|
|
1500
|
+
revisionBaseline = assertPlanningContractRevisionExecutionBaseline(existingAgent, { binding, taskId,
|
|
1501
|
+
...(previous ? { pending: previous } : { claimId: existingAgent.execution_claim_id }) });
|
|
1502
|
+
if (!previous && existingAgent.execution_claim_state !== 'running') {
|
|
1503
|
+
throw Object.assign(new Error('Only a started Planning revision owner may carry its attempt baseline into an interaction.'),
|
|
1504
|
+
{ code: 'PLANNING_CONTRACT_REVISION_EXECUTION_BASELINE_INVALID' });
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1326
1507
|
session.agents[interaction.agent] = {
|
|
1327
1508
|
...existingAgent,
|
|
1328
1509
|
status: 'waiting_for_user',
|
|
@@ -1332,6 +1513,7 @@ export function recordPendingInteraction(projectDir, interaction) {
|
|
|
1332
1513
|
delete session.agents[interaction.agent].execution_claim_state;
|
|
1333
1514
|
delete session.agents[interaction.agent].execution_claim_started_at;
|
|
1334
1515
|
delete session.agents[interaction.agent].execution_claim_expires_at;
|
|
1516
|
+
delete session.agents[interaction.agent].execution_resume_status;
|
|
1335
1517
|
session.pending_interaction = {
|
|
1336
1518
|
interaction_id: interactionId,
|
|
1337
1519
|
status: 'waiting_for_user',
|
|
@@ -1339,15 +1521,102 @@ export function recordPendingInteraction(projectDir, interaction) {
|
|
|
1339
1521
|
task_id: taskId,
|
|
1340
1522
|
language: session.language || 'en',
|
|
1341
1523
|
question: interaction.question,
|
|
1342
|
-
|
|
1524
|
+
history: nextHistory,
|
|
1525
|
+
...(initialTaskContext ? { initial_task_context: initialTaskContext } : {}),
|
|
1526
|
+
history_complete: previous ? previous.history_complete === true : true,
|
|
1527
|
+
relay_cycles: relayAdvanced && sameRelay ? (previous.relay_cycles || 1) + 1 : (sameRelay ? (previous.relay_cycles || 1) : 1),
|
|
1343
1528
|
execution_binding: binding,
|
|
1344
|
-
|
|
1529
|
+
...(qaBaseline ? { qa_report_baseline: cloneSessionValue(qaBaseline) } : {}),
|
|
1530
|
+
...(revisionBaseline ? { planning_revision_baseline: cloneSessionValue(revisionBaseline) } : {}),
|
|
1531
|
+
created_at: previous ? previous.created_at : now,
|
|
1345
1532
|
updated_at: now,
|
|
1346
1533
|
};
|
|
1347
1534
|
return session;
|
|
1348
1535
|
});
|
|
1349
1536
|
}
|
|
1350
1537
|
|
|
1538
|
+
// Older releases lost pending_rework when a reserved/running command failed.
|
|
1539
|
+
// Recover only the recorded, still-open rollback. A negative QA handoff is
|
|
1540
|
+
// context, never proof that QA completed or that downstream work may start.
|
|
1541
|
+
export function getPendingPlanningRollbackTarget(session) {
|
|
1542
|
+
if (session.mode !== 'plan' || session.last_handoff !== 'qa-planning'
|
|
1543
|
+
|| session.completed_agents?.includes('qa-planning')) return null;
|
|
1544
|
+
const rollback = session.deviations?.findLast(entry => entry.type === 'rollback');
|
|
1545
|
+
if (rollback?.details?.source !== 'pending_qa_correction') return null;
|
|
1546
|
+
const target = rollback.details.targetAgent;
|
|
1547
|
+
if (session.completed_agents?.includes(target) && session.agents?.[target]?.status === 'completed') return null;
|
|
1548
|
+
const invalid = () => { throw Object.assign(new Error('Pending Planning rollback is inconsistent; no execution was reserved.'),
|
|
1549
|
+
{ code: 'PLANNING_CORRECTION_STATE_INVALID' }); };
|
|
1550
|
+
if (!['detail', 'architect', 'ux'].includes(target)
|
|
1551
|
+
|| session.completed_agents?.includes(target)
|
|
1552
|
+
|| !['pending', 'pending_rework', 'in_progress', 'awaiting_advance'].includes(session.agents?.[target]?.status)
|
|
1553
|
+
|| session.agents?.['qa-planning']?.status !== 'pending'
|
|
1554
|
+
|| !session.decision_trail?.some(entry => entry.resolved === false)) invalid();
|
|
1555
|
+
const source = Object.entries(rollback.previous_agents || {}).find(([name, state]) =>
|
|
1556
|
+
['detail', 'architect', 'ux'].includes(name) && state.status === 'pending_rework')?.[0];
|
|
1557
|
+
const cycle = session.correction_cycles?.[`qa-planning->${source}`];
|
|
1558
|
+
if (!source || !Number.isSafeInteger(cycle) || cycle < 1 || cycle > 2) invalid();
|
|
1559
|
+
const affected = AGENT_PIPELINE.slice(AGENT_PIPELINE.findIndex(entry => entry.name === target))
|
|
1560
|
+
.filter(entry => entry.phase === 'plan' && entry.name !== target);
|
|
1561
|
+
if (session.pending_interaction || affected.some(({ name }) => {
|
|
1562
|
+
const state = session.agents?.[name];
|
|
1563
|
+
return state?.execution_claim_id || state?.execution_claim_state
|
|
1564
|
+
|| ['in_progress', 'awaiting_advance', 'waiting_for_user'].includes(state?.status);
|
|
1565
|
+
})) {
|
|
1566
|
+
throw Object.assign(new Error('Pending Planning rollback has conflicting execution or interaction ownership.'),
|
|
1567
|
+
{ code: 'PLANNING_CORRECTION_IN_FLIGHT' });
|
|
1568
|
+
}
|
|
1569
|
+
return target;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/** Revision owners consume each other's pinned output, so only one may run at a time. */
|
|
1573
|
+
export function getPlanningContractRevisionOwner(session, projectDir) {
|
|
1574
|
+
const lineage = projectDir ? authenticatePlanningContractRevisionHistory(projectDir, session)
|
|
1575
|
+
: planningContractRevisionHistory(session);
|
|
1576
|
+
const { history } = lineage;
|
|
1577
|
+
if (lineage.pending) throw Object.assign(new Error('The exact reserved Planning direction must settle before any owner may execute.'),
|
|
1578
|
+
{ code: 'PLANNING_CONTRACT_REVISION_PENDING' });
|
|
1579
|
+
const active = history.filter(entry => entry.status === 'active');
|
|
1580
|
+
const hasOwner = Object.values(session.agents || {}).some(agent => agent.planning_contract_revision_id);
|
|
1581
|
+
if (!active.length && !hasOwner) return null;
|
|
1582
|
+
const fail = () => { throw Object.assign(new Error('Planning contract revision requires one sequential owner with all prior completions recorded.'),
|
|
1583
|
+
{ code: 'PLANNING_CONTRACT_REVISION_ORDER_INVALID' }); };
|
|
1584
|
+
const revision = active[0];
|
|
1585
|
+
if (active.length !== 1 || lineage.tip !== revision || !revision.request?.revision_id
|
|
1586
|
+
|| revision.request.language !== session.language || !revision.owner_results
|
|
1587
|
+
|| Array.isArray(revision.owner_results)
|
|
1588
|
+
|| Object.keys(revision.owner_results).some(owner => !PLANNING_CONTRACT_REVISION_OWNERS.includes(owner))) fail();
|
|
1589
|
+
let next = null;
|
|
1590
|
+
for (const owner of PLANNING_CONTRACT_REVISION_OWNERS) {
|
|
1591
|
+
const result = revision.owner_results[owner], agent = session.agents?.[owner];
|
|
1592
|
+
if (result) {
|
|
1593
|
+
if (next || agent?.status !== 'completed' || !session.completed_agents?.includes(owner)
|
|
1594
|
+
|| !result.execution_claim_id || !result.execution_finished_at || !result.subject?.artifacts?.length) fail();
|
|
1595
|
+
} else {
|
|
1596
|
+
next ||= owner;
|
|
1597
|
+
if (!agent || agent.planning_contract_revision_id !== revision.request.revision_id
|
|
1598
|
+
|| !agent.content_review_pending || agent.status === 'completed' || session.completed_agents?.includes(owner)) fail();
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
if (!next || (session.pending_interaction && session.pending_interaction.agent !== next)) fail();
|
|
1602
|
+
for (const owner of PLANNING_CONTRACT_REVISION_OWNERS) {
|
|
1603
|
+
if (owner === next) continue;
|
|
1604
|
+
const agent = session.agents[owner];
|
|
1605
|
+
if (agent.execution_claim_id || agent.execution_claim_state
|
|
1606
|
+
|| ['in_progress', 'awaiting_advance', 'waiting_for_user'].includes(agent.status)) fail();
|
|
1607
|
+
}
|
|
1608
|
+
return next;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function assertPlanningContractRevisionOwner(session, agents, projectDir) {
|
|
1612
|
+
const owner = getPlanningContractRevisionOwner(session, projectDir);
|
|
1613
|
+
if (owner && (agents.length !== 1 || agents[0] !== owner)) {
|
|
1614
|
+
throw Object.assign(new Error(`Planning contract revision must execute ${owner} alone before later owners.`),
|
|
1615
|
+
{ code: 'PLANNING_CONTRACT_REVISION_ORDER_INVALID' });
|
|
1616
|
+
}
|
|
1617
|
+
return owner;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1351
1620
|
/** Atomically reserve one or more agent executions before returning commands. */
|
|
1352
1621
|
export function claimAgentExecutions(projectDir, executions = [], claim = {}) {
|
|
1353
1622
|
const sessionProjectDir = findSessionProjectDir(projectDir);
|
|
@@ -1360,6 +1629,22 @@ export function claimAgentExecutions(projectDir, executions = [], claim = {}) {
|
|
|
1360
1629
|
const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
|
|
1361
1630
|
const result = mutateSession(sessionProjectDir, (session) => {
|
|
1362
1631
|
session.agents = session.agents || {};
|
|
1632
|
+
// Recheck the recovery contract under the same writer lock as reservation.
|
|
1633
|
+
// A sibling may have claimed work after the router's read-only selection.
|
|
1634
|
+
assertPlanningContractRevisionOwner(session, requested.map(({ agent }) => agent), sessionProjectDir);
|
|
1635
|
+
try {
|
|
1636
|
+
const rollbackTarget = getPendingPlanningRollbackTarget(session);
|
|
1637
|
+
if (rollbackTarget && (requested.length !== 1 || requested[0].agent !== rollbackTarget)) {
|
|
1638
|
+
throw Object.assign(new Error('Pending Planning rollback must resume its recorded target first.'),
|
|
1639
|
+
{ code: 'PLANNING_CORRECTION_STATE_INVALID' });
|
|
1640
|
+
}
|
|
1641
|
+
if (session.runtime_version === 2 || existsSync(join(sessionProjectDir, '.chati', 'v2'))) {
|
|
1642
|
+
for (const { agent } of requested) assertV2PlanningPredecessors(session, agent);
|
|
1643
|
+
}
|
|
1644
|
+
} catch (error) {
|
|
1645
|
+
outcome = { claimed: false, code: error.code, error: error.message };
|
|
1646
|
+
return session;
|
|
1647
|
+
}
|
|
1363
1648
|
for (const entry of requested) {
|
|
1364
1649
|
const current = session.agents[entry.agent] || {};
|
|
1365
1650
|
if (current.status === 'completed' || session.completed_agents?.includes(entry.agent)) {
|
|
@@ -1407,15 +1692,27 @@ export function claimAgentExecutions(projectDir, executions = [], claim = {}) {
|
|
|
1407
1692
|
outcome.previous = snapshotAgentDispatch(session, requested.map(({ agent }) => agent));
|
|
1408
1693
|
for (const entry of requested) {
|
|
1409
1694
|
const claimId = randomUUID();
|
|
1695
|
+
const previous = session.agents[entry.agent] || {};
|
|
1696
|
+
const resumeStatus = previous.status === 'pending_rework'
|
|
1697
|
+
|| (previous.status === 'in_progress' && previous.execution_resume_status === 'pending_rework')
|
|
1698
|
+
? 'pending_rework' : 'pending';
|
|
1410
1699
|
session.agents[entry.agent] = {
|
|
1411
|
-
...
|
|
1700
|
+
...previous,
|
|
1412
1701
|
status: 'in_progress',
|
|
1702
|
+
execution_resume_status: resumeStatus,
|
|
1413
1703
|
execution_claim_id: claimId,
|
|
1414
1704
|
execution_claim_state: 'reserved',
|
|
1415
1705
|
execution_claim_started_at: now.toISOString(),
|
|
1416
1706
|
execution_claim_expires_at: expiresAt,
|
|
1707
|
+
...(entry.taskId ? { execution_task_id: entry.taskId } : {}),
|
|
1708
|
+
...(entry.binding ? { execution_binding: cloneSessionValue(entry.binding) } : {}),
|
|
1417
1709
|
};
|
|
1710
|
+
if (session.agents[entry.agent].content_review_pending && !session.agents[entry.agent].content_review_task_id) {
|
|
1711
|
+
session.agents[entry.agent].content_review_task_id = entry.taskId;
|
|
1712
|
+
}
|
|
1418
1713
|
outcome.claims[entry.agent] = claimId;
|
|
1714
|
+
delete session.agents[entry.agent].execution_result_status;
|
|
1715
|
+
delete session.agents[entry.agent].execution_qa_result;
|
|
1419
1716
|
}
|
|
1420
1717
|
session.current_agent = requested[0].agent;
|
|
1421
1718
|
if (requested[0].binding) session.current_execution_binding = requested[0].binding;
|
|
@@ -1430,9 +1727,14 @@ export function startAgentExecution(projectDir, match = {}) {
|
|
|
1430
1727
|
const sessionProjectDir = findSessionProjectDir(projectDir);
|
|
1431
1728
|
if (!sessionProjectDir) return { saved: false, started: false, error: 'Session file not found in working directory or its parents' };
|
|
1432
1729
|
let outcome = { started: false };
|
|
1730
|
+
let subject;
|
|
1731
|
+
let reportPin;
|
|
1732
|
+
let revisionBaselinePin;
|
|
1733
|
+
try {
|
|
1433
1734
|
const result = mutateSession(sessionProjectDir, (session) => {
|
|
1434
1735
|
const agent = session.agents?.[match.agent];
|
|
1435
|
-
const
|
|
1736
|
+
const expiry = Date.parse(agent?.execution_claim_expires_at || '');
|
|
1737
|
+
const expired = !Number.isFinite(expiry) || expiry <= Date.now();
|
|
1436
1738
|
if (!agent || agent.execution_claim_id !== match.claimId || agent.execution_claim_state !== 'reserved' || expired) {
|
|
1437
1739
|
outcome = {
|
|
1438
1740
|
started: false,
|
|
@@ -1441,36 +1743,983 @@ export function startAgentExecution(projectDir, match = {}) {
|
|
|
1441
1743
|
};
|
|
1442
1744
|
return session;
|
|
1443
1745
|
}
|
|
1746
|
+
let contentReviewContext;
|
|
1747
|
+
assertPlanningContractRevisionOwner(session, [match.agent], sessionProjectDir);
|
|
1748
|
+
try {
|
|
1749
|
+
if (session.runtime_version === 2 || existsSync(join(sessionProjectDir, '.chati', 'v2'))) {
|
|
1750
|
+
assertV2PlanningPredecessors(session, match.agent);
|
|
1751
|
+
}
|
|
1752
|
+
contentReviewContext = claimContentReviewContext(sessionProjectDir, session, match);
|
|
1753
|
+
if (agent.planning_contract_revision_id) {
|
|
1754
|
+
const revision = session.planning_contract_revisions?.find(item => item.request?.revision_id === agent.planning_contract_revision_id);
|
|
1755
|
+
if (!revision) throw Object.assign(new Error('The claimed Planning revision no longer exists.'),
|
|
1756
|
+
{ code: 'PLANNING_CONTRACT_REVISION_CONTEXT_INVALID' });
|
|
1757
|
+
revisionBaselinePin = pinPlanningContractRevisionOwnerBaseline(sessionProjectDir, revision, match.agent);
|
|
1758
|
+
agent.execution_planning_revision_baseline = { origin_claim_id: match.claimId, task_id: match.taskId,
|
|
1759
|
+
binding: cloneSessionValue(match.binding),
|
|
1760
|
+
owned_artifacts: cloneSessionValue(revisionBaselinePin.owned_artifacts),
|
|
1761
|
+
subject_artifacts: cloneSessionValue(revisionBaselinePin.subject_artifacts),
|
|
1762
|
+
handoff: cloneSessionValue(revisionBaselinePin.handoff) };
|
|
1763
|
+
contentReviewContext.planning_contract_revision.execution_subject = cloneSessionValue(revisionBaselinePin.subject_artifacts);
|
|
1764
|
+
}
|
|
1765
|
+
if (hasPlanningQaRecovery(agent, match.agent)) {
|
|
1766
|
+
subject = pinClaimedCouncilQa(sessionProjectDir, session, match.agent, { excludeQaReport: true });
|
|
1767
|
+
reportPin = pinPlanningQaReport(sessionProjectDir);
|
|
1768
|
+
agent.execution_qa_report_baseline = { origin_claim_id: match.claimId, task_id: match.taskId,
|
|
1769
|
+
binding: cloneSessionValue(match.binding), sha256: reportPin.sha256 };
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
catch (error) { outcome = { started: false, code: error.code, error: error.message }; return session; }
|
|
1444
1773
|
agent.execution_claim_state = 'running';
|
|
1445
1774
|
agent.execution_started_at = match.timestamp || new Date().toISOString();
|
|
1446
|
-
outcome = { started: true };
|
|
1775
|
+
outcome = { started: true, expiresAt: agent.execution_claim_expires_at, contentReviewContext };
|
|
1447
1776
|
return session;
|
|
1448
|
-
});
|
|
1449
|
-
return { ...result, ...outcome };
|
|
1777
|
+
}, { beforeCommit: () => { subject?.assertCurrent(); reportPin?.assertCurrent(); revisionBaselinePin?.assertCurrent(); } });
|
|
1778
|
+
return { ...result, ...outcome, started: result.saved && outcome.started };
|
|
1779
|
+
} finally { subject?.close(); reportPin?.close(); revisionBaselinePin?.close(); }
|
|
1450
1780
|
}
|
|
1451
1781
|
|
|
1452
1782
|
/** Keep a successful routed result durable until the canonical advance records completion. */
|
|
1453
1783
|
export function holdAgentExecutionForAdvance(projectDir, match = {}) {
|
|
1784
|
+
if (match.resultStatus !== 'complete') {
|
|
1785
|
+
return { saved: false, held: false, code: 'AGENT_RESULT_NOT_COMPLETE', error: 'Only an explicit complete result can await advance.' };
|
|
1786
|
+
}
|
|
1787
|
+
if (match.qaResult !== undefined && (match.agent !== 'qa-planning'
|
|
1788
|
+
|| !isPlanningQaOutcomeValid(match.qaResult, getQaThreshold(match.agent, projectDir)))) {
|
|
1789
|
+
return { saved: false, held: false, code: 'QA_RESULT_INVALID', error: 'Invalid normalized planning QA result.' };
|
|
1790
|
+
}
|
|
1454
1791
|
const sessionProjectDir = findSessionProjectDir(projectDir);
|
|
1455
1792
|
if (!sessionProjectDir) return { saved: false, held: false, error: 'Session file not found in working directory or its parents' };
|
|
1456
1793
|
let outcome = { held: false };
|
|
1794
|
+
let failureCode;
|
|
1795
|
+
let subject;
|
|
1796
|
+
let revisionOutput;
|
|
1797
|
+
let revisionAttemptBaseline;
|
|
1798
|
+
try {
|
|
1457
1799
|
const result = mutateSession(sessionProjectDir, (session) => {
|
|
1458
1800
|
const agent = session.agents?.[match.agent];
|
|
1459
1801
|
if (!agent) {
|
|
1460
1802
|
outcome = { held: false, code: 'AGENT_EXECUTION_NOT_FOUND', error: `Agent ${match.agent || 'unknown'} is not present in the canonical session.` };
|
|
1461
1803
|
return session;
|
|
1462
1804
|
}
|
|
1463
|
-
|
|
1805
|
+
assertPlanningContractRevisionOwner(session, [match.agent], sessionProjectDir);
|
|
1806
|
+
if (match.interactionId) {
|
|
1807
|
+
const pending = session.pending_interaction;
|
|
1808
|
+
const binding = pending?.escalation_execution_binding || pending?.execution_binding;
|
|
1809
|
+
if (!pending || pending.status !== 'relay_in_flight' || !match.claimId
|
|
1810
|
+
|| pending.relay_claim_id !== match.claimId || pending.interaction_id !== match.interactionId
|
|
1811
|
+
|| pending.agent !== match.agent || pending.task_id !== match.taskId
|
|
1812
|
+
|| !sameContentReviewBinding(binding, match.binding, false)) {
|
|
1813
|
+
failureCode = 'PENDING_INTERACTION_BINDING_MISMATCH';
|
|
1814
|
+
throw new Error('Relay completion requires the exact claimed interaction, task and binding.');
|
|
1815
|
+
}
|
|
1816
|
+
if (hasPlanningQaRecovery(agent, match.agent)) assertPlanningQaReportBaseline(agent, { pending, binding, taskId: match.taskId });
|
|
1817
|
+
if (agent.planning_contract_revision_id) {
|
|
1818
|
+
revisionAttemptBaseline = assertPlanningContractRevisionExecutionBaseline(
|
|
1819
|
+
agent, { pending, binding, taskId: match.taskId });
|
|
1820
|
+
agent.execution_planning_revision_relay_claim_id = pending.relay_claim_id;
|
|
1821
|
+
}
|
|
1822
|
+
// Consume the real relay identity and install its complete hold in ONE
|
|
1823
|
+
// atomic session commit. Never clear the answer first or mint a new ID.
|
|
1824
|
+
agent.execution_claim_id = pending.relay_claim_id;
|
|
1825
|
+
agent.execution_claim_started_at = pending.relay_started_at;
|
|
1826
|
+
agent.execution_task_id = pending.task_id;
|
|
1827
|
+
agent.execution_binding = cloneSessionValue(binding);
|
|
1828
|
+
session.pending_interaction = null;
|
|
1829
|
+
} else if (session.pending_interaction?.agent === match.agent
|
|
1830
|
+
|| (session.pending_interaction && !match.claimId)) {
|
|
1831
|
+
failureCode = 'PENDING_INTERACTION_BINDING_MISMATCH';
|
|
1832
|
+
throw new Error('A fresh completion cannot consume its own interaction or proceed unclaimed beside a pending peer.');
|
|
1833
|
+
} else if (match.claimId && agent.execution_claim_id !== match.claimId) {
|
|
1464
1834
|
outcome = { held: false, code: 'AGENT_EXECUTION_CLAIM_INVALID', error: `Execution claim for ${match.agent} does not own this result.` };
|
|
1465
1835
|
return session;
|
|
1466
1836
|
}
|
|
1837
|
+
if (agent.planning_contract_revision_id && !match.interactionId) {
|
|
1838
|
+
if (agent.execution_claim_state !== 'running') {
|
|
1839
|
+
throw Object.assign(new Error('Planning revision completion requires a genuinely started claim.'),
|
|
1840
|
+
{ code: 'PLANNING_CONTRACT_REVISION_EXECUTION_REQUIRED' });
|
|
1841
|
+
}
|
|
1842
|
+
revisionAttemptBaseline = assertPlanningContractRevisionExecutionBaseline(
|
|
1843
|
+
agent, { claimId: match.claimId, binding: agent.execution_binding, taskId: agent.execution_task_id });
|
|
1844
|
+
delete agent.execution_planning_revision_relay_claim_id;
|
|
1845
|
+
}
|
|
1846
|
+
if (hasPlanningQaRecovery(agent, match.agent)) {
|
|
1847
|
+
if (!match.claimId || (!match.interactionId && agent.execution_claim_state !== 'running')) {
|
|
1848
|
+
throw Object.assign(new Error('Council QA must complete its genuinely started fresh claim.'), { code: 'COUNCIL_QA_EXECUTION_REQUIRED' });
|
|
1849
|
+
}
|
|
1850
|
+
subject = pinClaimedCouncilQa(sessionProjectDir, session, match.agent);
|
|
1851
|
+
const baseline = match.interactionId ? agent.execution_qa_report_baseline
|
|
1852
|
+
: assertPlanningQaReportBaseline(agent, { claimId: match.claimId, binding: agent.execution_binding, taskId: agent.execution_task_id });
|
|
1853
|
+
const report = subject.plan.artifacts.find(item => item.path === 'artifacts/7-QA-Planning/qa-planning-report.md');
|
|
1854
|
+
if (!report || report.sha256 === baseline.sha256) {
|
|
1855
|
+
throw Object.assign(new Error('This QA execution must produce a report different from its own pre-spawn baseline.'), { code: 'COUNCIL_QA_REPORT_NOT_REGENERATED' });
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
if (agent.planning_contract_revision_id) {
|
|
1859
|
+
const revision = session.planning_contract_revisions?.find(
|
|
1860
|
+
item => item.request?.revision_id === agent.planning_contract_revision_id);
|
|
1861
|
+
if (!revision || revision.status !== 'active') {
|
|
1862
|
+
throw Object.assign(new Error('Planning revision output requires the single active direction.'),
|
|
1863
|
+
{ code: 'PLANNING_CONTRACT_REVISION_CONTEXT_INVALID' });
|
|
1864
|
+
}
|
|
1865
|
+
revisionOutput = pinPlanningContractRevisionOwnerOutput(sessionProjectDir, revision, match.agent,
|
|
1866
|
+
revisionAttemptBaseline);
|
|
1867
|
+
}
|
|
1467
1868
|
agent.status = 'awaiting_advance';
|
|
1468
1869
|
agent.execution_claim_state = 'awaiting_advance';
|
|
1469
1870
|
agent.execution_finished_at = match.timestamp || new Date().toISOString();
|
|
1871
|
+
agent.execution_result_status = match.resultStatus;
|
|
1872
|
+
delete agent.execution_resume_status;
|
|
1873
|
+
if (match.qaResult !== undefined) agent.execution_qa_result = { ...match.qaResult };
|
|
1874
|
+
if (revisionOutput) {
|
|
1875
|
+
agent.execution_planning_revision_result = {
|
|
1876
|
+
execution_claim_id: agent.execution_claim_id,
|
|
1877
|
+
execution_finished_at: agent.execution_finished_at,
|
|
1878
|
+
subject: { gate_id: revisionOutput.plan.gate_id,
|
|
1879
|
+
artifacts: cloneSessionValue(revisionOutput.plan.artifacts) },
|
|
1880
|
+
owned_artifacts: cloneSessionValue(revisionOutput.owned_artifacts),
|
|
1881
|
+
handoff: cloneSessionValue(revisionOutput.handoff),
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
if (subject) {
|
|
1885
|
+
const active = activePlanningQaHistory(session, match.agent);
|
|
1886
|
+
active.entry.result = {
|
|
1887
|
+
execution_claim_id: agent.execution_claim_id, execution_finished_at: agent.execution_finished_at,
|
|
1888
|
+
artifacts: cloneSessionValue(subject.plan.artifacts),
|
|
1889
|
+
...(match.qaResult ? { qa_result: cloneSessionValue(match.qaResult) } : {}),
|
|
1890
|
+
};
|
|
1891
|
+
if (active.kind === 'planning_contract_revision') {
|
|
1892
|
+
const previousSubjects = session.planning_contract_revision_subjects || [];
|
|
1893
|
+
if (!Array.isArray(previousSubjects)
|
|
1894
|
+
|| previousSubjects.some(item => item.revision_id === active.entry.request.revision_id)) {
|
|
1895
|
+
throw Object.assign(new Error('The Planning revision already recorded its one genuine QA subject.'),
|
|
1896
|
+
{ code: 'PLANNING_CONTRACT_REVISION_SUBJECT_LIMIT' });
|
|
1897
|
+
}
|
|
1898
|
+
session.planning_contract_revision_subjects = [...previousSubjects, {
|
|
1899
|
+
revision_id: active.entry.request.revision_id, gate_id: subject.plan.gate_id,
|
|
1900
|
+
artifacts: cloneSessionValue(subject.plan.artifacts), execution_claim_id: agent.execution_claim_id,
|
|
1901
|
+
execution_finished_at: agent.execution_finished_at, recorded_at: new Date().toISOString() }];
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1470
1904
|
delete agent.execution_claim_expires_at;
|
|
1471
1905
|
delete agent.execution_started_at;
|
|
1906
|
+
delete agent.execution_qa_report_baseline;
|
|
1472
1907
|
outcome = { held: true };
|
|
1473
1908
|
return session;
|
|
1909
|
+
}, { beforeCommit: () => { subject?.assertCurrent(); revisionOutput?.assertCurrent(); match.verifySubject?.(); } });
|
|
1910
|
+
return { ...result, ...outcome, held: result.saved && outcome.held,
|
|
1911
|
+
...(!result.saved ? { code: failureCode || result.code || 'SESSION_WRITE_FAILED' } : {}) };
|
|
1912
|
+
} finally { subject?.close(); revisionOutput?.close(); }
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
const CONTENT_REVIEW_AGENTS = new Set(['detail', 'architect', 'ux', 'qa-planning']);
|
|
1916
|
+
const CONTENT_REVIEW_BINDING_KEYS = ['provider', 'provider_id', 'model', 'reasoning_configuration', 'catalog_snapshot_ref'];
|
|
1917
|
+
const CONTENT_REVIEW_REASON_MAX_BYTES = 8192;
|
|
1918
|
+
// Separate authority and history from content rejection, but retain a finite
|
|
1919
|
+
// total recovery budget. New gate ids or cosmetic edits cannot reset it.
|
|
1920
|
+
export const MAX_COUNCIL_QA_REVALIDATIONS = 1;
|
|
1921
|
+
|
|
1922
|
+
function sameContentReviewBinding(left, right, requireCatalog = true) {
|
|
1923
|
+
return Boolean(left && right && left.provider && left.model && (!requireCatalog || left.catalog_snapshot_ref)
|
|
1924
|
+
&& CONTENT_REVIEW_BINDING_KEYS.every(key => (left[key] || null) === (right[key] || null)));
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
function qaInvocationBinding(artifact) {
|
|
1928
|
+
const invocation = resolveRuntimeInvocationV2({ artifact, agent: 'qa-planning' });
|
|
1929
|
+
return { provider: invocation.harness_id, provider_id: invocation.provider_id,
|
|
1930
|
+
model: invocation.model_pin.model_id, reasoning_configuration: invocation.model_pin.reasoning_configuration,
|
|
1931
|
+
catalog_snapshot_ref: invocation.model_pin.catalog_snapshot_ref };
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
function hasPlanningQaRecovery(agent, agentName = 'qa-planning') {
|
|
1935
|
+
return agentName === 'qa-planning' && Boolean(agent?.council_revalidation_pending || agent?.planning_reconciliation_pending
|
|
1936
|
+
|| agent?.planning_contract_revision_id);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
function assertPlanningQaReportBaseline(agent, { claimId, pending, binding, taskId }) {
|
|
1940
|
+
const baseline = agent.execution_qa_report_baseline;
|
|
1941
|
+
if (!baseline || typeof baseline.origin_claim_id !== 'string' || !baseline.origin_claim_id
|
|
1942
|
+
|| !(baseline.sha256 === null || /^[a-f0-9]{64}$/.test(baseline.sha256 || ''))
|
|
1943
|
+
|| baseline.task_id !== taskId || !sameContentReviewBinding(baseline.binding, binding)
|
|
1944
|
+
|| (pending ? canonicalSerialize(pending.qa_report_baseline) !== canonicalSerialize(baseline)
|
|
1945
|
+
: baseline.origin_claim_id !== claimId)) {
|
|
1946
|
+
throw Object.assign(new Error('QA report baseline must belong to this exact started execution and relay lineage.'), { code: 'COUNCIL_QA_REPORT_BASELINE_INVALID' });
|
|
1947
|
+
}
|
|
1948
|
+
return baseline;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
function assertPlanningContractRevisionExecutionBaseline(agent, { claimId, pending, binding, taskId }) {
|
|
1952
|
+
const baseline = agent.execution_planning_revision_baseline;
|
|
1953
|
+
if (!baseline || typeof baseline.origin_claim_id !== 'string' || !baseline.origin_claim_id
|
|
1954
|
+
|| baseline.task_id !== taskId || !sameContentReviewBinding(baseline.binding, binding)
|
|
1955
|
+
|| !Array.isArray(baseline.owned_artifacts) || baseline.owned_artifacts.length === 0
|
|
1956
|
+
|| !baseline.owned_artifacts.every(item => typeof item?.path === 'string' && /^[a-f0-9]{64}$/.test(item.sha256 || ''))
|
|
1957
|
+
|| !Array.isArray(baseline.subject_artifacts) || baseline.subject_artifacts.length < baseline.owned_artifacts.length
|
|
1958
|
+
|| !baseline.subject_artifacts.every(item => typeof item?.path === 'string' && /^[a-f0-9]{64}$/.test(item.sha256 || ''))
|
|
1959
|
+
|| typeof baseline.handoff?.path !== 'string' || !/^[a-f0-9]{64}$/.test(baseline.handoff.sha256 || '')
|
|
1960
|
+
|| (pending ? canonicalSerialize(pending.planning_revision_baseline) !== canonicalSerialize(baseline)
|
|
1961
|
+
: baseline.origin_claim_id !== claimId)) {
|
|
1962
|
+
throw Object.assign(new Error('Planning contract revision baseline must belong to this exact execution and relay lineage.'),
|
|
1963
|
+
{ code: 'PLANNING_CONTRACT_REVISION_EXECUTION_BASELINE_INVALID' });
|
|
1964
|
+
}
|
|
1965
|
+
return baseline;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
/** Exactly one active authority, with separate immutable recovery histories. */
|
|
1969
|
+
function activePlanningQaHistory(session, agentName) {
|
|
1970
|
+
const agent = session.agents?.[agentName];
|
|
1971
|
+
const fail = message => { throw Object.assign(new Error(message), { code: 'COUNCIL_QA_EXECUTION_REQUIRED' }); };
|
|
1972
|
+
if (agentName !== 'qa-planning' || !hasPlanningQaRecovery(agent)) {
|
|
1973
|
+
fail('Exactly one active Planning QA recovery authority is required.');
|
|
1974
|
+
}
|
|
1975
|
+
if (agent.planning_contract_revision_id) {
|
|
1976
|
+
const { history } = planningContractRevisionHistory(session);
|
|
1977
|
+
const active = history.filter(item => item.status === 'active');
|
|
1978
|
+
const entry = active[0];
|
|
1979
|
+
if (active.length !== 1 || entry.request?.revision_id !== agent.planning_contract_revision_id
|
|
1980
|
+
|| agent.council_revalidation_pending || agent.planning_reconciliation_pending
|
|
1981
|
+
|| !Array.isArray(agent.council_revalidations) || agent.council_revalidations.length !== 1
|
|
1982
|
+
|| !Array.isArray(agent.planning_reconciliations)
|
|
1983
|
+
|| agent.planning_reconciliations.filter(item => item.authorization).length !== 1
|
|
1984
|
+
|| session.language !== entry.request.language
|
|
1985
|
+
|| !['detail', 'architect', 'ux'].every(owner => entry.owner_results?.[owner])) {
|
|
1986
|
+
fail('Fresh QA requires the single active contract revision and genuine recompletion of every upstream owner.');
|
|
1987
|
+
}
|
|
1988
|
+
return { kind: 'planning_contract_revision', entry, binding: entry.request.source_held_qa.binding };
|
|
1989
|
+
}
|
|
1990
|
+
if ((agent.council_revalidation_pending && agent.planning_reconciliation_pending)
|
|
1991
|
+
|| !Array.isArray(agent.council_revalidations) || agent.council_revalidations.length !== 1) {
|
|
1992
|
+
fail('Exactly one active Planning QA recovery authority is required.');
|
|
1993
|
+
}
|
|
1994
|
+
if (!agent.planning_reconciliation_pending) return { kind: 'council_revalidation', entry: agent.council_revalidations[0] };
|
|
1995
|
+
const history = agent.planning_reconciliations;
|
|
1996
|
+
const authorized = Array.isArray(history) ? history.filter(item => item.authorization) : [];
|
|
1997
|
+
if (authorized.length !== 1 || history.length > MAX_PLANNING_RECONCILIATION_TARGETS) fail('A single consumed reconciliation authority is required.');
|
|
1998
|
+
const entry = authorized[0], prior = agent.council_revalidations[0], held = entry.request?.held_qa;
|
|
1999
|
+
if (entry.status !== 'authorized' || !entry.authorization.fresh_claim_id
|
|
2000
|
+
|| prior.result?.execution_claim_id !== held?.claim_id
|
|
2001
|
+
|| prior.result?.execution_finished_at !== held?.finished_at
|
|
2002
|
+
|| canonicalSerialize(prior.result?.artifacts) !== canonicalSerialize(entry.request?.source_artifacts)
|
|
2003
|
+
|| canonicalSerialize(prior.result?.qa_result) !== canonicalSerialize(held?.qa_result)
|
|
2004
|
+
|| !sameContentReviewBinding(prior.binding, held?.binding)) {
|
|
2005
|
+
fail('Consumed reconciliation must retain the original held QA and source manifest.');
|
|
2006
|
+
}
|
|
2007
|
+
return { kind: 'planning_reconciliation', entry, artifacts: entry.request.target_artifacts, binding: held.binding };
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
function pinClaimedCouncilQa(projectDir, session, agentName, { allowOwnInteraction = false, expectedArtifacts, inputsOnly = true, excludeQaReport = false } = {}) {
|
|
2011
|
+
const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
|
|
2012
|
+
const active = activePlanningQaHistory(session, agentName);
|
|
2013
|
+
const subject = pinPlanningQaSubject(projectDir, expectedArtifacts || active.artifacts || active.entry.artifacts, { inputsOnly, excludeQaReport });
|
|
2014
|
+
try {
|
|
2015
|
+
const binding = qaInvocationBinding(subject.artifact);
|
|
2016
|
+
if (!sameContentReviewBinding(binding, active.binding || active.entry.binding)) {
|
|
2017
|
+
fail('CONTENT_REVIEW_BINDING_MISMATCH', 'The selected QA route changed after council revalidation.');
|
|
2018
|
+
}
|
|
2019
|
+
const verifyRecovery = () => {
|
|
2020
|
+
if (active.kind === 'planning_contract_revision') {
|
|
2021
|
+
authenticatePlanningContractRevisionHistory(projectDir, session);
|
|
2022
|
+
readAttestedPlanningContractRevision(projectDir, active.entry.request,
|
|
2023
|
+
active.entry.launch_nonce, active.entry.decision_sha256);
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
if (active.kind === 'planning_reconciliation') {
|
|
2027
|
+
const decision = readAttestedPlanningReconciliationDecision(projectDir, subject, active.entry.request, active.entry.authorization.decision_sha256);
|
|
2028
|
+
if (decision.record.decision.decision !== 'authorize-qa-revalidation'
|
|
2029
|
+
|| decision.record.decision.launch_nonce !== active.entry.launch_nonce
|
|
2030
|
+
|| active.entry.request.language !== session.language) {
|
|
2031
|
+
fail('PLANNING_RECONCILIATION_DECISION_UNTRUSTED', 'Fresh QA requires the exact consumed positive reconciliation and original language.');
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
};
|
|
2035
|
+
verifyRecovery();
|
|
2036
|
+
assertPlanningContentBoundary(projectDir, session, { agent: agentName, binding }, fail, { allowOwnInteraction });
|
|
2037
|
+
assertV2PlanningPredecessors(session, agentName);
|
|
2038
|
+
return Object.freeze({ ...subject, assertCurrent() { subject.assertCurrent(); verifyRecovery(); } });
|
|
2039
|
+
} catch (error) { subject.close(); throw error; }
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
/** The fresh QA verdict is authority only for the exact bytes it actually held. */
|
|
2043
|
+
export function pinHeldCouncilQaSubject(projectDir, session) {
|
|
2044
|
+
const agent = session?.agents?.['qa-planning'];
|
|
2045
|
+
if (!hasPlanningQaRecovery(agent, 'qa-planning')) return null;
|
|
2046
|
+
const active = activePlanningQaHistory(session, 'qa-planning');
|
|
2047
|
+
const result = active.entry.result;
|
|
2048
|
+
const revisionSubject = active.kind === 'planning_contract_revision'
|
|
2049
|
+
? session.planning_contract_revision_subjects?.filter(item => item.revision_id === active.entry.request.revision_id)
|
|
2050
|
+
: null;
|
|
2051
|
+
if (agent.status !== 'awaiting_advance' || agent.execution_claim_state !== 'awaiting_advance'
|
|
2052
|
+
|| agent.execution_result_status !== 'complete' || !result?.artifacts
|
|
2053
|
+
|| result.execution_claim_id !== agent.execution_claim_id || result.execution_finished_at !== agent.execution_finished_at
|
|
2054
|
+
|| !result.qa_result || !['score', 'gate_result', 'blocking_count'].every(key => result.qa_result[key] === agent.execution_qa_result?.[key])
|
|
2055
|
+
|| (revisionSubject && (revisionSubject.length !== 1
|
|
2056
|
+
|| revisionSubject[0].execution_claim_id !== result.execution_claim_id
|
|
2057
|
+
|| revisionSubject[0].execution_finished_at !== result.execution_finished_at
|
|
2058
|
+
|| canonicalSerialize(revisionSubject[0].artifacts) !== canonicalSerialize(result.artifacts)))) {
|
|
2059
|
+
throw Object.assign(new Error('The new QA verdict must be held with its exact claim and result manifest.'), { code: 'COUNCIL_QA_HELD_RESULT_MISMATCH' });
|
|
2060
|
+
}
|
|
2061
|
+
return pinClaimedCouncilQa(projectDir, session, 'qa-planning', { expectedArtifacts: result.artifacts, inputsOnly: false });
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/** Feedback is data for the validated next execution/relay, never an approval. */
|
|
2065
|
+
function claimContentReviewContext(projectDir, session, match) {
|
|
2066
|
+
assertPlanningContractRevisionOwner(session, [match.agent], projectDir);
|
|
2067
|
+
const agent = session.agents?.[match.agent];
|
|
2068
|
+
if (!agent?.content_review_pending) return null;
|
|
2069
|
+
const reviews = agent.content_rejections;
|
|
2070
|
+
const revision = agent.planning_contract_revision_id
|
|
2071
|
+
? session.planning_contract_revisions?.find(item => item.request?.revision_id === agent.planning_contract_revision_id)
|
|
2072
|
+
: null;
|
|
2073
|
+
let historicalRejectionCount;
|
|
2074
|
+
if (!match.claimId || !match.taskId || agent.content_review_task_id !== match.taskId
|
|
2075
|
+
|| !sameContentReviewBinding(agent.execution_binding, match.binding)
|
|
2076
|
+
|| (!revision && (!Array.isArray(reviews) || !reviews.length || reviews.length > MAX_CORRECTION_CYCLES
|
|
2077
|
+
|| !sameContentReviewBinding(reviews.at(-1).binding, match.binding)))) {
|
|
2078
|
+
throw Object.assign(new Error('Content-review feedback does not match this claimed task and binding.'),
|
|
2079
|
+
{ code: 'CONTENT_REVIEW_BINDING_MISMATCH' });
|
|
2080
|
+
}
|
|
2081
|
+
if (revision) {
|
|
2082
|
+
if (revision.status !== 'active' || planningContractRevisionHistory(session).tip !== revision
|
|
2083
|
+
|| !PLANNING_CONTRACT_REVISION_OWNERS.includes(match.agent)
|
|
2084
|
+
|| revision.request.language !== session.language) {
|
|
2085
|
+
throw Object.assign(new Error('Planning contract direction is not active for this exact owner and language.'),
|
|
2086
|
+
{ code: 'PLANNING_CONTRACT_REVISION_CONTEXT_INVALID' });
|
|
2087
|
+
}
|
|
2088
|
+
readAttestedPlanningContractRevision(projectDir, revision.request, revision.launch_nonce, revision.decision_sha256);
|
|
2089
|
+
// Settlement archives the exact rejection prefix. Later rejected owner
|
|
2090
|
+
// results append to it; dates cannot distinguish inherited and current work.
|
|
2091
|
+
const archivedOwner = revision.archived?.previous_agents?.[match.agent];
|
|
2092
|
+
const historicalReviews = archivedOwner?.content_rejections === undefined ? [] : archivedOwner.content_rejections;
|
|
2093
|
+
const currentReviews = reviews === undefined ? [] : reviews;
|
|
2094
|
+
if (!archivedOwner || typeof archivedOwner !== 'object' || Array.isArray(archivedOwner)
|
|
2095
|
+
|| !Array.isArray(historicalReviews) || !Array.isArray(currentReviews)
|
|
2096
|
+
|| historicalReviews.length > currentReviews.length
|
|
2097
|
+
|| canonicalSerialize(currentReviews.slice(0, historicalReviews.length)) !== canonicalSerialize(historicalReviews)) {
|
|
2098
|
+
throw Object.assign(new Error('Planning revision rejection history must retain its exact archived prefix.'),
|
|
2099
|
+
{ code: 'PLANNING_CONTRACT_REVISION_CONTEXT_INVALID' });
|
|
2100
|
+
}
|
|
2101
|
+
historicalRejectionCount = historicalReviews.length;
|
|
2102
|
+
}
|
|
2103
|
+
const revalidation = agent.council_revalidation_pending ? agent.council_revalidations?.at(-1) : null;
|
|
2104
|
+
if (agent.council_revalidation_pending && (!revalidation || match.agent !== 'qa-planning'
|
|
2105
|
+
|| !sameContentReviewBinding(revalidation.binding, match.binding))) {
|
|
2106
|
+
throw Object.assign(new Error('Council QA revalidation does not match this execution.'), { code: 'CONTENT_REVIEW_BINDING_MISMATCH' });
|
|
2107
|
+
}
|
|
2108
|
+
const reconciliation = agent.planning_reconciliation_pending ? activePlanningQaHistory(session, match.agent) : null;
|
|
2109
|
+
if (reconciliation && !sameContentReviewBinding(reconciliation.binding, match.binding)) {
|
|
2110
|
+
throw Object.assign(new Error('Planning reconciliation does not match this QA execution.'), { code: 'CONTENT_REVIEW_BINDING_MISMATCH' });
|
|
2111
|
+
}
|
|
2112
|
+
return { agent: match.agent, taskId: match.taskId, claimId: match.claimId,
|
|
2113
|
+
binding: cloneSessionValue(match.binding),
|
|
2114
|
+
rejections: (reviews || []).map(review => ({ reason: review.reason, rejected_at: review.rejected_at,
|
|
2115
|
+
execution_claim_id: review.execution.execution_claim_id,
|
|
2116
|
+
execution_finished_at: review.execution.execution_finished_at })),
|
|
2117
|
+
...(revalidation ? { council_revalidation: {
|
|
2118
|
+
source_gate_id: revalidation.source_gate_id, artifacts: cloneSessionValue(revalidation.artifacts),
|
|
2119
|
+
blockers: cloneSessionValue(revalidation.blockers),
|
|
2120
|
+
} } : {}),
|
|
2121
|
+
...(reconciliation ? { planning_reconciliation: {
|
|
2122
|
+
reconciliation_id: reconciliation.entry.request.reconciliation_id,
|
|
2123
|
+
decision_sha256: reconciliation.entry.authorization.decision_sha256,
|
|
2124
|
+
source_gate_id: reconciliation.entry.request.source_gate_id,
|
|
2125
|
+
artifacts: cloneSessionValue(reconciliation.artifacts),
|
|
2126
|
+
blockers: cloneSessionValue(reconciliation.entry.request.blockers),
|
|
2127
|
+
} } : {}),
|
|
2128
|
+
...(revision ? { planning_contract_revision: {
|
|
2129
|
+
revision_id: revision.request.revision_id, source_gate_id: revision.request.source_gate_id,
|
|
2130
|
+
source_decision_sha256: revision.request.source_decision_sha256,
|
|
2131
|
+
old_subject: cloneSessionValue(revision.request.old_subject),
|
|
2132
|
+
accepted_blocker_ids: cloneSessionValue(revision.request.accepted_blocker_ids),
|
|
2133
|
+
direction: cloneSessionValue(revision.request.direction), authority_scope: 'pipeline-reopening-only',
|
|
2134
|
+
historical_rejection_count: historicalRejectionCount,
|
|
2135
|
+
} } : {}),
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
function assertHeldPlanningContent(projectDir, session, match, fail) {
|
|
2140
|
+
const agent = session.agents?.[match.agent];
|
|
2141
|
+
if (!CONTENT_REVIEW_AGENTS.has(match.agent) || session.current_agent !== match.agent
|
|
2142
|
+
|| agent?.status !== 'awaiting_advance' || agent.execution_claim_state !== 'awaiting_advance'
|
|
2143
|
+
|| agent.execution_result_status !== 'complete' || session.completed_agents?.includes(match.agent)
|
|
2144
|
+
|| typeof match.claimId !== 'string' || !match.claimId || agent.execution_claim_id !== match.claimId
|
|
2145
|
+
|| typeof match.finishedAt !== 'string' || !Number.isFinite(Date.parse(match.finishedAt))
|
|
2146
|
+
|| agent.execution_finished_at !== match.finishedAt) {
|
|
2147
|
+
fail('AGENT_RESULT_REJECTION_MISMATCH', 'Rejection requires the exact current, unapproved complete Planning result.');
|
|
2148
|
+
}
|
|
2149
|
+
assertPlanningContentBoundary(projectDir, session, match, fail);
|
|
2150
|
+
return agent;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
function assertPlanningContentBoundary(projectDir, session, match, fail, { allowOwnInteraction = false } = {}) {
|
|
2154
|
+
const agent = session.agents?.[match.agent];
|
|
2155
|
+
if (session.current_agent !== match.agent || session.completed_agents?.includes(match.agent)) {
|
|
2156
|
+
fail('AGENT_RESULT_REJECTION_MISMATCH', 'The unapproved Planning agent must still own the session.');
|
|
2157
|
+
}
|
|
2158
|
+
if (session.mode !== 'plan' || session.rail_handoff_id || session.rail_handoff_path || session.rail_state) {
|
|
2159
|
+
fail('CONTENT_REVIEW_SEALED', 'Content rejection cannot reopen sealed Planning or RAIL.');
|
|
2160
|
+
}
|
|
2161
|
+
const handoffs = join(projectDir, '.chati/v2/rail/handoffs');
|
|
2162
|
+
try {
|
|
2163
|
+
const stat = lstatSync(handoffs);
|
|
2164
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || storedRailHandoffEntries(handoffs).length) {
|
|
2165
|
+
fail('CONTENT_REVIEW_SEALED', 'Stored RAIL handoffs prohibit content rejection.');
|
|
2166
|
+
}
|
|
2167
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
2168
|
+
if (!sameContentReviewBinding(session.current_execution_binding, match.binding)
|
|
2169
|
+
|| (agent?.execution_binding && !sameContentReviewBinding(agent.execution_binding, match.binding))) {
|
|
2170
|
+
fail('CONTENT_REVIEW_BINDING_MISMATCH', 'The held execution binding must match the validated installation route.');
|
|
2171
|
+
}
|
|
2172
|
+
const later = AGENT_PIPELINE.slice(AGENT_PIPELINE.findIndex(entry => entry.name === match.agent) + 1)
|
|
2173
|
+
.filter(entry => entry.phase === 'plan').map(entry => entry.name);
|
|
2174
|
+
if ((session.pending_interaction && !(allowOwnInteraction && session.pending_interaction.agent === match.agent))
|
|
2175
|
+
|| Object.entries(session.agents || {}).some(([name, state]) => name !== match.agent
|
|
2176
|
+
&& (state.execution_claim_id || state.execution_claim_state || ['in_progress', 'awaiting_advance', 'waiting_for_user'].includes(state.status)
|
|
2177
|
+
|| (later.includes(name) && (state.status === 'completed' || session.completed_agents?.includes(name)))))) {
|
|
2178
|
+
fail('CONTENT_REVIEW_DEPENDENTS_ACTIVE', 'Settle other claims, interactions and dependent work before rejecting this result.');
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
/** Read-only precondition for a separately authenticated final QA recovery. */
|
|
2183
|
+
export function assertPlanningReconciliationBoundary(projectDir, session, match, binding) {
|
|
2184
|
+
const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
|
|
2185
|
+
if (match.agent !== 'qa-planning') fail('PLANNING_RECONCILIATION_AGENT_REQUIRED', 'Planning reconciliation requires QA-Planning.');
|
|
2186
|
+
const agent = assertHeldPlanningContent(projectDir, session, { ...match, binding }, fail);
|
|
2187
|
+
assertV2PlanningPredecessors(session, match.agent);
|
|
2188
|
+
if (agent.content_rejections?.length !== MAX_CORRECTION_CYCLES
|
|
2189
|
+
|| agent.council_revalidations?.length !== MAX_COUNCIL_QA_REVALIDATIONS
|
|
2190
|
+
|| !agent.council_revalidation_pending) {
|
|
2191
|
+
fail('PLANNING_RECONCILIATION_HISTORY_REQUIRED', 'Planning reconciliation requires the exact exhausted QA recovery history.');
|
|
2192
|
+
}
|
|
2193
|
+
const prior = agent.council_revalidations[0];
|
|
2194
|
+
const held = prior.result;
|
|
2195
|
+
if (!held?.artifacts || held.execution_claim_id !== match.claimId
|
|
2196
|
+
|| held.execution_finished_at !== match.finishedAt
|
|
2197
|
+
|| !sameContentReviewBinding(prior.binding, binding)
|
|
2198
|
+
|| !isPlanningQaOutcomeValid(agent.execution_qa_result, getQaThreshold(match.agent, projectDir))
|
|
2199
|
+
|| agent.execution_qa_result.gate_result !== 'approved'
|
|
2200
|
+
|| !['score', 'gate_result', 'blocking_count'].every(key => held.qa_result?.[key] === agent.execution_qa_result[key])) {
|
|
2201
|
+
fail('PLANNING_RECONCILIATION_HELD_QA_MISMATCH', 'The held QA must be the genuine result of the single prior council revalidation.');
|
|
2202
|
+
}
|
|
2203
|
+
return prior;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
// A distinct recovery authority does not reset either existing correction budget.
|
|
2207
|
+
// At most three distinct targets may be assessed, and at most one may authorize QA.
|
|
2208
|
+
export const MAX_PLANNING_RECONCILIATION_TARGETS = 3;
|
|
2209
|
+
|
|
2210
|
+
/** Reserve before inference; identical requests can only retrieve the same slot. */
|
|
2211
|
+
export function reservePlanningReconciliation(projectDir, match = {}) {
|
|
2212
|
+
let subject;
|
|
2213
|
+
let outcome;
|
|
2214
|
+
try {
|
|
2215
|
+
const result = mutateSession(projectDir, session => {
|
|
2216
|
+
subject = pinPlanningReconciliation(projectDir, match, session);
|
|
2217
|
+
const request = subject.request;
|
|
2218
|
+
const qa = session.agents['qa-planning'];
|
|
2219
|
+
const history = qa.planning_reconciliations || [];
|
|
2220
|
+
if (!Array.isArray(history) || history.length > MAX_PLANNING_RECONCILIATION_TARGETS) {
|
|
2221
|
+
throw Object.assign(new Error('Planning reconciliation history is invalid.'), { code: 'PLANNING_RECONCILIATION_HISTORY_INVALID' });
|
|
2222
|
+
}
|
|
2223
|
+
const previous = history.find(item => item.request?.reconciliation_id === request.reconciliation_id);
|
|
2224
|
+
if (previous) {
|
|
2225
|
+
if (canonicalSerialize(previous.request) !== canonicalSerialize(request)) {
|
|
2226
|
+
throw Object.assign(new Error('Stored Planning reconciliation request differs from its recomputed subject.'), { code: 'PLANNING_RECONCILIATION_REQUEST_MISMATCH' });
|
|
2227
|
+
}
|
|
2228
|
+
outcome = { reservation: cloneSessionValue(previous), reused: true };
|
|
2229
|
+
return session;
|
|
2230
|
+
}
|
|
2231
|
+
if (history.some(item => item.authorization)) {
|
|
2232
|
+
throw Object.assign(new Error('The final Planning reconciliation QA authorization was already consumed.'), { code: 'PLANNING_RECONCILIATION_QA_LIMIT' });
|
|
2233
|
+
}
|
|
2234
|
+
if (history.some(item => ['reserved', 'running', 'outcome_unknown'].includes(item.status))) {
|
|
2235
|
+
throw Object.assign(new Error('An unresolved Planning reconciliation cannot be replaced or relaunched by changing its target.'), { code: 'PLANNING_RECONCILIATION_IN_FLIGHT' });
|
|
2236
|
+
}
|
|
2237
|
+
if (history.length >= MAX_PLANNING_RECONCILIATION_TARGETS) {
|
|
2238
|
+
throw Object.assign(new Error('Planning reconciliation target budget exhausted; new technical direction is required.'), { code: 'PLANNING_RECONCILIATION_TARGET_LIMIT' });
|
|
2239
|
+
}
|
|
2240
|
+
const reservation = { request: cloneSessionValue(request), status: 'reserved',
|
|
2241
|
+
launch_nonce: randomUUID(), reserved_at: new Date().toISOString() };
|
|
2242
|
+
qa.planning_reconciliations = [...history, reservation];
|
|
2243
|
+
outcome = { reservation: cloneSessionValue(reservation), reused: false };
|
|
2244
|
+
return session;
|
|
2245
|
+
}, { beforeCommit: () => subject?.assertCurrent() });
|
|
2246
|
+
return { ...result, reserved: result.saved, ...(result.saved ? outcome : {}) };
|
|
2247
|
+
} finally { subject?.close(); }
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/** Single pre-spawn admission. Expiration never resurrects an unknown launch. */
|
|
2251
|
+
export function startPlanningReconciliation(projectDir, match = {}) {
|
|
2252
|
+
let subject;
|
|
2253
|
+
let outcome;
|
|
2254
|
+
try {
|
|
2255
|
+
const result = mutateSession(projectDir, session => {
|
|
2256
|
+
subject = pinPlanningReconciliation(projectDir, match, session);
|
|
2257
|
+
const reservation = session.agents['qa-planning'].planning_reconciliations?.find(
|
|
2258
|
+
item => item.request?.reconciliation_id === subject.request.reconciliation_id);
|
|
2259
|
+
if (!reservation || reservation.status !== 'reserved'
|
|
2260
|
+
|| canonicalSerialize(reservation.request) !== canonicalSerialize(subject.request)
|
|
2261
|
+
|| reservation.launch_nonce !== match.launchNonce
|
|
2262
|
+
|| subject.request.reconciliation_id !== match.reconciliationId) {
|
|
2263
|
+
throw Object.assign(new Error('Only the exact never-started Planning reconciliation reservation may launch.'), { code: 'PLANNING_RECONCILIATION_LAUNCH_MISMATCH' });
|
|
2264
|
+
}
|
|
2265
|
+
reservation.status = 'running';
|
|
2266
|
+
reservation.started_at = new Date().toISOString();
|
|
2267
|
+
outcome = { reservation: cloneSessionValue(reservation) };
|
|
2268
|
+
return session;
|
|
2269
|
+
}, { beforeCommit: () => subject?.assertCurrent() });
|
|
2270
|
+
return { ...result, started: result.saved, ...(result.saved ? outcome : {}) };
|
|
2271
|
+
} finally { subject?.close(); }
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
/** Consume a completed attested assessment atomically, never a caller's verdict. */
|
|
2275
|
+
export function settlePlanningReconciliation(projectDir, match = {}) {
|
|
2276
|
+
let subject;
|
|
2277
|
+
let outcome;
|
|
2278
|
+
try {
|
|
2279
|
+
const result = mutateSession(projectDir, session => {
|
|
2280
|
+
const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
|
|
2281
|
+
const agent = session.agents?.['qa-planning'];
|
|
2282
|
+
const entry = agent?.planning_reconciliations?.find(item => item.request?.reconciliation_id === match.reconciliationId);
|
|
2283
|
+
if (match.agent !== 'qa-planning' || !entry || entry.launch_nonce !== match.launchNonce
|
|
2284
|
+
|| entry.request.held_qa.claim_id !== match.claimId || entry.request.held_qa.finished_at !== match.finishedAt
|
|
2285
|
+
|| entry.request.source_gate_id !== match.gateId) {
|
|
2286
|
+
fail('PLANNING_RECONCILIATION_LAUNCH_MISMATCH', 'Settlement requires the exact reserved request, held result and nonce.');
|
|
2287
|
+
}
|
|
2288
|
+
if (entry.authorization) {
|
|
2289
|
+
if (!agent.planning_reconciliation_pending || agent.status !== 'in_progress'
|
|
2290
|
+
|| agent.execution_claim_state !== 'reserved' || Date.parse(agent.execution_claim_expires_at) <= Date.now()
|
|
2291
|
+
|| !Number.isFinite(Date.parse(agent.execution_claim_expires_at))
|
|
2292
|
+
|| agent.execution_claim_id !== entry.authorization.fresh_claim_id) {
|
|
2293
|
+
fail('PLANNING_RECONCILIATION_QA_LIMIT', 'Consumed authorization cannot create or resurrect another QA claim.');
|
|
2294
|
+
}
|
|
2295
|
+
subject = pinClaimedCouncilQa(projectDir, session, 'qa-planning', { excludeQaReport: true });
|
|
2296
|
+
outcome = { authorized: true, claimId: agent.execution_claim_id, binding: qaInvocationBinding(subject.artifact), reused: true };
|
|
2297
|
+
return session;
|
|
2298
|
+
}
|
|
2299
|
+
if (!['running', 'outcome_unknown', 'denied'].includes(entry.status)) {
|
|
2300
|
+
fail('PLANNING_RECONCILIATION_LAUNCH_MISMATCH', 'Only a started assessment or its cached denial may settle.');
|
|
2301
|
+
}
|
|
2302
|
+
subject = pinPlanningReconciliation(projectDir, match, session);
|
|
2303
|
+
if (canonicalSerialize(entry.request) !== canonicalSerialize(subject.request)) {
|
|
2304
|
+
fail('PLANNING_RECONCILIATION_REQUEST_MISMATCH', 'Reconciliation target changed before settlement.');
|
|
2305
|
+
}
|
|
2306
|
+
const decision = readPlanningReconciliationDecision(subject);
|
|
2307
|
+
if (decision.record.decision.launch_nonce !== entry.launch_nonce) {
|
|
2308
|
+
fail('PLANNING_RECONCILIATION_LAUNCH_MISMATCH', 'Decision does not belong to the started reservation.');
|
|
2309
|
+
}
|
|
2310
|
+
if (decision.record.decision.decision === 'deny-qa-revalidation') {
|
|
2311
|
+
entry.status = 'denied';
|
|
2312
|
+
entry.decision_sha256 = decision.sha256;
|
|
2313
|
+
entry.settled_at ||= new Date().toISOString();
|
|
2314
|
+
outcome = { authorized: false, decision: 'deny-qa-revalidation', decision_path: decision.path,
|
|
2315
|
+
assessments: cloneSessionValue(decision.record.decision.assessments) };
|
|
2316
|
+
return session;
|
|
2317
|
+
}
|
|
2318
|
+
if (!['running', 'outcome_unknown'].includes(entry.status) || agent.planning_reconciliations.some(item => item.authorization)) {
|
|
2319
|
+
fail('PLANNING_RECONCILIATION_QA_LIMIT', 'Only one positive reconciliation may authorize fresh QA.');
|
|
2320
|
+
}
|
|
2321
|
+
const now = new Date(), claimId = randomUUID(), binding = subject.binding;
|
|
2322
|
+
const execution = Object.fromEntries(Object.entries(agent).filter(([key]) => key.startsWith('execution_') || key === 'status' || key === 'score'));
|
|
2323
|
+
entry.status = 'authorized';
|
|
2324
|
+
entry.authorization = { decision_sha256: decision.sha256, consumed_at: now.toISOString(), fresh_claim_id: claimId,
|
|
2325
|
+
execution: cloneSessionValue(execution) };
|
|
2326
|
+
// Keep the original council history untouched. The new history becomes
|
|
2327
|
+
// the sole active authority, without resetting either earlier budget.
|
|
2328
|
+
delete agent.council_revalidation_pending;
|
|
2329
|
+
agent.planning_reconciliation_pending = true;
|
|
2330
|
+
agent.content_review_pending = true;
|
|
2331
|
+
agent.content_review_task_id = 'qa-planning-task';
|
|
2332
|
+
agent.status = 'in_progress';
|
|
2333
|
+
agent.execution_resume_status = 'pending_rework';
|
|
2334
|
+
agent.execution_claim_id = claimId;
|
|
2335
|
+
agent.execution_claim_state = 'reserved';
|
|
2336
|
+
agent.execution_claim_started_at = now.toISOString();
|
|
2337
|
+
agent.execution_claim_expires_at = new Date(now.getTime() + 30 * 60 * 1000).toISOString();
|
|
2338
|
+
agent.execution_task_id = 'qa-planning-task';
|
|
2339
|
+
agent.execution_binding = binding;
|
|
2340
|
+
for (const key of ['execution_started_at', 'execution_finished_at', 'execution_result_status', 'execution_qa_result']) delete agent[key];
|
|
2341
|
+
outcome = { authorized: true, claimId, binding, reused: false };
|
|
2342
|
+
return session;
|
|
2343
|
+
}, { beforeCommit: () => subject?.assertCurrent() });
|
|
2344
|
+
return { ...result, settled: result.saved, ...(result.saved ? outcome : {}) };
|
|
2345
|
+
} finally { subject?.close(); }
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
/** A lost/unverifiable result never makes a started assessment launchable again. */
|
|
2349
|
+
export function recordPlanningReconciliationFailure(projectDir, match = {}, code = 'PLANNING_RECONCILIATION_FAILED') {
|
|
2350
|
+
const result = mutateSession(projectDir, session => {
|
|
2351
|
+
const entry = session.agents?.['qa-planning']?.planning_reconciliations?.find(
|
|
2352
|
+
item => item.request?.reconciliation_id === match.reconciliationId);
|
|
2353
|
+
if (!entry || entry.status !== 'running' || entry.launch_nonce !== match.launchNonce
|
|
2354
|
+
|| entry.request.held_qa.claim_id !== match.claimId || entry.request.held_qa.finished_at !== match.finishedAt
|
|
2355
|
+
|| entry.request.source_gate_id !== match.gateId || match.agent !== 'qa-planning') {
|
|
2356
|
+
throw Object.assign(new Error('Failure does not match a started reconciliation.'), { code: 'PLANNING_RECONCILIATION_LAUNCH_MISMATCH' });
|
|
2357
|
+
}
|
|
2358
|
+
entry.status = 'outcome_unknown';
|
|
2359
|
+
entry.failure = { code: /^[A-Z][A-Z0-9_]{0,100}$/.test(code) ? code : 'PLANNING_RECONCILIATION_FAILED',
|
|
2360
|
+
recorded_at: new Date().toISOString() };
|
|
2361
|
+
return session;
|
|
2362
|
+
});
|
|
2363
|
+
return { ...result, recorded: result.saved };
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
/** Exact exhausted hold required before a controller may reopen Planning ownership. */
|
|
2367
|
+
export function assertPlanningContractRevisionBoundary(projectDir, session, match, binding, subject) {
|
|
2368
|
+
const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
|
|
2369
|
+
if (match.agent !== 'qa-planning') {
|
|
2370
|
+
fail('PLANNING_CONTRACT_REVISION_AGENT_REQUIRED', 'Planning contract revision requires the held QA-Planning result.');
|
|
2371
|
+
}
|
|
2372
|
+
const agent = assertHeldPlanningContent(projectDir, session, { ...match, binding }, fail);
|
|
2373
|
+
assertV2PlanningPredecessors(session, 'qa-planning');
|
|
2374
|
+
if (match.previousRevisionId !== undefined) {
|
|
2375
|
+
const lineage = authenticatePlanningContractRevisionHistory(projectDir, session);
|
|
2376
|
+
const previous = lineage.pending ? lineage.history.at(-2) : lineage.tip;
|
|
2377
|
+
if (previous?.status === 'completed' && previous.request.revision_id === match.previousRevisionId) {
|
|
2378
|
+
if (agent.planning_contract_revision_id || agent.council_revalidation_pending || agent.planning_reconciliation_pending
|
|
2379
|
+
|| agent.execution_planning_revision_result || session.language !== previous.request.language
|
|
2380
|
+
|| !sameContentReviewBinding(previous.request.source_held_qa.binding, binding)
|
|
2381
|
+
|| !isPlanningQaOutcomeValid(agent.execution_qa_result, getQaThreshold('qa-planning', projectDir))
|
|
2382
|
+
|| agent.execution_qa_result.gate_result !== 'approved'
|
|
2383
|
+
|| agent.content_rejections?.length !== MAX_CORRECTION_CYCLES
|
|
2384
|
+
|| agent.council_revalidations?.length !== MAX_COUNCIL_QA_REVALIDATIONS
|
|
2385
|
+
|| !Array.isArray(agent.planning_reconciliations)
|
|
2386
|
+
|| agent.planning_reconciliations.some(item => !['authorized', 'denied'].includes(item.status))
|
|
2387
|
+
|| agent.planning_reconciliations.filter(item => item.authorization).length !== 1) {
|
|
2388
|
+
fail('PLANNING_CONTRACT_REVISION_HELD_QA_MISMATCH', 'Post-correction continuation requires a fresh normal approved QA on the original route and language with exhausted, terminal recovery history.');
|
|
2389
|
+
}
|
|
2390
|
+
const held = canonicalize({ claim_id: match.claimId, finished_at: match.finishedAt, binding, qa_result: agent.execution_qa_result });
|
|
2391
|
+
const correction = derivePlanningCorrectionSource(projectDir, session, previous, held);
|
|
2392
|
+
for (const owner of correction.owners) {
|
|
2393
|
+
if (!sameContentReviewBinding(owner.binding, previous.owner_results[owner.agent]?.execution_binding)) {
|
|
2394
|
+
fail('CONTENT_REVIEW_BINDING_MISMATCH', 'Corrected owners must retain their preceding installed execution bindings.');
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
return { agent, held, correction, reconciliation: cloneSessionValue(previous.request.source_reconciliation),
|
|
2398
|
+
previous: canonicalize({ revision_id: previous.request.revision_id, launch_nonce: previous.launch_nonce,
|
|
2399
|
+
decision_sha256: previous.decision_sha256, result_sha256: planningRevisionTerminalDigest(session, previous) }) };
|
|
2400
|
+
}
|
|
2401
|
+
if (!previous || previous.request.revision_id !== match.previousRevisionId
|
|
2402
|
+
|| previous !== lineage.active
|
|
2403
|
+
|| agent.planning_contract_revision_id !== previous.request.revision_id
|
|
2404
|
+
|| (lineage.pending && lineage.pending.request.previous_revision?.revision_id !== previous.request.revision_id)
|
|
2405
|
+
|| (!lineage.pending && lineage.tip !== previous)
|
|
2406
|
+
|| agent.council_revalidation_pending || agent.planning_reconciliation_pending
|
|
2407
|
+
|| agent.planning_reconciliations?.some(item => ['reserved', 'running', 'outcome_unknown'].includes(item.status))) {
|
|
2408
|
+
fail('PLANNING_CONTRACT_REVISION_PREDECESSOR_INVALID', 'Explicit continuation requires the active predecessor with a terminal held QA and no unresolved recovery.');
|
|
2409
|
+
}
|
|
2410
|
+
const active = activePlanningQaHistory(session, 'qa-planning');
|
|
2411
|
+
const held = active.entry.result;
|
|
2412
|
+
if (active.kind !== 'planning_contract_revision' || active.entry !== previous
|
|
2413
|
+
|| held?.execution_claim_id !== match.claimId || held.execution_finished_at !== match.finishedAt
|
|
2414
|
+
|| !sameContentReviewBinding(active.binding, binding)
|
|
2415
|
+
|| !isPlanningQaOutcomeValid(agent.execution_qa_result, getQaThreshold('qa-planning', projectDir))
|
|
2416
|
+
|| agent.execution_qa_result.gate_result !== 'approved'
|
|
2417
|
+
|| canonicalSerialize(held.qa_result) !== canonicalSerialize(agent.execution_qa_result)
|
|
2418
|
+
|| canonicalSerialize(held.artifacts) !== canonicalSerialize(subject.plan.artifacts)
|
|
2419
|
+
|| agent.content_rejections?.length !== MAX_CORRECTION_CYCLES
|
|
2420
|
+
|| agent.council_revalidations?.length !== MAX_COUNCIL_QA_REVALIDATIONS) {
|
|
2421
|
+
fail('PLANNING_CONTRACT_REVISION_HELD_QA_MISMATCH', 'Continuation must consume the exact genuine QA subject of the preceding direction.');
|
|
2422
|
+
}
|
|
2423
|
+
const resultDigest = planningRevisionResultDigest(session, previous);
|
|
2424
|
+
if (session.planning_contract_revision_subjects.find(item => item.revision_id === previous.request.revision_id).gate_id !== subject.plan.gate_id) {
|
|
2425
|
+
fail('PLANNING_CONTRACT_REVISION_HELD_QA_MISMATCH', 'The preceding QA subject must name this exact council gate.');
|
|
2426
|
+
}
|
|
2427
|
+
const ownedHold = agent.execution_planning_revision_result;
|
|
2428
|
+
if (ownedHold?.execution_claim_id !== match.claimId || ownedHold.execution_finished_at !== match.finishedAt
|
|
2429
|
+
|| canonicalSerialize(ownedHold.subject) !== canonicalSerialize({ gate_id: subject.plan.gate_id, artifacts: subject.plan.artifacts })) {
|
|
2430
|
+
fail('PLANNING_CONTRACT_REVISION_HELD_QA_MISMATCH', 'Continuation cannot replace the produced QA subject with caller-authored bytes.');
|
|
2431
|
+
}
|
|
2432
|
+
return { agent, held: canonicalize({ claim_id: match.claimId, finished_at: match.finishedAt, binding, qa_result: held.qa_result }),
|
|
2433
|
+
reconciliation: cloneSessionValue(previous.request.source_reconciliation),
|
|
2434
|
+
previous: canonicalize({ revision_id: previous.request.revision_id, launch_nonce: previous.launch_nonce,
|
|
2435
|
+
decision_sha256: previous.decision_sha256, result_sha256: resultDigest }) };
|
|
2436
|
+
}
|
|
2437
|
+
const active = activePlanningQaHistory(session, 'qa-planning');
|
|
2438
|
+
const reconciliation = agent.planning_reconciliations;
|
|
2439
|
+
if (active.kind !== 'planning_reconciliation' || !agent.planning_reconciliation_pending
|
|
2440
|
+
|| agent.content_rejections?.length !== MAX_CORRECTION_CYCLES
|
|
2441
|
+
|| agent.council_revalidations?.length !== MAX_COUNCIL_QA_REVALIDATIONS
|
|
2442
|
+
|| !Array.isArray(reconciliation) || reconciliation.filter(item => item.authorization).length !== 1
|
|
2443
|
+
|| reconciliation.find(item => item.authorization)?.status !== 'authorized') {
|
|
2444
|
+
fail('PLANNING_CONTRACT_REVISION_HISTORY_REQUIRED', 'Contract revision requires the exact exhausted rejection, council revalidation and consumed reconciliation history.');
|
|
2445
|
+
}
|
|
2446
|
+
const held = active.entry.result;
|
|
2447
|
+
if (!held?.artifacts || held.execution_claim_id !== match.claimId
|
|
2448
|
+
|| held.execution_finished_at !== match.finishedAt
|
|
2449
|
+
|| canonicalSerialize(held.artifacts) !== canonicalSerialize(subject.plan.artifacts)
|
|
2450
|
+
|| !sameContentReviewBinding(active.binding, binding)
|
|
2451
|
+
|| !isPlanningQaOutcomeValid(agent.execution_qa_result, getQaThreshold('qa-planning', projectDir))
|
|
2452
|
+
|| !['score', 'gate_result', 'blocking_count'].every(key => held.qa_result?.[key] === agent.execution_qa_result?.[key])) {
|
|
2453
|
+
fail('PLANNING_CONTRACT_REVISION_HELD_QA_MISMATCH', 'Contract revision requires the exact held QA claim, result and council subject.');
|
|
2454
|
+
}
|
|
2455
|
+
const positive = readAttestedPlanningReconciliationDecision(projectDir, subject, active.entry.request,
|
|
2456
|
+
active.entry.authorization.decision_sha256);
|
|
2457
|
+
if (positive.record.decision.decision !== 'authorize-qa-revalidation'
|
|
2458
|
+
|| positive.record.decision.launch_nonce !== active.entry.launch_nonce) {
|
|
2459
|
+
fail('PLANNING_CONTRACT_REVISION_RECONCILIATION_UNTRUSTED',
|
|
2460
|
+
'Contract revision requires the exact immutable positive reconciliation and launch nonce.');
|
|
2461
|
+
}
|
|
2462
|
+
return { agent, held: canonicalize({ claim_id: match.claimId, finished_at: match.finishedAt,
|
|
2463
|
+
binding, qa_result: held.qa_result }), reconciliation: canonicalize({ request: active.entry.request,
|
|
2464
|
+
launch_nonce: active.entry.launch_nonce, decision_sha256: positive.sha256 }) };
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
/** Reserve one content-addressed controller direction before its host record is persisted. */
|
|
2468
|
+
export function reservePlanningContractRevision(projectDir, match = {}) {
|
|
2469
|
+
let subject;
|
|
2470
|
+
let outcome;
|
|
2471
|
+
try {
|
|
2472
|
+
const result = mutateSession(projectDir, session => {
|
|
2473
|
+
const direction = normalizePlanningContractDirection(match.direction);
|
|
2474
|
+
const { history, tip } = authenticatePlanningContractRevisionHistory(projectDir, session);
|
|
2475
|
+
if (!history.length && session.planning_contract_revision_subjects !== undefined
|
|
2476
|
+
&& (!Array.isArray(session.planning_contract_revision_subjects)
|
|
2477
|
+
|| session.planning_contract_revision_subjects.length)) {
|
|
2478
|
+
throw Object.assign(new Error('Planning contract revision subject history exists without its immutable direction.'),
|
|
2479
|
+
{ code: 'PLANNING_CONTRACT_REVISION_HISTORY_INVALID' });
|
|
2480
|
+
}
|
|
2481
|
+
const previous = history.find(item => item.request.source_gate_id === match.gateId);
|
|
2482
|
+
if (previous) {
|
|
2483
|
+
const same = previous.request?.source_gate_id === match.gateId
|
|
2484
|
+
&& previous.request?.source_held_qa?.claim_id === match.claimId
|
|
2485
|
+
&& previous.request?.source_held_qa?.finished_at === match.finishedAt
|
|
2486
|
+
&& canonicalSerialize(previous.request?.direction) === canonicalSerialize(direction)
|
|
2487
|
+
&& (previous.request.previous_revision?.revision_id ?? undefined) === match.previousRevisionId;
|
|
2488
|
+
if (!same) {
|
|
2489
|
+
throw Object.assign(new Error('Only one bounded Planning contract direction is allowed for this history.'), { code: 'PLANNING_CONTRACT_REVISION_LIMIT' });
|
|
2490
|
+
}
|
|
2491
|
+
if (previous !== tip) throw Object.assign(new Error('A superseded direction cannot be replayed as current authority.'),
|
|
2492
|
+
{ code: 'PLANNING_CONTRACT_REVISION_SUPERSEDED' });
|
|
2493
|
+
if (previous.status === 'reserved' && previous.request.schema_version === 3) {
|
|
2494
|
+
subject = pinPlanningContractRevision(projectDir, { ...match, direction }, session);
|
|
2495
|
+
if (canonicalSerialize(subject.request) !== canonicalSerialize(previous.request)) {
|
|
2496
|
+
throw Object.assign(new Error('The reserved normal correction source changed before retry.'),
|
|
2497
|
+
{ code: 'PLANNING_CONTRACT_REVISION_SUBJECT_CHANGED' });
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
outcome = { reservation: cloneSessionValue(previous), reused: true };
|
|
2501
|
+
return session;
|
|
2502
|
+
}
|
|
2503
|
+
if (history.length && match.previousRevisionId === undefined) {
|
|
2504
|
+
throw Object.assign(new Error('Automatic recovery is exhausted. A new controller direction requires the exact --previous-revision; no budget was renewed.'),
|
|
2505
|
+
{ code: 'PLANNING_CONTRACT_REVISION_LIMIT' });
|
|
2506
|
+
}
|
|
2507
|
+
if (match.previousRevisionId !== undefined && (!tip || !['active', 'completed'].includes(tip.status)
|
|
2508
|
+
|| tip.request.revision_id !== match.previousRevisionId
|
|
2509
|
+
|| tip.request.direction.instruction === direction.instruction
|
|
2510
|
+
|| history.some(item => item.request.source_held_qa.claim_id === match.claimId))) {
|
|
2511
|
+
throw Object.assign(new Error('Continuation requires a distinct explicit direction, a qualified tip and a previously unconsumed genuine QA result.'),
|
|
2512
|
+
{ code: 'PLANNING_CONTRACT_REVISION_PREDECESSOR_INVALID' });
|
|
2513
|
+
}
|
|
2514
|
+
subject = pinPlanningContractRevision(projectDir, { ...match, direction }, session);
|
|
2515
|
+
const reservation = { kind: 'planning_contract_revision', request: cloneSessionValue(subject.request),
|
|
2516
|
+
status: 'reserved', launch_nonce: randomUUID(), reserved_at: new Date().toISOString() };
|
|
2517
|
+
session.planning_contract_revisions = [...history, reservation];
|
|
2518
|
+
outcome = { reservation: cloneSessionValue(reservation), reused: false };
|
|
2519
|
+
return session;
|
|
2520
|
+
}, { beforeCommit: () => subject?.assertCurrent() });
|
|
2521
|
+
return { ...result, reserved: result.saved, ...(result.saved ? outcome : {}) };
|
|
2522
|
+
} finally { subject?.close(); }
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
/** Consume the immutable controller record and reopen real owners without resetting history. */
|
|
2526
|
+
export function settlePlanningContractRevision(projectDir, match = {}) {
|
|
2527
|
+
let subject;
|
|
2528
|
+
let outcome;
|
|
2529
|
+
try {
|
|
2530
|
+
const result = mutateSession(projectDir, session => {
|
|
2531
|
+
const lineage = authenticatePlanningContractRevisionHistory(projectDir, session);
|
|
2532
|
+
const entry = lineage.tip;
|
|
2533
|
+
if (!entry || entry.request?.revision_id !== match.revisionId
|
|
2534
|
+
|| entry.launch_nonce !== match.launchNonce
|
|
2535
|
+
|| entry.request.source_gate_id !== match.gateId
|
|
2536
|
+
|| entry.request.source_held_qa.claim_id !== match.claimId
|
|
2537
|
+
|| entry.request.source_held_qa.finished_at !== match.finishedAt
|
|
2538
|
+
|| (entry.request.previous_revision?.revision_id ?? undefined) !== match.previousRevisionId) {
|
|
2539
|
+
throw Object.assign(new Error('Settlement requires the exact reserved Planning contract direction.'), { code: 'PLANNING_CONTRACT_REVISION_RESERVATION_MISMATCH' });
|
|
2540
|
+
}
|
|
2541
|
+
if (['active', 'completed'].includes(entry.status)) {
|
|
2542
|
+
readAttestedPlanningContractRevision(projectDir, entry.request, entry.launch_nonce, entry.decision_sha256);
|
|
2543
|
+
outcome = { revision: cloneSessionValue(entry), reused: true };
|
|
2544
|
+
return session;
|
|
2545
|
+
}
|
|
2546
|
+
if (entry.status !== 'reserved') {
|
|
2547
|
+
throw Object.assign(new Error('Only the never-consumed Planning contract direction may settle.'), { code: 'PLANNING_CONTRACT_REVISION_RESERVATION_MISMATCH' });
|
|
2548
|
+
}
|
|
2549
|
+
subject = pinPlanningContractRevision(projectDir, { agent: 'qa-planning', claimId: match.claimId,
|
|
2550
|
+
finishedAt: match.finishedAt, gateId: match.gateId, direction: entry.request.direction,
|
|
2551
|
+
...(match.previousRevisionId === undefined ? {} : { previousRevisionId: match.previousRevisionId }) }, session);
|
|
2552
|
+
if (canonicalSerialize(subject.request) !== canonicalSerialize(entry.request)) {
|
|
2553
|
+
throw Object.assign(new Error('Planning contract revision subject changed before settlement.'), { code: 'PLANNING_CONTRACT_REVISION_SUBJECT_CHANGED' });
|
|
2554
|
+
}
|
|
2555
|
+
const decision = readAttestedPlanningContractRevision(projectDir, entry.request, entry.launch_nonce, match.decisionSha256);
|
|
2556
|
+
const affected = [...PLANNING_CONTRACT_REVISION_OWNERS];
|
|
2557
|
+
const previousAgents = Object.fromEntries(affected.map(name => [name, cloneSessionValue(session.agents[name])]));
|
|
2558
|
+
const previousResults = Object.fromEntries(affected.filter(name => session.agent_results?.[name])
|
|
2559
|
+
.map(name => [name, cloneSessionValue(session.agent_results[name])]));
|
|
2560
|
+
if ([2, 3].includes(entry.request.schema_version)) {
|
|
2561
|
+
const previous = lineage.history.at(-2);
|
|
2562
|
+
previous.status = 'superseded';
|
|
2563
|
+
previous.superseded_by = entry.request.revision_id;
|
|
2564
|
+
}
|
|
2565
|
+
entry.status = 'active';
|
|
2566
|
+
entry.decision_path = decision.path;
|
|
2567
|
+
entry.decision_sha256 = decision.sha256;
|
|
2568
|
+
entry.applied_at = new Date().toISOString();
|
|
2569
|
+
entry.owner_results = {};
|
|
2570
|
+
entry.archived = { previous_agents: previousAgents, previous_agent_results: previousResults,
|
|
2571
|
+
completed_agents: [...(session.completed_agents || [])], last_handoff: session.last_handoff || null,
|
|
2572
|
+
current_execution_binding: cloneSessionValue(session.current_execution_binding || null) };
|
|
2573
|
+
const reset = resetPipelineTo({ agents: structuredClone(session.agents),
|
|
2574
|
+
completedAgents: [...(session.completed_agents || [])], history: [] }, PLANNING_CONTRACT_REVISION_TARGET);
|
|
2575
|
+
for (const name of affected) {
|
|
2576
|
+
if (session.agent_results) delete session.agent_results[name];
|
|
2577
|
+
const agent = session.agents[name] || {};
|
|
2578
|
+
for (const key of Object.keys(agent)) {
|
|
2579
|
+
if (key.startsWith('execution_')) delete agent[key];
|
|
2580
|
+
}
|
|
2581
|
+
for (const key of ['started_at', 'completed_at', 'startedAt', 'completedAt',
|
|
2582
|
+
'council_revalidation_pending', 'planning_reconciliation_pending']) delete agent[key];
|
|
2583
|
+
agent.status = name === PLANNING_CONTRACT_REVISION_TARGET ? 'pending_rework' : reset.agents[name].status;
|
|
2584
|
+
agent.score = null;
|
|
2585
|
+
agent.criteria_count = 0;
|
|
2586
|
+
agent.content_review_pending = true;
|
|
2587
|
+
agent.content_review_task_id = `${name}-task`;
|
|
2588
|
+
agent.planning_contract_revision_id = entry.request.revision_id;
|
|
2589
|
+
session.agents[name] = agent;
|
|
2590
|
+
}
|
|
2591
|
+
session.completed_agents = reset.completedAgents;
|
|
2592
|
+
session.current_agent = PLANNING_CONTRACT_REVISION_TARGET;
|
|
2593
|
+
session.pipeline_position = reset.completedAgents.length;
|
|
2594
|
+
session.last_handoff = 'brief';
|
|
2595
|
+
// Persist the normalized empty binding so exact retries are byte-idempotent.
|
|
2596
|
+
if (entry.request.schema_version === 3) session.current_execution_binding = null;
|
|
2597
|
+
else delete session.current_execution_binding;
|
|
2598
|
+
session.deviations = [...(session.deviations || []), { type: 'rollback',
|
|
2599
|
+
details: { targetAgent: PLANNING_CONTRACT_REVISION_TARGET, source: 'planning_contract_revision',
|
|
2600
|
+
revision_id: entry.request.revision_id, source_gate_id: entry.request.source_gate_id },
|
|
2601
|
+
applied_at: entry.applied_at, changes: affected.map(name => `Reopen agent: ${name}`) }];
|
|
2602
|
+
outcome = { revision: cloneSessionValue(entry), reused: false };
|
|
2603
|
+
return session;
|
|
2604
|
+
}, { beforeCommit: () => subject?.assertCurrent() });
|
|
2605
|
+
return { ...result, settled: result.saved, ...(result.saved ? outcome : {}) };
|
|
2606
|
+
} finally { subject?.close(); }
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
/** Reject only unapproved complete Planning content, without inventing an execution failure. */
|
|
2610
|
+
export function rejectAgentResult(projectDir, match = {}) {
|
|
2611
|
+
let failureCode;
|
|
2612
|
+
const result = mutateSession(projectDir, session => {
|
|
2613
|
+
const fail = (code, message) => { failureCode = code; throw new Error(message); };
|
|
2614
|
+
const agent = assertHeldPlanningContent(projectDir, session, match, fail);
|
|
2615
|
+
if (typeof match.reason !== 'string' || !match.reason.trim()
|
|
2616
|
+
|| Buffer.byteLength(match.reason, 'utf8') > CONTENT_REVIEW_REASON_MAX_BYTES) {
|
|
2617
|
+
fail('CONTENT_REVIEW_REASON_INVALID', 'A nonempty content-review reason of at most 8192 UTF-8 bytes is required.');
|
|
2618
|
+
}
|
|
2619
|
+
const reviews = agent.content_rejections || [];
|
|
2620
|
+
if (!Array.isArray(reviews) || reviews.length >= MAX_CORRECTION_CYCLES) {
|
|
2621
|
+
fail('CONTENT_REJECTION_LIMIT', `At most ${MAX_CORRECTION_CYCLES} content rejections per agent are allowed; direction is required.`);
|
|
2622
|
+
}
|
|
2623
|
+
const execution = Object.fromEntries(Object.entries(agent).filter(([key]) => key.startsWith('execution_') || key === 'status' || key === 'score'));
|
|
2624
|
+
agent.content_rejections = [...reviews, { kind: 'content_rejection', reason: match.reason,
|
|
2625
|
+
rejected_at: new Date().toISOString(), binding: cloneSessionValue(match.binding), execution: cloneSessionValue(execution) }];
|
|
2626
|
+
agent.content_review_pending = true;
|
|
2627
|
+
if (agent.execution_task_id) agent.content_review_task_id = agent.execution_task_id;
|
|
2628
|
+
agent.status = 'pending_rework';
|
|
2629
|
+
for (const key of ['execution_claim_id', 'execution_claim_state', 'execution_claim_started_at',
|
|
2630
|
+
'execution_claim_expires_at', 'execution_started_at', 'execution_finished_at', 'execution_result_status',
|
|
2631
|
+
'execution_qa_result']) delete agent[key];
|
|
2632
|
+
return session;
|
|
2633
|
+
});
|
|
2634
|
+
return result.saved ? { ...result, rejected: true } : { ...result, rejected: false, code: failureCode || 'SESSION_WRITE_FAILED' };
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
/** Council-directed QA is a fresh execution, never a third content rejection. */
|
|
2638
|
+
export function revalidateQaAfterCouncil(projectDir, match = {}) {
|
|
2639
|
+
let proof;
|
|
2640
|
+
let outcome;
|
|
2641
|
+
try {
|
|
2642
|
+
const result = mutateSession(projectDir, session => {
|
|
2643
|
+
const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
|
|
2644
|
+
if (match.agent !== 'qa-planning') fail('COUNCIL_QA_AGENT_REQUIRED', 'Only QA-Planning can revalidate a Planning council.');
|
|
2645
|
+
proof = pinCouncilQaRevalidation(projectDir, match.gateId);
|
|
2646
|
+
const binding = qaInvocationBinding(proof.artifact);
|
|
2647
|
+
assertPlanningContentBoundary(projectDir, session, { ...match, binding }, fail);
|
|
2648
|
+
assertV2PlanningPredecessors(session, match.agent);
|
|
2649
|
+
const pending = session.agents?.[match.agent];
|
|
2650
|
+
const previous = pending?.council_revalidations?.at(-1);
|
|
2651
|
+
// A lost response can retrieve the same still-reserved command. It may
|
|
2652
|
+
// neither allocate a second claim nor resurrect one already started.
|
|
2653
|
+
if (pending?.council_revalidation_pending && pending.status === 'in_progress'
|
|
2654
|
+
&& pending.execution_claim_state === 'reserved' && Date.parse(pending.execution_claim_expires_at) > Date.now()
|
|
2655
|
+
&& previous?.execution?.execution_claim_id === match.claimId
|
|
2656
|
+
&& previous.execution.execution_finished_at === match.finishedAt
|
|
2657
|
+
&& previous.source_gate_id === match.gateId && previous.subject_gate_id === proof.evidence.subject_gate_id
|
|
2658
|
+
&& previous.fresh_claim_id === pending.execution_claim_id
|
|
2659
|
+
&& sameContentReviewBinding(binding, pending.execution_binding)
|
|
2660
|
+
&& sameContentReviewBinding(binding, session.current_execution_binding)) {
|
|
2661
|
+
outcome = { claimId: pending.execution_claim_id, binding, reused: true };
|
|
2662
|
+
return session;
|
|
2663
|
+
}
|
|
2664
|
+
const agent = assertHeldPlanningContent(projectDir, session, { ...match, binding }, fail);
|
|
2665
|
+
assertV2PlanningPredecessors(session, match.agent);
|
|
2666
|
+
if (agent.content_rejections?.length !== MAX_CORRECTION_CYCLES) {
|
|
2667
|
+
fail('COUNCIL_QA_CONTENT_LIMIT_REQUIRED', 'Use the existing content-rejection route before its finite limit is reached.');
|
|
2668
|
+
}
|
|
2669
|
+
if (!isPlanningQaOutcomeValid(agent.execution_qa_result, getQaThreshold(match.agent, projectDir))
|
|
2670
|
+
|| agent.execution_qa_result.gate_result !== 'approved') {
|
|
2671
|
+
fail('COUNCIL_QA_HELD_VERDICT_REQUIRED', 'Revalidation requires the genuine held QA approval, not an unresolved negative result.');
|
|
2672
|
+
}
|
|
2673
|
+
const history = agent.council_revalidations || [];
|
|
2674
|
+
if (!Array.isArray(history) || history.length >= MAX_COUNCIL_QA_REVALIDATIONS) {
|
|
2675
|
+
fail('COUNCIL_QA_REVALIDATION_LIMIT', 'Council-directed QA revalidation budget exhausted; adjudicate the remaining findings.');
|
|
2676
|
+
}
|
|
2677
|
+
if (history.some(item => item.source_gate_id === match.gateId
|
|
2678
|
+
|| item.execution?.execution_claim_id === match.claimId
|
|
2679
|
+
|| item.subject_gate_id === proof.evidence.subject_gate_id)) {
|
|
2680
|
+
fail('COUNCIL_QA_REVALIDATION_REPLAY', 'This council decision, held result or corrected subject was already consumed.');
|
|
2681
|
+
}
|
|
2682
|
+
const execution = Object.fromEntries(Object.entries(agent).filter(([key]) => key.startsWith('execution_') || key === 'status' || key === 'score'));
|
|
2683
|
+
const now = new Date();
|
|
2684
|
+
const claimId = randomUUID();
|
|
2685
|
+
agent.council_revalidations = [...history, { kind: 'council_qa_revalidation', ...proof.evidence,
|
|
2686
|
+
requested_at: now.toISOString(), binding, execution: cloneSessionValue(execution), fresh_claim_id: claimId }];
|
|
2687
|
+
agent.council_revalidation_pending = true;
|
|
2688
|
+
agent.content_review_pending = true;
|
|
2689
|
+
agent.content_review_task_id = 'qa-planning-task';
|
|
2690
|
+
agent.status = 'in_progress';
|
|
2691
|
+
agent.execution_resume_status = 'pending_rework';
|
|
2692
|
+
agent.execution_claim_id = claimId;
|
|
2693
|
+
agent.execution_claim_state = 'reserved';
|
|
2694
|
+
agent.execution_claim_started_at = now.toISOString();
|
|
2695
|
+
agent.execution_claim_expires_at = new Date(now.getTime() + 30 * 60 * 1000).toISOString();
|
|
2696
|
+
agent.execution_task_id = 'qa-planning-task';
|
|
2697
|
+
agent.execution_binding = binding;
|
|
2698
|
+
for (const key of ['execution_started_at', 'execution_finished_at', 'execution_result_status', 'execution_qa_result']) delete agent[key];
|
|
2699
|
+
outcome = { claimId, binding };
|
|
2700
|
+
return session;
|
|
2701
|
+
}, { beforeCommit: () => proof?.assertCurrent() });
|
|
2702
|
+
return { ...result, revalidated: result.saved, ...(result.saved ? outcome : {}) };
|
|
2703
|
+
} finally { proof?.close(); }
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
/** Negatively settle a legacy ambiguous hold. Never converts it to completion. */
|
|
2707
|
+
export function rejectLegacyAgentResult(projectDir, match = {}) {
|
|
2708
|
+
let outcome = { recovered: false, code: 'AGENT_RESULT_RECOVERY_MISMATCH',
|
|
2709
|
+
error: 'Recovery requires the exact unfinished legacy result identity.' };
|
|
2710
|
+
const result = mutateSession(projectDir, (session) => {
|
|
2711
|
+
const agent = session.agents?.[match.agent];
|
|
2712
|
+
if (!agent || agent.status !== 'awaiting_advance' || agent.execution_claim_state !== 'awaiting_advance'
|
|
2713
|
+
|| agent.execution_result_status !== undefined || session.completed_agents?.includes(match.agent)
|
|
2714
|
+
|| typeof match.finishedAt !== 'string' || !Number.isFinite(Date.parse(match.finishedAt))
|
|
2715
|
+
|| agent.execution_finished_at !== match.finishedAt) return session;
|
|
2716
|
+
agent.execution_failure = { status: 'error', reason: 'legacy_ambiguous_result_rejected',
|
|
2717
|
+
execution_finished_at: agent.execution_finished_at, rejected_at: new Date().toISOString() };
|
|
2718
|
+
agent.status = 'pending';
|
|
2719
|
+
for (const key of ['execution_claim_id', 'execution_claim_state', 'execution_claim_started_at',
|
|
2720
|
+
'execution_claim_expires_at', 'execution_started_at', 'execution_finished_at']) delete agent[key];
|
|
2721
|
+
outcome = { recovered: true };
|
|
2722
|
+
return session;
|
|
1474
2723
|
});
|
|
1475
2724
|
return { ...result, ...outcome };
|
|
1476
2725
|
}
|
|
@@ -1484,12 +2733,17 @@ export function releaseAgentExecution(projectDir, match = {}) {
|
|
|
1484
2733
|
const agent = session.agents?.[match.agent];
|
|
1485
2734
|
if (!agent || agent.execution_claim_id !== match.claimId) return session;
|
|
1486
2735
|
if (agent.execution_claim_state === 'awaiting_advance' || agent.status === 'awaiting_advance') return session;
|
|
1487
|
-
if (agent.status === 'in_progress') agent.status =
|
|
2736
|
+
if (agent.status === 'in_progress') agent.status = agent.content_review_pending
|
|
2737
|
+
|| agent.execution_resume_status === 'pending_rework' ? 'pending_rework' : 'pending';
|
|
2738
|
+
delete agent.execution_resume_status;
|
|
1488
2739
|
delete agent.execution_claim_id;
|
|
1489
2740
|
delete agent.execution_claim_state;
|
|
1490
2741
|
delete agent.execution_claim_started_at;
|
|
1491
2742
|
delete agent.execution_claim_expires_at;
|
|
1492
2743
|
delete agent.execution_started_at;
|
|
2744
|
+
delete agent.execution_qa_report_baseline;
|
|
2745
|
+
delete agent.execution_planning_revision_baseline;
|
|
2746
|
+
delete agent.execution_planning_revision_relay_claim_id;
|
|
1493
2747
|
released = true;
|
|
1494
2748
|
return session;
|
|
1495
2749
|
});
|
|
@@ -1504,7 +2758,12 @@ export function releaseReservedAgentExecutions(projectDir) {
|
|
|
1504
2758
|
const result = mutateSession(sessionProjectDir, (session) => {
|
|
1505
2759
|
for (const agent of Object.values(session.agents || {})) {
|
|
1506
2760
|
if (agent.execution_claim_state !== 'reserved') continue;
|
|
1507
|
-
if (agent.status === 'in_progress') agent.status =
|
|
2761
|
+
if (agent.status === 'in_progress') agent.status = agent.content_review_pending
|
|
2762
|
+
|| agent.execution_resume_status === 'pending_rework' ? 'pending_rework' : 'pending';
|
|
2763
|
+
delete agent.execution_resume_status;
|
|
2764
|
+
delete agent.execution_qa_report_baseline;
|
|
2765
|
+
delete agent.execution_planning_revision_baseline;
|
|
2766
|
+
delete agent.execution_planning_revision_relay_claim_id;
|
|
1508
2767
|
delete agent.execution_claim_id;
|
|
1509
2768
|
delete agent.execution_claim_state;
|
|
1510
2769
|
delete agent.execution_claim_started_at;
|
|
@@ -1550,6 +2809,8 @@ export function claimPendingInteraction(projectDir, claim = {}) {
|
|
|
1550
2809
|
const sessionProjectDir = findSessionProjectDir(projectDir);
|
|
1551
2810
|
if (!sessionProjectDir) return { saved: false, claimed: false, error: 'Session file not found in working directory or its parents' };
|
|
1552
2811
|
let outcome = { allowed: true, claimed: false };
|
|
2812
|
+
let subject;
|
|
2813
|
+
try {
|
|
1553
2814
|
const result = mutateSession(sessionProjectDir, (session) => {
|
|
1554
2815
|
const pending = session.pending_interaction;
|
|
1555
2816
|
if (!pending) {
|
|
@@ -1583,8 +2844,8 @@ export function claimPendingInteraction(projectDir, claim = {}) {
|
|
|
1583
2844
|
};
|
|
1584
2845
|
return session;
|
|
1585
2846
|
}
|
|
1586
|
-
const response = typeof claim.response === 'string' ? claim.response
|
|
1587
|
-
if (!response) {
|
|
2847
|
+
const response = typeof claim.response === 'string' ? claim.response : '';
|
|
2848
|
+
if (!response.trim()) {
|
|
1588
2849
|
outcome = {
|
|
1589
2850
|
allowed: false, claimed: false, code: 'PENDING_INTERACTION_REQUIRES_RESPONSE',
|
|
1590
2851
|
error: `Agent ${pending.agent} is waiting for the user's response.`,
|
|
@@ -1615,19 +2876,45 @@ export function claimPendingInteraction(projectDir, claim = {}) {
|
|
|
1615
2876
|
};
|
|
1616
2877
|
return session;
|
|
1617
2878
|
}
|
|
2879
|
+
const history = pending.history || [];
|
|
2880
|
+
const responseKind = reasoningEscalation ? 'controller_instruction' : 'user_answer';
|
|
2881
|
+
validateRelayHistory([...history, {
|
|
2882
|
+
interaction_id: pending.interaction_id, kind: responseKind,
|
|
2883
|
+
...(reasoningEscalation ? {} : { question: pending.question }), response,
|
|
2884
|
+
}], pending.question, pending.initial_task_context);
|
|
1618
2885
|
const claimId = randomUUID();
|
|
2886
|
+
const contentReviewContext = claimContentReviewContext(projectDir, session, {
|
|
2887
|
+
agent: pending.agent, taskId: pending.task_id, claimId, binding,
|
|
2888
|
+
});
|
|
2889
|
+
if (hasPlanningQaRecovery(session.agents?.[pending.agent], pending.agent)) {
|
|
2890
|
+
assertPlanningQaReportBaseline(session.agents[pending.agent], { pending, binding, taskId: pending.task_id });
|
|
2891
|
+
subject = pinClaimedCouncilQa(sessionProjectDir, session, pending.agent, { allowOwnInteraction: true, excludeQaReport: true });
|
|
2892
|
+
}
|
|
2893
|
+
if (session.agents?.[pending.agent]?.planning_contract_revision_id) {
|
|
2894
|
+
const baseline = assertPlanningContractRevisionExecutionBaseline(session.agents[pending.agent], { pending, binding, taskId: pending.task_id });
|
|
2895
|
+
contentReviewContext.planning_contract_revision.execution_subject = cloneSessionValue(baseline.subject_artifacts);
|
|
2896
|
+
}
|
|
1619
2897
|
session.pending_interaction = {
|
|
1620
2898
|
...pending,
|
|
1621
2899
|
status: 'relay_in_flight',
|
|
1622
2900
|
...(reasoningEscalation ? { escalation_execution_binding: binding } : {}),
|
|
1623
2901
|
relay_claim_id: claimId,
|
|
2902
|
+
relay_response: response,
|
|
2903
|
+
relay_response_kind: responseKind,
|
|
2904
|
+
relay_next_task_id: reasoningEscalation ? `${pending.task_id}-post-escalation` : pending.task_id,
|
|
1624
2905
|
relay_started_at: claim.timestamp || new Date().toISOString(),
|
|
1625
2906
|
relay_lease_expires_at: claim.leaseExpiresAt || null,
|
|
1626
2907
|
};
|
|
1627
|
-
outcome = { allowed: true, claimed: true, claimId
|
|
2908
|
+
outcome = { allowed: true, claimed: true, claimId, contentReviewContext, relayContext: {
|
|
2909
|
+
agent: pending.agent, taskId: pending.task_id, interactionId: pending.interaction_id,
|
|
2910
|
+
binding, history, historyComplete: pending.history_complete === true,
|
|
2911
|
+
initialTaskContext: pending.initial_task_context,
|
|
2912
|
+
question: pending.question, response, responseKind,
|
|
2913
|
+
} };
|
|
1628
2914
|
return session;
|
|
1629
|
-
});
|
|
1630
|
-
return { ...result, ...outcome };
|
|
2915
|
+
}, { beforeCommit: () => subject?.assertCurrent() });
|
|
2916
|
+
return { ...result, ...outcome, claimed: result.saved && outcome.claimed };
|
|
2917
|
+
} finally { subject?.close(); }
|
|
1631
2918
|
}
|
|
1632
2919
|
|
|
1633
2920
|
/** Restore a claimed relay after a controlled provider or contract failure. */
|
|
@@ -1654,6 +2941,10 @@ export function releasePendingInteraction(projectDir, match = {}) {
|
|
|
1654
2941
|
delete session.pending_interaction.relay_claim_id;
|
|
1655
2942
|
delete session.pending_interaction.relay_started_at;
|
|
1656
2943
|
delete session.pending_interaction.relay_lease_expires_at;
|
|
2944
|
+
delete session.pending_interaction.relay_response;
|
|
2945
|
+
delete session.pending_interaction.relay_response_kind;
|
|
2946
|
+
delete session.pending_interaction.relay_next_task_id;
|
|
2947
|
+
delete session.pending_interaction.escalation_execution_binding;
|
|
1657
2948
|
released = true;
|
|
1658
2949
|
settled = true;
|
|
1659
2950
|
return session;
|
|
@@ -1668,6 +2959,12 @@ export function clearPendingInteraction(projectDir, match = {}) {
|
|
|
1668
2959
|
let cleared = false;
|
|
1669
2960
|
const result = mutateSession(sessionProjectDir, (session) => {
|
|
1670
2961
|
const pending = session.pending_interaction;
|
|
2962
|
+
if (match.requireClaim && (!pending || pending.status !== 'relay_in_flight'
|
|
2963
|
+
|| !match.claimId || pending.relay_claim_id !== match.claimId
|
|
2964
|
+
|| pending.interaction_id !== match.interactionId
|
|
2965
|
+
|| pending.agent !== match.agent || pending.task_id !== match.taskId)) {
|
|
2966
|
+
throw new Error('Pending interaction completion rejected: relay ownership mismatch');
|
|
2967
|
+
}
|
|
1671
2968
|
if (!pending) return session;
|
|
1672
2969
|
if (match.agent && pending.agent !== match.agent) return session;
|
|
1673
2970
|
if (match.taskId && pending.task_id !== match.taskId) return session;
|
|
@@ -1710,13 +3007,65 @@ export function recordModeTransition(projectDir, transition) {
|
|
|
1710
3007
|
* @param {string} projectDir
|
|
1711
3008
|
* @param {object} completion - { agent, status, score, outputs, handoffData }
|
|
1712
3009
|
* handoffData may include:
|
|
1713
|
-
* - decision_trail_entries:
|
|
3010
|
+
* - decision_trail_entries: new findings or compatible repeated findings
|
|
1714
3011
|
* - resolved_decision_trail: array of DT entry IDs to mark resolved: true
|
|
1715
3012
|
* @returns {{ saved: boolean }}
|
|
1716
3013
|
*/
|
|
1717
3014
|
export function recordAgentCompletion(projectDir, completion) {
|
|
1718
3015
|
const { agent, status, score, outputs = [], handoffData = {} } = completion;
|
|
1719
|
-
|
|
3016
|
+
let failureCode;
|
|
3017
|
+
let subject;
|
|
3018
|
+
let revisionOutput;
|
|
3019
|
+
let revisionHeldResult;
|
|
3020
|
+
try {
|
|
3021
|
+
const result = mutateSession(projectDir, (session) => {
|
|
3022
|
+
// Consume the SAME hold validated before any async artifact/council checks.
|
|
3023
|
+
// A concurrent content rejection must win without later DT merges or events.
|
|
3024
|
+
const expected = completion.expectedResult;
|
|
3025
|
+
const held = session.agents?.[agent];
|
|
3026
|
+
assertPlanningContractRevisionOwner(session, [agent], projectDir);
|
|
3027
|
+
if (expected && (held?.status !== 'awaiting_advance' || held.execution_claim_state !== 'awaiting_advance'
|
|
3028
|
+
|| held.execution_result_status !== 'complete' || held.execution_claim_id !== expected.claimId
|
|
3029
|
+
|| held.execution_finished_at !== expected.finishedAt || session.completed_agents?.includes(agent))) {
|
|
3030
|
+
failureCode = 'AGENT_RESULT_HOLD_CHANGED';
|
|
3031
|
+
throw new Error('The complete result changed or was consumed before canonical advance.');
|
|
3032
|
+
}
|
|
3033
|
+
if (agent === 'qa-planning') subject = pinHeldCouncilQaSubject(projectDir, session);
|
|
3034
|
+
const revision = held?.planning_contract_revision_id
|
|
3035
|
+
? session.planning_contract_revisions?.find(item => item.request?.revision_id === held.planning_contract_revision_id)
|
|
3036
|
+
: null;
|
|
3037
|
+
if (revision) {
|
|
3038
|
+
if (!expected || status !== 'completed' || revision.status !== 'active'
|
|
3039
|
+
|| planningContractRevisionHistory(session).tip !== revision) {
|
|
3040
|
+
failureCode = 'PLANNING_CONTRACT_REVISION_CONTEXT_INVALID';
|
|
3041
|
+
throw new Error('Planning owner completion requires its exact held claim and the single active contract revision.');
|
|
3042
|
+
}
|
|
3043
|
+
const relayClaimId = held.execution_planning_revision_relay_claim_id;
|
|
3044
|
+
if (relayClaimId && relayClaimId !== expected.claimId) {
|
|
3045
|
+
failureCode = 'PLANNING_CONTRACT_REVISION_EXECUTION_BASELINE_INVALID';
|
|
3046
|
+
throw new Error('Planning owner completion does not belong to the recorded relay lineage.');
|
|
3047
|
+
}
|
|
3048
|
+
const attemptBaseline = assertPlanningContractRevisionExecutionBaseline(held, {
|
|
3049
|
+
...(relayClaimId
|
|
3050
|
+
? { pending: { planning_revision_baseline: held.execution_planning_revision_baseline } }
|
|
3051
|
+
: { claimId: expected.claimId }),
|
|
3052
|
+
binding: held.execution_binding, taskId: held.execution_task_id,
|
|
3053
|
+
});
|
|
3054
|
+
revisionOutput = pinPlanningContractRevisionOwnerOutput(projectDir, revision, agent, attemptBaseline);
|
|
3055
|
+
revisionHeldResult = cloneSessionValue(held.execution_planning_revision_result);
|
|
3056
|
+
if (!revisionHeldResult || revisionHeldResult.execution_claim_id !== expected.claimId
|
|
3057
|
+
|| revisionHeldResult.execution_finished_at !== expected.finishedAt
|
|
3058
|
+
|| canonicalSerialize(revisionHeldResult.subject) !== canonicalSerialize({ gate_id: revisionOutput.plan.gate_id,
|
|
3059
|
+
artifacts: revisionOutput.plan.artifacts })
|
|
3060
|
+
|| canonicalSerialize(revisionHeldResult.owned_artifacts) !== canonicalSerialize(revisionOutput.owned_artifacts)
|
|
3061
|
+
|| canonicalSerialize(revisionHeldResult.handoff) !== canonicalSerialize(revisionOutput.handoff)) {
|
|
3062
|
+
failureCode = 'PLANNING_CONTRACT_REVISION_HELD_OUTPUT_CHANGED';
|
|
3063
|
+
throw new Error('Planning owner artifacts or handoff changed after the genuine execution was held.');
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
const heldExecution = revision ? { execution_claim_id: held.execution_claim_id,
|
|
3067
|
+
execution_finished_at: held.execution_finished_at,
|
|
3068
|
+
execution_binding: cloneSessionValue(held.execution_binding || null) } : null;
|
|
1720
3069
|
|
|
1721
3070
|
// Update agent data
|
|
1722
3071
|
if (!session.agents[agent]) {
|
|
@@ -1735,8 +3084,19 @@ export function recordAgentCompletion(projectDir, completion) {
|
|
|
1735
3084
|
delete session.agents[agent].execution_claim_state;
|
|
1736
3085
|
delete session.agents[agent].execution_claim_started_at;
|
|
1737
3086
|
delete session.agents[agent].execution_claim_expires_at;
|
|
3087
|
+
delete session.agents[agent].execution_resume_status;
|
|
1738
3088
|
delete session.agents[agent].execution_started_at;
|
|
1739
3089
|
delete session.agents[agent].execution_finished_at;
|
|
3090
|
+
delete session.agents[agent].execution_result_status;
|
|
3091
|
+
delete session.agents[agent].content_review_pending;
|
|
3092
|
+
delete session.agents[agent].content_review_task_id;
|
|
3093
|
+
delete session.agents[agent].council_revalidation_pending;
|
|
3094
|
+
delete session.agents[agent].planning_reconciliation_pending;
|
|
3095
|
+
delete session.agents[agent].execution_qa_report_baseline;
|
|
3096
|
+
delete session.agents[agent].execution_planning_revision_baseline;
|
|
3097
|
+
delete session.agents[agent].execution_planning_revision_relay_claim_id;
|
|
3098
|
+
delete session.agents[agent].execution_planning_revision_result;
|
|
3099
|
+
delete session.agents[agent].planning_contract_revision_id;
|
|
1740
3100
|
|
|
1741
3101
|
// Add to completed agents list
|
|
1742
3102
|
session.completed_agents = session.completed_agents || [];
|
|
@@ -1751,7 +3111,18 @@ export function recordAgentCompletion(projectDir, completion) {
|
|
|
1751
3111
|
score,
|
|
1752
3112
|
outputs,
|
|
1753
3113
|
completed_at: session.agents[agent].completed_at,
|
|
3114
|
+
...(session.agents[agent].execution_qa_result ? { qa_result: session.agents[agent].execution_qa_result } : {}),
|
|
1754
3115
|
};
|
|
3116
|
+
if (revision) {
|
|
3117
|
+
revision.owner_results[agent] = { ...heldExecution,
|
|
3118
|
+
subject: cloneSessionValue(revisionHeldResult.subject), owned_artifacts: cloneSessionValue(revisionHeldResult.owned_artifacts),
|
|
3119
|
+
handoff: cloneSessionValue(revisionHeldResult.handoff), completed_at: session.agents[agent].completed_at };
|
|
3120
|
+
if (agent === 'qa-planning') {
|
|
3121
|
+
revision.status = 'completed';
|
|
3122
|
+
revision.completed_at = session.agents[agent].completed_at;
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
delete session.agents[agent].execution_qa_result;
|
|
1755
3126
|
|
|
1756
3127
|
// Update last handoff
|
|
1757
3128
|
session.last_handoff = agent;
|
|
@@ -1761,10 +3132,20 @@ export function recordAgentCompletion(projectDir, completion) {
|
|
|
1761
3132
|
// document instead of directly editing session.yaml (which is blocked by the
|
|
1762
3133
|
// mode-governance hook). The CLI reads them here and merges into session.yaml.
|
|
1763
3134
|
if (Array.isArray(handoffData.decision_trail_entries) && handoffData.decision_trail_entries.length > 0) {
|
|
1764
|
-
session.decision_trail
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
3135
|
+
const trail = [...(session.decision_trail || [])];
|
|
3136
|
+
for (const incoming of handoffData.decision_trail_entries) {
|
|
3137
|
+
const existing = trail.find(entry => entry.id === incoming.id);
|
|
3138
|
+
if (!existing) { trail.push(incoming); continue; }
|
|
3139
|
+
// A negative recheck must keep its open finding for QA/FVP routing, but
|
|
3140
|
+
// recurrence is not a new identity and must not overwrite its history.
|
|
3141
|
+
// In particular, do not silently reopen or resolve a prior record.
|
|
3142
|
+
if (existing.fault_origin !== incoming.fault_origin
|
|
3143
|
+
|| (existing.resolved === true) !== (incoming.resolved === true)) {
|
|
3144
|
+
failureCode = 'DECISION_TRAIL_ID_CONFLICT';
|
|
3145
|
+
throw new Error(`Decision Trail identity ${incoming.id} conflicts with its canonical origin or resolution.`);
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
session.decision_trail = trail;
|
|
1768
3149
|
}
|
|
1769
3150
|
|
|
1770
3151
|
// Mark resolved Decision Trail entries from agent handoff document.
|
|
@@ -1803,7 +3184,9 @@ export function recordAgentCompletion(projectDir, completion) {
|
|
|
1803
3184
|
}
|
|
1804
3185
|
|
|
1805
3186
|
return session;
|
|
1806
|
-
});
|
|
3187
|
+
}, { beforeCommit: () => { subject?.assertCurrent(); revisionOutput?.assertCurrent(); } });
|
|
3188
|
+
return result.saved ? result : { ...result, code: failureCode || result.code || 'SESSION_WRITE_FAILED' };
|
|
3189
|
+
} finally { subject?.close(); revisionOutput?.close(); }
|
|
1807
3190
|
}
|
|
1808
3191
|
|
|
1809
3192
|
/**
|
|
@@ -1824,6 +3207,41 @@ export function parseHandoffDecisionTrail(handoffMarkdown) {
|
|
|
1824
3207
|
return { decision_trail_entries, resolved_decision_trail };
|
|
1825
3208
|
}
|
|
1826
3209
|
|
|
3210
|
+
const invalid = message => Object.assign(new Error(message), { code: 'INVALID_HANDOFF_DECISION_TRAIL' });
|
|
3211
|
+
// Read the structured field, not an incidental heading chosen by a model.
|
|
3212
|
+
// The normal writer contract nests this YAML inside Summary/Outputs/Deep Context.
|
|
3213
|
+
for (const match of handoffMarkdown.matchAll(/^```(?:yaml|yml)\s*\r?\n([\s\S]*?)^```\s*$/gm)) {
|
|
3214
|
+
if (!/^(?:decision_trail|resolved_decision_trail):/m.test(match[1])) continue;
|
|
3215
|
+
let document;
|
|
3216
|
+
try { document = yaml.load(match[1], { schema: yaml.JSON_SCHEMA }); }
|
|
3217
|
+
catch (error) { throw invalid(`Malformed Decision Trail YAML: ${error.message}`); }
|
|
3218
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)) throw invalid('Decision Trail requires a mapping.');
|
|
3219
|
+
if (Object.hasOwn(document, 'decision_trail')) {
|
|
3220
|
+
const entries = document.decision_trail;
|
|
3221
|
+
if (!Array.isArray(entries) || entries.length > 256) throw invalid('decision_trail must be a bounded array.');
|
|
3222
|
+
for (const entry of entries) {
|
|
3223
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)
|
|
3224
|
+
|| typeof entry.id !== 'string' || !/^DT-[A-Za-z0-9][A-Za-z0-9-]*$/.test(entry.id)
|
|
3225
|
+
|| !['INTENT', 'SPEC', 'CODE', 'DEFER', 'UNKNOWN'].includes(entry.fault_origin)
|
|
3226
|
+
|| typeof entry.resolved !== 'boolean') throw invalid('Decision Trail entry identity, fault_origin, or resolved type is invalid.');
|
|
3227
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
3228
|
+
if (['id', 'fault_origin', 'resolved'].includes(key)) continue;
|
|
3229
|
+
if (key === 'correction_loop') {
|
|
3230
|
+
if (!Number.isSafeInteger(value) || value < 0) throw invalid('correction_loop must be a nonnegative integer.');
|
|
3231
|
+
} else if (typeof value !== 'string') throw invalid(`Decision Trail ${key} must be a string.`);
|
|
3232
|
+
}
|
|
3233
|
+
decision_trail_entries.push(entry);
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
if (Object.hasOwn(document, 'resolved_decision_trail')) {
|
|
3237
|
+
if (!Array.isArray(document.resolved_decision_trail)
|
|
3238
|
+
|| !document.resolved_decision_trail.every(id => typeof id === 'string' && /^DT-[A-Za-z0-9][A-Za-z0-9-]*$/.test(id))) {
|
|
3239
|
+
throw invalid('resolved_decision_trail must contain Decision Trail IDs.');
|
|
3240
|
+
}
|
|
3241
|
+
resolved_decision_trail.push(...document.resolved_decision_trail);
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
|
|
1827
3245
|
// Extract ## Decision Trail section (stop at next ## heading or end of input).
|
|
1828
3246
|
// Note: JS regex does not support \z (end-of-input anchor). Using $(?![\s\S])
|
|
1829
3247
|
// — an $ followed by negative lookahead "no more chars" — gives the same
|
|
@@ -1833,7 +3251,10 @@ export function parseHandoffDecisionTrail(handoffMarkdown) {
|
|
|
1833
3251
|
/^## Decision Trail\s*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m,
|
|
1834
3252
|
);
|
|
1835
3253
|
if (dtMatch) {
|
|
1836
|
-
|
|
3254
|
+
// Structured fences were consumed above, including when placed under the
|
|
3255
|
+
// legacy heading. Do not parse those same entries a second time.
|
|
3256
|
+
const block = dtMatch[1].replace(/^```(?:yaml|yml)\s*\r?\n([\s\S]*?)^```\s*$/gm,
|
|
3257
|
+
(fence, body) => /^(?:decision_trail|resolved_decision_trail):/m.test(body) ? '' : fence);
|
|
1837
3258
|
// Parse YAML-like bullet blocks: lines starting with " id: DT-"
|
|
1838
3259
|
// Split on entries that start a new id: field
|
|
1839
3260
|
const entryBlocks = block.split(/(?=^\s*-?\s*id:\s*DT-)/m).filter(Boolean);
|
|
@@ -1877,6 +3298,13 @@ export function parseHandoffDecisionTrail(handoffMarkdown) {
|
|
|
1877
3298
|
}
|
|
1878
3299
|
}
|
|
1879
3300
|
|
|
3301
|
+
const ids = decision_trail_entries.map(entry => entry.id);
|
|
3302
|
+
if (new Set(ids).size !== ids.length || new Set(resolved_decision_trail).size !== resolved_decision_trail.length) {
|
|
3303
|
+
throw invalid('Duplicate Decision Trail IDs are ambiguous.');
|
|
3304
|
+
}
|
|
3305
|
+
if (decision_trail_entries.some(entry => entry.resolved === false && resolved_decision_trail.includes(entry.id))) {
|
|
3306
|
+
throw invalid('A Decision Trail entry cannot be both unresolved and resolved in the same handoff.');
|
|
3307
|
+
}
|
|
1880
3308
|
return { decision_trail_entries, resolved_decision_trail };
|
|
1881
3309
|
}
|
|
1882
3310
|
|