specrails-core 6.3.0 → 6.5.0
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/dist/agent-runtime/capabilities.js +22 -5
- package/dist/agent-runtime/cli-executor.js +8 -3
- package/dist/agent-runtime/cli.d.ts +1 -1
- package/dist/agent-runtime/cli.js +50 -3
- package/dist/agent-runtime/compact/environment.d.ts +49 -4
- package/dist/agent-runtime/compact/environment.js +191 -8
- package/dist/agent-runtime/config.js +68 -53
- package/dist/agent-runtime/engine/compiler.js +10 -2
- package/dist/agent-runtime/engine/contracts.d.ts +16 -0
- package/dist/agent-runtime/engine/pieces/control.js +11 -2
- package/dist/agent-runtime/engine/pieces/role-turn.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/role-turn.js +69 -3
- package/dist/agent-runtime/engine/pieces/verify.d.ts +10 -3
- package/dist/agent-runtime/engine/pieces/verify.js +105 -30
- package/dist/agent-runtime/executor-types.d.ts +2 -0
- package/dist/agent-runtime/graph/nodes.js +9 -55
- package/dist/agent-runtime/prompts.d.ts +1 -1
- package/dist/agent-runtime/prompts.js +18 -12
- package/dist/agent-runtime/session/application/active-session.d.ts +176 -0
- package/dist/agent-runtime/session/application/active-session.js +701 -0
- package/dist/agent-runtime/session/application/delegation.d.ts +73 -0
- package/dist/agent-runtime/session/application/delegation.js +171 -0
- package/dist/agent-runtime/session/application/output-buffer.d.ts +43 -0
- package/dist/agent-runtime/session/application/output-buffer.js +65 -0
- package/dist/agent-runtime/session/application/session-service.d.ts +121 -0
- package/dist/agent-runtime/session/application/session-service.js +256 -0
- package/dist/agent-runtime/session/application/timers.d.ts +15 -0
- package/dist/agent-runtime/session/application/timers.js +35 -0
- package/dist/agent-runtime/session/domain/descriptor.d.ts +6 -0
- package/dist/agent-runtime/session/domain/descriptor.js +32 -0
- package/dist/agent-runtime/session/domain/errors.d.ts +9 -0
- package/dist/agent-runtime/session/domain/errors.js +16 -0
- package/dist/agent-runtime/session/domain/events.d.ts +147 -0
- package/dist/agent-runtime/session/domain/events.js +24 -0
- package/dist/agent-runtime/session/domain/fingerprint.d.ts +2 -0
- package/dist/agent-runtime/session/domain/fingerprint.js +10 -0
- package/dist/agent-runtime/session/domain/interruption.d.ts +13 -0
- package/dist/agent-runtime/session/domain/interruption.js +39 -0
- package/dist/agent-runtime/session/domain/policy.d.ts +42 -0
- package/dist/agent-runtime/session/domain/policy.js +104 -0
- package/dist/agent-runtime/session/domain/snapshot.d.ts +100 -0
- package/dist/agent-runtime/session/domain/snapshot.js +234 -0
- package/dist/agent-runtime/session/domain/state-machines.d.ts +21 -0
- package/dist/agent-runtime/session/domain/state-machines.js +41 -0
- package/dist/agent-runtime/session/domain/types.d.ts +130 -0
- package/dist/agent-runtime/session/domain/types.js +15 -0
- package/dist/agent-runtime/session/domain/usage.d.ts +54 -0
- package/dist/agent-runtime/session/domain/usage.js +86 -0
- package/dist/agent-runtime/session/drivers/claude/argv.d.ts +19 -0
- package/dist/agent-runtime/session/drivers/claude/argv.js +67 -0
- package/dist/agent-runtime/session/drivers/claude/driver.d.ts +19 -0
- package/dist/agent-runtime/session/drivers/claude/driver.js +134 -0
- package/dist/agent-runtime/session/drivers/claude/translator.d.ts +40 -0
- package/dist/agent-runtime/session/drivers/claude/translator.js +247 -0
- package/dist/agent-runtime/session/drivers/codex/argv.d.ts +12 -0
- package/dist/agent-runtime/session/drivers/codex/argv.js +76 -0
- package/dist/agent-runtime/session/drivers/codex/driver.d.ts +19 -0
- package/dist/agent-runtime/session/drivers/codex/driver.js +193 -0
- package/dist/agent-runtime/session/drivers/codex/rpc.d.ts +28 -0
- package/dist/agent-runtime/session/drivers/codex/rpc.js +81 -0
- package/dist/agent-runtime/session/drivers/codex/translator.d.ts +53 -0
- package/dist/agent-runtime/session/drivers/codex/translator.js +267 -0
- package/dist/agent-runtime/session/drivers/common/process.d.ts +37 -0
- package/dist/agent-runtime/session/drivers/common/process.js +116 -0
- package/dist/agent-runtime/session/drivers/executor/driver.d.ts +22 -0
- package/dist/agent-runtime/session/drivers/executor/driver.js +163 -0
- package/dist/agent-runtime/session/drivers/registry.d.ts +32 -0
- package/dist/agent-runtime/session/drivers/registry.js +38 -0
- package/dist/agent-runtime/session/host/protocol.d.ts +18 -0
- package/dist/agent-runtime/session/host/protocol.js +88 -0
- package/dist/agent-runtime/session/host/server.d.ts +57 -0
- package/dist/agent-runtime/session/host/server.js +229 -0
- package/dist/agent-runtime/session/index.d.ts +45 -0
- package/dist/agent-runtime/session/index.js +74 -0
- package/dist/agent-runtime/session/journal/schema.d.ts +7 -0
- package/dist/agent-runtime/session/journal/schema.js +44 -0
- package/dist/agent-runtime/session/journal/sqlite-journal.d.ts +70 -0
- package/dist/agent-runtime/session/journal/sqlite-journal.js +279 -0
- package/dist/agent-runtime/session/ports.d.ts +192 -0
- package/dist/agent-runtime/session/ports.js +1 -0
- package/dist/agent-runtime/session/testing/driver-conformance.d.ts +21 -0
- package/dist/agent-runtime/session/testing/driver-conformance.js +97 -0
- package/dist/agent-runtime/session/testing/fake-clock.d.ts +20 -0
- package/dist/agent-runtime/session/testing/fake-clock.js +34 -0
- package/dist/agent-runtime/session/testing/fixture-replayer.d.ts +41 -0
- package/dist/agent-runtime/session/testing/fixture-replayer.js +172 -0
- package/dist/agent-runtime/session/testing/index.d.ts +9 -0
- package/dist/agent-runtime/session/testing/index.js +9 -0
- package/dist/agent-runtime/session/testing/memory-journal.d.ts +27 -0
- package/dist/agent-runtime/session/testing/memory-journal.js +70 -0
- package/dist/agent-runtime/session/testing/scripted-driver.d.ts +30 -0
- package/dist/agent-runtime/session/testing/scripted-driver.js +77 -0
- package/dist/agent-runtime/verification-proposals.d.ts +9 -0
- package/dist/agent-runtime/verification-proposals.js +69 -0
- package/dist/agent-runtime/verification-repair.d.ts +26 -0
- package/dist/agent-runtime/verification-repair.js +88 -0
- package/dist/installer/util/registry.js +3 -3
- package/dist/shared/specrails-home.d.ts +21 -0
- package/dist/shared/specrails-home.js +36 -0
- package/integration-contract.json +43 -2
- package/package.json +11 -1
- package/schemas/agent-runtime.schema.json +9 -7
|
@@ -60,9 +60,66 @@ function score(value, field, floor) {
|
|
|
60
60
|
fail(field, `must be at least ${floor}, Core's own review gate`);
|
|
61
61
|
return value;
|
|
62
62
|
}
|
|
63
|
+
/** `verification` and `setup` share one command shape and one set of rules, including the credential refusal. */
|
|
64
|
+
function validateCommandList(value, list) {
|
|
65
|
+
if (!Array.isArray(value))
|
|
66
|
+
fail(list, 'expected an array of verification commands');
|
|
67
|
+
return value.map((item, i) => {
|
|
68
|
+
const field = `${list}[${i}]`, command = object(item, field);
|
|
69
|
+
keys(command, ['repositoryId', 'command', 'args', 'cwd', 'env', 'timeoutMs', 'key', 'label', 'policy'], field);
|
|
70
|
+
if (command.key !== undefined)
|
|
71
|
+
identifier(command.key, `${field}.key`);
|
|
72
|
+
if (command.label !== undefined && string(command.label, `${field}.label`).length > 256)
|
|
73
|
+
fail(`${field}.label`, 'maximum 256 characters');
|
|
74
|
+
if (command.policy !== undefined) {
|
|
75
|
+
const policy = object(command.policy, `${field}.policy`);
|
|
76
|
+
keys(policy, ['reuse', 'inputs', 'deterministic', 'readOnly', 'toolchainInputs', 'independentGroup', 'resources'], `${field}.policy`);
|
|
77
|
+
if (policy.reuse !== undefined)
|
|
78
|
+
choices(policy.reuse, ['never', 'snapshot-local'], `${field}.policy.reuse`);
|
|
79
|
+
for (const key of ['deterministic', 'readOnly'])
|
|
80
|
+
if (policy[key] !== undefined)
|
|
81
|
+
boolean(policy[key], `${field}.policy.${key}`);
|
|
82
|
+
if (policy.independentGroup !== undefined)
|
|
83
|
+
identifier(policy.independentGroup, `${field}.policy.independentGroup`);
|
|
84
|
+
for (const key of ['inputs', 'toolchainInputs', 'resources']) {
|
|
85
|
+
const values = policy[key];
|
|
86
|
+
if (values === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
if (!Array.isArray(values) || values.length > 256)
|
|
89
|
+
fail(`${field}.policy.${key}`, 'expected at most 256 entries');
|
|
90
|
+
for (const value of values)
|
|
91
|
+
if (string(value, `${field}.policy.${key}`).length > 4096)
|
|
92
|
+
fail(`${field}.policy.${key}`, 'entry exceeds 4096 characters');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
identifier(command.repositoryId, `${field}.repositoryId`);
|
|
96
|
+
string(command.command, `${field}.command`);
|
|
97
|
+
if (!Array.isArray(command.args) || !command.args.every(arg => typeof arg === 'string' && !arg.includes('\0')))
|
|
98
|
+
fail(`${field}.args`, 'expected a string array');
|
|
99
|
+
if (command.cwd !== undefined)
|
|
100
|
+
string(command.cwd, `${field}.cwd`);
|
|
101
|
+
if (command.timeoutMs !== undefined)
|
|
102
|
+
positive(command.timeoutMs, `${field}.timeoutMs`);
|
|
103
|
+
if (command.env !== undefined) {
|
|
104
|
+
const env = object(command.env, `${field}.env`);
|
|
105
|
+
for (const [key, value] of Object.entries(env)) {
|
|
106
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof value !== 'string' || value.includes('\0'))
|
|
107
|
+
fail(`${field}.env`, 'expected environment names and string values');
|
|
108
|
+
if (/(?:token|secret|password|api_?key|credential)/i.test(key))
|
|
109
|
+
fail(`${field}.env.${key}`, 'credentials must be inherited from the process environment, never saved');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return structuredClone(command);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function uniqueKeys(commands, list) {
|
|
116
|
+
const checkKeys = commands.flatMap(check => check.key ? [check.key] : []);
|
|
117
|
+
if (new Set(checkKeys).size !== checkKeys.length)
|
|
118
|
+
fail(list, 'duplicate check key');
|
|
119
|
+
}
|
|
63
120
|
export function validateRuntimeConfig(input, options = {}) {
|
|
64
121
|
const config = object(input, '$');
|
|
65
|
-
keys(config, ['schemaVersion', 'enabled', 'providers', 'agents', 'roles', 'fixer', 'limits', 'verification', 'approvalBeforeArchive', 'review', 'architect', 'rolePrompts', 'efficiency', 'guardrails'], '$');
|
|
122
|
+
keys(config, ['schemaVersion', 'enabled', 'providers', 'agents', 'roles', 'fixer', 'limits', 'verification', 'setup', 'approvalBeforeArchive', 'review', 'architect', 'rolePrompts', 'efficiency', 'guardrails'], '$');
|
|
66
123
|
if (config.schemaVersion !== 1)
|
|
67
124
|
fail('schemaVersion', 'expected 1');
|
|
68
125
|
if (config.enabled !== undefined && typeof config.enabled !== 'boolean')
|
|
@@ -176,58 +233,15 @@ export function validateRuntimeConfig(input, options = {}) {
|
|
|
176
233
|
for (const [key, value] of Object.entries(limits))
|
|
177
234
|
positive(value, `limits.${key}`, key !== 'maxCostUsd');
|
|
178
235
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (command.policy !== undefined) {
|
|
189
|
-
const policy = object(command.policy, `${field}.policy`);
|
|
190
|
-
keys(policy, ['reuse', 'inputs', 'deterministic', 'readOnly', 'toolchainInputs', 'independentGroup', 'resources'], `${field}.policy`);
|
|
191
|
-
if (policy.reuse !== undefined)
|
|
192
|
-
choices(policy.reuse, ['never', 'snapshot-local'], `${field}.policy.reuse`);
|
|
193
|
-
for (const key of ['deterministic', 'readOnly'])
|
|
194
|
-
if (policy[key] !== undefined)
|
|
195
|
-
boolean(policy[key], `${field}.policy.${key}`);
|
|
196
|
-
if (policy.independentGroup !== undefined)
|
|
197
|
-
identifier(policy.independentGroup, `${field}.policy.independentGroup`);
|
|
198
|
-
for (const key of ['inputs', 'toolchainInputs', 'resources']) {
|
|
199
|
-
const values = policy[key];
|
|
200
|
-
if (values === undefined)
|
|
201
|
-
continue;
|
|
202
|
-
if (!Array.isArray(values) || values.length > 256)
|
|
203
|
-
fail(`${field}.policy.${key}`, 'expected at most 256 entries');
|
|
204
|
-
for (const value of values)
|
|
205
|
-
if (string(value, `${field}.policy.${key}`).length > 4096)
|
|
206
|
-
fail(`${field}.policy.${key}`, 'entry exceeds 4096 characters');
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
identifier(command.repositoryId, `${field}.repositoryId`);
|
|
210
|
-
string(command.command, `${field}.command`);
|
|
211
|
-
if (!Array.isArray(command.args) || !command.args.every(arg => typeof arg === 'string' && !arg.includes('\0')))
|
|
212
|
-
fail(`${field}.args`, 'expected a string array');
|
|
213
|
-
if (command.cwd !== undefined)
|
|
214
|
-
string(command.cwd, `${field}.cwd`);
|
|
215
|
-
if (command.timeoutMs !== undefined)
|
|
216
|
-
positive(command.timeoutMs, `${field}.timeoutMs`);
|
|
217
|
-
if (command.env !== undefined) {
|
|
218
|
-
const env = object(command.env, `${field}.env`);
|
|
219
|
-
for (const [key, value] of Object.entries(env)) {
|
|
220
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof value !== 'string' || value.includes('\0'))
|
|
221
|
-
fail(`${field}.env`, 'expected environment names and string values');
|
|
222
|
-
if (/(?:token|secret|password|api_?key|credential)/i.test(key))
|
|
223
|
-
fail(`${field}.env.${key}`, 'credentials must be inherited from the process environment, never saved');
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
return structuredClone(command);
|
|
227
|
-
});
|
|
228
|
-
const checkKeys = verification.flatMap(check => check.key ? [check.key] : []);
|
|
229
|
-
if (new Set(checkKeys).size !== checkKeys.length)
|
|
230
|
-
fail('verification', 'duplicate check key');
|
|
236
|
+
const verification = validateCommandList(config.verification, 'verification');
|
|
237
|
+
const setup = config.setup === undefined ? undefined : validateCommandList(config.setup, 'setup');
|
|
238
|
+
uniqueKeys(verification, 'verification');
|
|
239
|
+
if (setup) {
|
|
240
|
+
uniqueKeys(setup, 'setup');
|
|
241
|
+
// A key names one check in receipts and host UIs: it cannot be both a setup command and a verification check.
|
|
242
|
+
if (setup.some(command => command.key && verification.some(check => check.key === command.key)))
|
|
243
|
+
fail('setup', 'duplicate check key shared with verification');
|
|
244
|
+
}
|
|
231
245
|
if (config.efficiency !== undefined) {
|
|
232
246
|
const policy = object(config.efficiency, 'efficiency');
|
|
233
247
|
keys(policy, ['schemaVersion', 'contextMode', 'reviewMode', 'planning', 'acceptDeveloperChecks', 'verification'], 'efficiency');
|
|
@@ -295,6 +309,7 @@ export function validateRuntimeConfig(input, options = {}) {
|
|
|
295
309
|
}
|
|
296
310
|
}
|
|
297
311
|
return { schemaVersion: 1, enabled: true, providers, agents, verification,
|
|
312
|
+
...(setup === undefined ? {} : { setup }),
|
|
298
313
|
...(roles === undefined ? {} : { roles }),
|
|
299
314
|
...(config.efficiency === undefined ? {} : { efficiency: structuredClone(config.efficiency) }),
|
|
300
315
|
...(rolePrompts === undefined ? {} : { rolePrompts }),
|
|
@@ -85,7 +85,7 @@ export function compileWorkflowDefinition(definition, registry, execution, optio
|
|
|
85
85
|
if (!exit)
|
|
86
86
|
throw new EngineError('component_exit_missing', `Component ${nodePath} did not produce an exit`);
|
|
87
87
|
childEvidence = { $candidate: completed.$candidate, $verified: completed.$verified };
|
|
88
|
-
result = { outcome: exit.outcome, status: exit.status, output: { outputs: completed.$outputs, completion:
|
|
88
|
+
result = { outcome: exit.outcome, status: exit.status, output: { outputs: completed.$outputs, completion: completionOutput(exit.completion) },
|
|
89
89
|
usage: completed.$usage, completion: exit.completion,
|
|
90
90
|
...(exit.error ? { error: exit.error } : {}) };
|
|
91
91
|
}
|
|
@@ -156,7 +156,7 @@ export function compileWorkflowDefinition(definition, registry, execution, optio
|
|
|
156
156
|
throw new EngineError('component_exit_missing', 'Map body did not produce an exit');
|
|
157
157
|
return { $branches: { [id]: { total: task.total, visitId: task.frame.visitId, transition: task.frame.transition,
|
|
158
158
|
results: [{ id: task.frame.visitId + ':' + task.index, transition: task.frame.transition, attempt: task.frame.attempt, ordinal: task.index,
|
|
159
|
-
index: task.index, outcome: completed.$exit.completion.ok ? 'next' : 'failed', output: boundPieceOutput({ outputs: completed.$outputs, completion:
|
|
159
|
+
index: task.index, outcome: completed.$exit.completion.ok ? 'next' : 'failed', output: boundPieceOutput({ outputs: completed.$outputs, completion: completionOutput(completed.$exit.completion) }) }] } } };
|
|
160
160
|
}, { subgraphs: [child] });
|
|
161
161
|
}
|
|
162
162
|
}
|
|
@@ -236,6 +236,14 @@ function componentInput(state, frame, mapping, body) {
|
|
|
236
236
|
input.$usage = state.$usage;
|
|
237
237
|
return input;
|
|
238
238
|
}
|
|
239
|
+
/** A completion as committed output: the optional blocker is present only when set, so the value stays plain JSON. */
|
|
240
|
+
function completionOutput(completion) {
|
|
241
|
+
const { blocker, ...rest } = completion;
|
|
242
|
+
if (!blocker)
|
|
243
|
+
return { ...rest };
|
|
244
|
+
const { evidenceId, ...fields } = blocker;
|
|
245
|
+
return { ...rest, blocker: { ...fields, ...(evidenceId === undefined ? {} : { evidenceId }) } };
|
|
246
|
+
}
|
|
239
247
|
function implicitCompletion(result, state) {
|
|
240
248
|
const candidate = result.candidate ?? state.$candidate;
|
|
241
249
|
const verified = Object.hasOwn(result, 'verified') ? result.verified : state.$verified;
|
|
@@ -97,10 +97,26 @@ export interface ReceiptEvidence {
|
|
|
97
97
|
scope: 'full' | 'scoped';
|
|
98
98
|
evidence: JsonValue;
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* A failure only the host can repair, reported structurally so hosts render the
|
|
102
|
+
* action instead of parsing prose. `cwd` is relative to the checkout. `scope`
|
|
103
|
+
* is reserved for a correction role that diagnoses the cause outside the change.
|
|
104
|
+
*/
|
|
105
|
+
export interface HostBlocker {
|
|
106
|
+
kind: 'network' | 'credential' | 'environment-variable' | 'toolchain' | 'setup' | 'environment' | 'scope';
|
|
107
|
+
reason: string;
|
|
108
|
+
command: string;
|
|
109
|
+
args: string[];
|
|
110
|
+
cwd: string;
|
|
111
|
+
requiredAction: string;
|
|
112
|
+
evidenceId?: string;
|
|
113
|
+
}
|
|
100
114
|
export interface EngineCompletion {
|
|
101
115
|
ok: boolean;
|
|
102
116
|
reasons: string[];
|
|
103
117
|
verified: boolean;
|
|
118
|
+
/** Copied by an `end` piece from the node named in `blockerFrom`, when that output carries one. */
|
|
119
|
+
blocker?: HostBlocker;
|
|
104
120
|
}
|
|
105
121
|
export interface EngineAnswer {
|
|
106
122
|
id: string;
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { EngineError } from '../contracts.js';
|
|
2
2
|
import { parseExpression } from '../expressions.js';
|
|
3
|
+
import { boundedBlocker } from '../../verification-repair.js';
|
|
3
4
|
import { answerEntry, idSchema, paramsSchema, positiveInteger, stringSchema, text } from './shared.js';
|
|
5
|
+
/** A host blocker committed by `verify` (`output.blocker`) or by a correction role's structured JSON (`output.structured.blocker`). */
|
|
6
|
+
function committedBlocker(output) {
|
|
7
|
+
if (!output || typeof output !== 'object' || Array.isArray(output))
|
|
8
|
+
return undefined;
|
|
9
|
+
const structured = output.structured;
|
|
10
|
+
return boundedBlocker(output.blocker) ?? (structured && typeof structured === 'object' && !Array.isArray(structured) ? boundedBlocker(structured.blocker) : undefined);
|
|
11
|
+
}
|
|
4
12
|
export function controlPieces(bindings) {
|
|
5
13
|
const pieces = [{
|
|
6
14
|
descriptor: { kind: 'condition', paramsSchema: paramsSchema({ expr: { ...stringSchema, minLength: 1, maxLength: 4096 } }, ['expr']), outcomes: ['true', 'false'], effect: 'read', requiresAI: false },
|
|
@@ -14,14 +22,15 @@ export function controlPieces(bindings) {
|
|
|
14
22
|
}
|
|
15
23
|
},
|
|
16
24
|
}, {
|
|
17
|
-
descriptor: { kind: 'end', paramsSchema: paramsSchema({ outcome: { enum: ['success', 'failure'] }, requiresVerified: { type: 'boolean' }, reason: stringSchema, exit: idSchema }, ['outcome']), outcomes: [], effect: 'read', requiresAI: false },
|
|
25
|
+
descriptor: { kind: 'end', paramsSchema: paramsSchema({ outcome: { enum: ['success', 'failure'] }, requiresVerified: { type: 'boolean' }, reason: stringSchema, exit: idSchema, blockerFrom: idSchema }, ['outcome']), outcomes: [], effect: 'read', requiresAI: false },
|
|
18
26
|
async execute(params, context) {
|
|
19
27
|
const { candidate, verified } = bindings().executionSnapshot(context);
|
|
20
28
|
const current = verified !== null && candidate !== null && verified.candidateHash === candidate.hash;
|
|
21
29
|
const reasons = typeof params.reason === 'string' && params.reason ? [params.reason] : [];
|
|
22
30
|
if (params.requiresVerified && !current)
|
|
23
31
|
reasons.push('unverified');
|
|
24
|
-
|
|
32
|
+
const blocker = typeof params.blockerFrom === 'string' ? committedBlocker(context.state.$outputs[params.blockerFrom]) : undefined;
|
|
33
|
+
return { outcome: text(params.outcome), completion: { ok: params.outcome === 'success' && !(params.requiresVerified && !current), verified: current, reasons, ...(blocker ? { blocker } : {}) } };
|
|
25
34
|
},
|
|
26
35
|
}];
|
|
27
36
|
for (const kind of ['approval', 'question', 'gate']) {
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import { type InstallOutcome } from '../../compact/environment.js';
|
|
1
2
|
import { type JsonObject, type Piece, type PieceExecutionContext, type PieceResult } from '../contracts.js';
|
|
2
3
|
import type { PieceDependencies, PieceDependencyProvider } from './ports.js';
|
|
3
4
|
export declare function roleTurnPiece(bindings: PieceDependencyProvider): Piece;
|
|
4
5
|
/** One invocation path for configured roles, including routing and exactly one repair. */
|
|
5
6
|
export declare function executeRoleTurn(deps: PieceDependencies, params: JsonObject, context: PieceExecutionContext, options?: {
|
|
6
7
|
normalizeStructuredOutput?: (output: Record<string, unknown> | undefined, text: string) => Record<string, unknown> | undefined;
|
|
8
|
+
/** Set on the single rerun after the host repaired an environment blocker the agent reported. */
|
|
9
|
+
environmentRetry?: {
|
|
10
|
+
installs: InstallOutcome[];
|
|
11
|
+
};
|
|
7
12
|
}): Promise<PieceResult>;
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { contentDigest } from '../canonical-json.js';
|
|
2
2
|
import { advisoryMemory } from './project-memory.js';
|
|
3
3
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
4
|
+
import { withScopeDefault } from '../../change-scope.js';
|
|
4
5
|
import { resolveRoleDescriptor } from '../../config.js';
|
|
5
6
|
import { AgentExecutionError } from '../../executor-types.js';
|
|
6
7
|
import { createRoleInvoker } from '../../graph/roles.js';
|
|
8
|
+
import { admitVerificationProposals } from '../../verification-proposals.js';
|
|
9
|
+
import { guardrailEnabled } from '../../guardrails.js';
|
|
10
|
+
import { installEnvironment, prepareEnvironment } from '../../compact/environment.js';
|
|
7
11
|
import { roleInstructions } from '../../prompts.js';
|
|
8
12
|
import { EngineError } from '../contracts.js';
|
|
9
13
|
import { boundedText, historyEntry, idSchema, json, paramsSchema, invocationTimers, stringSchema, text } from './shared.js';
|
|
10
14
|
const ajv = new Ajv2020({ allErrors: true, strict: true, validateFormats: false, ownProperties: true });
|
|
11
15
|
export function roleTurnPiece(bindings) {
|
|
12
16
|
return {
|
|
13
|
-
descriptor: { kind: 'role-turn', paramsSchema: paramsSchema({ ...invocationTimers, roleId: idSchema, prompt: { ...stringSchema, minLength: 1 }, structuredOutput: { type: 'object' }, sessionContinuity: { enum: ['run', 'none'] } }, ['roleId', 'prompt']), outcomes: ['next', 'invalid', 'failed'], effect: 'derived', requiresAI: true, storeAccess: 'write' },
|
|
17
|
+
descriptor: { kind: 'role-turn', paramsSchema: paramsSchema({ ...invocationTimers, roleId: idSchema, prompt: { ...stringSchema, minLength: 1 }, structuredOutput: { type: 'object' }, sessionContinuity: { enum: ['run', 'none'] }, verificationProposalsFrom: { type: 'string', minLength: 1, maxLength: 128 } }, ['roleId', 'prompt']), outcomes: ['next', 'invalid', 'failed'], effect: 'derived', requiresAI: true, storeAccess: 'write' },
|
|
14
18
|
getOutcomes: params => params.structuredOutput ? ['next', 'invalid', 'failed'] : ['next', 'failed'],
|
|
15
19
|
getEffect: (params, roles) => {
|
|
16
20
|
const role = roles[text(params.roleId)];
|
|
@@ -24,6 +28,14 @@ export function roleTurnPiece(bindings) {
|
|
|
24
28
|
/** One invocation path for configured roles, including routing and exactly one repair. */
|
|
25
29
|
export async function executeRoleTurn(deps, params, context, options = {}) {
|
|
26
30
|
const roleId = text(params.roleId), descriptor = resolveRoleDescriptor(deps.config, roleId);
|
|
31
|
+
// Agents run sandboxed and cannot install what the repository needs (Codex
|
|
32
|
+
// cannot even write the Playwright browser cache): the host prepares it
|
|
33
|
+
// before any writer turn and says so in the task.
|
|
34
|
+
const hostRepairs = descriptor.access === 'write' && guardrailEnabled(deps.config.guardrails, 'environment-repair');
|
|
35
|
+
const note = (line) => context.progress({ type: 'verification-output', payload: { text: line } });
|
|
36
|
+
const environment = options.environmentRetry
|
|
37
|
+
? environmentNote(options.environmentRetry.installs, 'retry')
|
|
38
|
+
: hostRepairs ? environmentNote(prepareEnvironment(deps.context.repositories.map(repository => repository.path), environmentIo(deps, note)), 'prepared') : '';
|
|
27
39
|
const schema = params.structuredOutput;
|
|
28
40
|
const validate = schema ? ajv.compile(schema) : undefined;
|
|
29
41
|
const roleState = deps.roleState(context);
|
|
@@ -44,8 +56,9 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
|
|
|
44
56
|
});
|
|
45
57
|
const priorNote = !previous && descriptor.access === 'read' && saved?.session && typeof saved.notes?.value.text === 'string'
|
|
46
58
|
? saved.notes.value.text.slice(0, 4000) : '';
|
|
47
|
-
const task = text(params.prompt) + (priorNote ? '\n\n## Prior project review note\nTreat this bounded note as prior evidence to check against the current task; frozen requirements remain authoritative.\n' + JSON.stringify(priorNote) : '');
|
|
48
|
-
const
|
|
59
|
+
const task = text(params.prompt) + environment + (priorNote ? '\n\n## Prior project review note\nTreat this bounded note as prior evidence to check against the current task; frozen requirements remain authoritative.\n' + JSON.stringify(priorNote) : '');
|
|
60
|
+
const verification = descriptor.access === 'write' ? hostVerificationPlan(deps, context, params) : undefined;
|
|
61
|
+
const full = roleInstructions(descriptor, deps.context, openspec?.[roleId]?.change, { definition: descriptor.prompt, verification }) + '\n## Current workflow task\n' + task;
|
|
49
62
|
const result = await invoke(roleId, deps.stepContext(context), { prompt: previous ? task : full, ...(previous ? { resumeSessionId: previous, fallbackPrompt: full } : {}),
|
|
50
63
|
structured: schema !== undefined, lenient: options.normalizeStructuredOutput !== undefined, outputSchema: schema, timeoutMs: params.timeoutMs, idleTimeoutMs: params.idleTimeoutMs }, (output, responseText) => {
|
|
51
64
|
const normalized = options.normalizeStructuredOutput ? options.normalizeStructuredOutput(output, responseText) : output;
|
|
@@ -58,6 +71,19 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
|
|
|
58
71
|
throw new AgentExecutionError(result.error, result.code);
|
|
59
72
|
return { outcome: schema ? 'invalid' : 'failed', status: 'failed', output: { error: boundedText(result.error) }, error: { code: schema ? 'invalid_role_output' : 'role_failed', message: result.error } };
|
|
60
73
|
}
|
|
74
|
+
// A writer that still hit a missing tool reports a structured blocker. When
|
|
75
|
+
// the host can install it, it does so and reruns the turn once instead of
|
|
76
|
+
// ending the run on something it can fix itself.
|
|
77
|
+
const blocker = hostRepairs && !options.environmentRetry ? repairableBlocker(result.value) : undefined;
|
|
78
|
+
if (blocker) {
|
|
79
|
+
const roots = deps.context.repositories.map(repository => repository.path);
|
|
80
|
+
const io = environmentIo(deps, note);
|
|
81
|
+
const installs = [...installEnvironment(roots, { ...io, failureOutput: blocker }), ...prepareEnvironment(roots, io)];
|
|
82
|
+
if (installs.some(install => install.ok)) {
|
|
83
|
+
note('[environment] The agent reported a missing dependency or tool; the host installed it and runs the turn again.');
|
|
84
|
+
return executeRoleTurn(deps, params, context, { ...options, environmentRetry: { installs } });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
61
87
|
const currentState = roleState.read(), session = currentState.sessions[roleId];
|
|
62
88
|
const finalIdentity = { ...memoryIdentity, tier: currentState.routes[roleId]?.tier ?? 'base' }, finalKey = contentDigest(finalIdentity);
|
|
63
89
|
await advisoryMemory(context, async () => {
|
|
@@ -71,3 +97,43 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
|
|
|
71
97
|
return { outcome: 'next', output: { candidateHash: deps.executionSnapshot(context).candidate?.hash ?? null, text: boundedText(result.text), ...(result.value ? { structured: json(result.value) } : {}) },
|
|
72
98
|
history: [historyEntry(context, result.text)], ...(params.sessionContinuity !== 'none' && session ? { session: { sessionId: session.sessionId, identity: session.identity } } : {}) };
|
|
73
99
|
}
|
|
100
|
+
const REPAIRABLE_BLOCKERS = new Set(['toolchain', 'setup', 'environment']);
|
|
101
|
+
/** The text of a structured `blocker` the host may be able to install away; undefined for other kinds (credentials, network, scope). */
|
|
102
|
+
function repairableBlocker(value) {
|
|
103
|
+
const blocker = value?.blocker;
|
|
104
|
+
if (!blocker || typeof blocker !== 'object' || !REPAIRABLE_BLOCKERS.has(String(blocker.kind)))
|
|
105
|
+
return undefined;
|
|
106
|
+
return ['evidence', 'reason', 'command', 'requiredAction'].map(field => typeof blocker[field] === 'string' ? blocker[field] : '').join('\n').slice(0, 8_000);
|
|
107
|
+
}
|
|
108
|
+
function environmentIo(deps, note) {
|
|
109
|
+
return { lockfileRepair: guardrailEnabled(deps.config.guardrails, 'lockfile-repair'),
|
|
110
|
+
onEvent: (event) => note(event.kind === 'text' ? event.text ?? '' : `[environment] ${event.tool ?? ''} ${event.detail ?? ''}`.trim()) };
|
|
111
|
+
}
|
|
112
|
+
/** What the host installed (or could not) for this turn, as task context; empty when nothing was needed. */
|
|
113
|
+
function environmentNote(installs, when) {
|
|
114
|
+
if (!installs.length)
|
|
115
|
+
return '';
|
|
116
|
+
const done = installs.filter(install => install.ok).map(install => install.installs ?? `${install.ecosystem} dependencies`);
|
|
117
|
+
const failed = installs.filter(install => !install.ok).map(install => install.detail);
|
|
118
|
+
return '\n\n## Host environment\n' + (when === 'retry' ? 'Your previous turn reported an environment blocker. ' : '')
|
|
119
|
+
+ (done.length ? `The host installed ${done.join('; ')} for this workspace${when === 'retry' ? ': continue the remaining work and rerun the checks that were blocked.' : ' before this turn.'} ` : '')
|
|
120
|
+
+ (failed.length ? `It could not install: ${failed.join('; ')}. ` : '')
|
|
121
|
+
+ 'Do not install these yourself; if something is still missing, return the structured blocker.';
|
|
122
|
+
}
|
|
123
|
+
/** The complete plan the host will run for the repositories in scope: configured checks plus, when `verificationProposalsFrom`
|
|
124
|
+
* names a committed output, the proposals the verify piece admits (see admitVerificationProposals). */
|
|
125
|
+
function hostVerificationPlan(deps, context, params) {
|
|
126
|
+
const inScope = new Set(deps.context.repositories.map(repository => repository.id));
|
|
127
|
+
const commands = deps.config.verification.filter(command => inScope.has(command.repositoryId)).map(command => withScopeDefault(deps.context, command));
|
|
128
|
+
if (typeof params.verificationProposalsFrom === 'string') {
|
|
129
|
+
const source = context.state.$outputs[params.verificationProposalsFrom];
|
|
130
|
+
const proposals = (Array.isArray(source?.structured?.verification) ? source.structured.verification.slice(0, 20) : []).filter((proposal) => {
|
|
131
|
+
if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal))
|
|
132
|
+
return false;
|
|
133
|
+
const raw = proposal;
|
|
134
|
+
return typeof raw.repositoryId === 'string' && inScope.has(raw.repositoryId) && typeof raw.command === 'string' && Array.isArray(raw.args) && raw.args.every(arg => typeof arg === 'string');
|
|
135
|
+
});
|
|
136
|
+
commands.push(...admitVerificationProposals(deps.context, commands, proposals, raw => withScopeDefault(deps.context, { repositoryId: raw.repositoryId, command: raw.command, args: raw.args, ...(typeof raw.cwd === 'string' ? { cwd: raw.cwd } : {}) })));
|
|
137
|
+
}
|
|
138
|
+
return commands;
|
|
139
|
+
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import { type VerificationReceipt } from '../../../pipeline/pipeline-state.js';
|
|
2
|
-
import type { Piece, PieceExecutionContext, ReceiptEvidence } from '../contracts.js';
|
|
1
|
+
import { type VerificationCommand, type VerificationReceipt } from '../../../pipeline/pipeline-state.js';
|
|
2
|
+
import type { JsonObject, Piece, PieceExecutionContext, ReceiptEvidence } from '../contracts.js';
|
|
3
3
|
import type { PieceDependencies, PieceDependencyProvider } from './ports.js';
|
|
4
|
-
export declare const verificationCommandSchema:
|
|
4
|
+
export declare const verificationCommandSchema: JsonObject;
|
|
5
5
|
export declare function receiptEvidence(receipt: VerificationReceipt): ReceiptEvidence;
|
|
6
6
|
export declare function verificationDeadline(deps: PieceDependencies, context: PieceExecutionContext): number | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* The complete host plan for a `verify` node: configured checks (or the inline
|
|
9
|
+
* list) plus the proposals of the node named in `additionalCommandsFrom` for
|
|
10
|
+
* repositories without a configured check. Role turns show write roles this
|
|
11
|
+
* same list, so the developer never passes a check the host does not run.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveVerificationPlan(deps: PieceDependencies, context: PieceExecutionContext, params: JsonObject): VerificationCommand[];
|
|
7
14
|
export declare function verifyPiece(bindings: PieceDependencyProvider): Piece;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { admitVerificationProposals } from '../../verification-proposals.js';
|
|
1
2
|
import { stripVTControlCharacters } from 'node:util';
|
|
2
3
|
import { contentDigest } from '../canonical-json.js';
|
|
3
4
|
import { advisoryMemory } from './project-memory.js';
|
|
@@ -5,6 +6,9 @@ import { executeVerification, validateVerificationRequest } from '../../../pipel
|
|
|
5
6
|
import { withScopeDefault } from '../../change-scope.js';
|
|
6
7
|
import { verificationDiagnosticPriority, verificationFailureSummary } from '../../verification-diagnostics.js';
|
|
7
8
|
import { boundedText, json, paramsSchema, positiveInteger } from './shared.js';
|
|
9
|
+
import { isEnvironmentFailure } from '../../compact/environment.js';
|
|
10
|
+
import { guardrailEnabled } from '../../guardrails.js';
|
|
11
|
+
import { checkoutRelative, preconditionBlock, repairEnvironment } from '../../verification-repair.js';
|
|
8
12
|
/** Keep real subprocess diagnostics in committed outputs for subsequent agents.
|
|
9
13
|
* The full command evidence remains in the receipt; prioritize failed checks
|
|
10
14
|
* when the output budget is exhausted. */
|
|
@@ -75,50 +79,115 @@ export function verificationDeadline(deps, context) {
|
|
|
75
79
|
const budget = deps.stepContext(context).remainingBudget();
|
|
76
80
|
return budget.maxDurationMs === undefined ? undefined : Date.now() + Math.max(0, budget.maxDurationMs);
|
|
77
81
|
}
|
|
82
|
+
const VERIFY_OUTCOMES = ['pass', 'fail', 'failed'];
|
|
83
|
+
const verificationCommandList = { type: 'array', maxItems: 100, items: verificationCommandSchema };
|
|
84
|
+
/**
|
|
85
|
+
* The complete host plan for a `verify` node: configured checks (or the inline
|
|
86
|
+
* list) plus the proposals of the node named in `additionalCommandsFrom` for
|
|
87
|
+
* repositories without a configured check. Role turns show write roles this
|
|
88
|
+
* same list, so the developer never passes a check the host does not run.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveVerificationPlan(deps, context, params) {
|
|
91
|
+
const configured = params.commands === undefined || params.commands === 'configured' ? deps.config.verification : params.commands;
|
|
92
|
+
const commands = configured.map(command => withScopeDefault(deps.context, command));
|
|
93
|
+
if (typeof params.additionalCommandsFrom === 'string') {
|
|
94
|
+
const source = context.state.$outputs[params.additionalCommandsFrom];
|
|
95
|
+
const proposals = source?.structured?.verification;
|
|
96
|
+
if (!Array.isArray(proposals) || proposals.length > 20)
|
|
97
|
+
throw new Error('Verification proposals must be a bounded array from a committed agent output');
|
|
98
|
+
// Host checks always stay mandatory. The same scoped command validation
|
|
99
|
+
// applies to these actual subprocesses and to configured checks below.
|
|
100
|
+
for (const proposal of proposals) {
|
|
101
|
+
if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal))
|
|
102
|
+
throw new Error('Invalid verification proposal');
|
|
103
|
+
if (!deps.context.repositories.some(repository => repository.id === proposal.repositoryId))
|
|
104
|
+
throw new Error('Unknown verification repository');
|
|
105
|
+
}
|
|
106
|
+
commands.push(...admitVerificationProposals(deps.context, commands, proposals, raw => withScopeDefault(deps.context, raw)));
|
|
107
|
+
}
|
|
108
|
+
return commands;
|
|
109
|
+
}
|
|
110
|
+
/** A setup command that exited non-zero: the host declared it, so only the host can fix or remove it. */
|
|
111
|
+
function setupBlocker(deps, command) {
|
|
112
|
+
const cwd = checkoutRelative(deps.context, command.cwd);
|
|
113
|
+
return { kind: 'setup', reason: `the setup command \`${[command.command, ...command.args].join(' ')}\` failed in ${cwd} (exit ${command.exitCode})`, command: command.command, args: command.args, cwd,
|
|
114
|
+
requiredAction: `Fix or remove the setup command \`${[command.command, ...command.args].join(' ')}\` in the runtime configuration, then retry the run.`, ...(command.evidenceId ? { evidenceId: command.evidenceId } : {}) };
|
|
115
|
+
}
|
|
78
116
|
export function verifyPiece(bindings) {
|
|
79
117
|
return {
|
|
80
|
-
descriptor: { kind: 'verify', paramsSchema: paramsSchema({ commands: { oneOf: [{ const: 'configured' },
|
|
81
|
-
additionalCommandsFrom: { type: 'string', minLength: 1, maxLength: 128 }, unverified: { type: 'boolean' }, maxConcurrency: { type: 'integer', minimum: 1, maximum: 4 }
|
|
118
|
+
descriptor: { kind: 'verify', paramsSchema: paramsSchema({ commands: { oneOf: [{ const: 'configured' }, verificationCommandList] },
|
|
119
|
+
additionalCommandsFrom: { type: 'string', minLength: 1, maxLength: 128 }, unverified: { type: 'boolean' }, maxConcurrency: { type: 'integer', minimum: 1, maximum: 4 },
|
|
120
|
+
setup: { oneOf: [{ const: 'configured' }, verificationCommandList] }, hostBlockers: { type: 'boolean' } }, ['commands']), outcomes: [...VERIFY_OUTCOMES, 'blocked'], effect: 'write', requiresAI: false, storeAccess: 'write' },
|
|
121
|
+
// `blocked` is opt-in so definitions published before host blockers keep their exact `ends`.
|
|
122
|
+
getOutcomes: params => params.hostBlockers === true ? [...VERIFY_OUTCOMES, 'blocked'] : VERIFY_OUTCOMES,
|
|
82
123
|
async execute(params, context) {
|
|
83
124
|
const deps = bindings();
|
|
84
|
-
const commands = (
|
|
85
|
-
const hostRepositories = new Set(commands.map(command => command.repositoryId));
|
|
86
|
-
if (typeof params.additionalCommandsFrom === 'string') {
|
|
87
|
-
const source = context.state.$outputs[params.additionalCommandsFrom];
|
|
88
|
-
const proposals = source?.structured?.verification;
|
|
89
|
-
if (!Array.isArray(proposals) || proposals.length > 20)
|
|
90
|
-
throw new Error('Verification proposals must be a bounded array from a committed agent output');
|
|
91
|
-
// Host checks always stay mandatory. The same scoped command validation
|
|
92
|
-
// applies to these actual subprocesses and to configured checks below.
|
|
93
|
-
for (const proposal of proposals) {
|
|
94
|
-
if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal))
|
|
95
|
-
throw new Error('Invalid verification proposal');
|
|
96
|
-
const raw = proposal;
|
|
97
|
-
if (!deps.context.repositories.some(repository => repository.id === raw.repositoryId))
|
|
98
|
-
throw new Error('Unknown verification repository');
|
|
99
|
-
if (!hostRepositories.has(raw.repositoryId))
|
|
100
|
-
commands.push(withScopeDefault(deps.context, raw));
|
|
101
|
-
}
|
|
102
|
-
}
|
|
125
|
+
const commands = resolveVerificationPlan(deps, context, params);
|
|
103
126
|
const coversAll = deps.context.repositories.every(repository => commands.some(command => command.repositoryId === repository.id));
|
|
104
127
|
const request = { kind: coversAll || params.unverified === true ? 'full' : 'scoped', commands, ...(params.unverified === true ? { unverified: true } : {}) };
|
|
105
128
|
validateVerificationRequest(deps.context, request);
|
|
129
|
+
// Setup is validated before anything runs: a setup entry escaping the workspace fails exactly like a check would.
|
|
130
|
+
const setupCommands = (params.setup === 'configured' ? deps.config.setup ?? [] : params.setup ?? [])
|
|
131
|
+
.map(({ policy: _policy, ...command }) => withScopeDefault(deps.context, command));
|
|
132
|
+
const setupRequest = { kind: 'scoped', commands: setupCommands };
|
|
133
|
+
if (setupCommands.length)
|
|
134
|
+
validateVerificationRequest(deps.context, setupRequest);
|
|
135
|
+
const progress = (text) => context.progress({ type: 'verification-output', payload: { text } });
|
|
136
|
+
const concurrency = params.maxConcurrency ?? deps.config.efficiency?.verification?.maxConcurrency;
|
|
137
|
+
const run = (plan, maxConcurrency) => executeVerification(deps.context, plan, deps.verification(context), progress, context.signal, {
|
|
138
|
+
deadline: verificationDeadline(deps, context), maxConcurrency, idleTimeoutMs: deps.config.guardrails?.['verify-idle-timeout'] === false ? 0 : undefined,
|
|
139
|
+
onEvidence: async (kind, payload) => context.progress({ type: 'runtime-efficiency-event', payload: { kind, ...payload } }),
|
|
140
|
+
});
|
|
106
141
|
const identity = { candidateHash: deps.executionSnapshot(context).candidate?.hash ?? null, scopeId: context.frame.scope.id,
|
|
107
142
|
repositories: deps.context.repositories.map(repository => ({ id: repository.id, path: repository.path })),
|
|
108
|
-
planHash: contentDigest(json(request)), concurrency:
|
|
143
|
+
planHash: contentDigest(json(request)), concurrency: concurrency ?? 1 };
|
|
144
|
+
const infrastructureFailure = (receipt) => receipt.commands.some(command => command.exitCode === -1 && command.outcome !== 'cancelled');
|
|
145
|
+
const executionError = (receipt) => ({ status: 'failed', error: { code: 'verification_execution_error', message: receipt.reason ?? 'A verification command could not complete' } });
|
|
146
|
+
const blocked = (blocker, output) => ({
|
|
147
|
+
outcome: params.hostBlockers === true ? 'blocked' : 'fail',
|
|
148
|
+
...(params.hostBlockers === true ? { status: 'blocked', error: { code: 'verification_host_precondition', message: `${blocker.reason} ${blocker.requiredAction}` } } : {}),
|
|
149
|
+
output: { ...output, valid: false, candidateHash: identity.candidateHash, blocker: json(blocker) }, verified: null,
|
|
150
|
+
});
|
|
151
|
+
// Setup never installs a verified candidate or feeds the no-progress
|
|
152
|
+
// fingerprint: it prepares the workspace, it does not judge the change.
|
|
153
|
+
let setup;
|
|
154
|
+
if (setupCommands.length) {
|
|
155
|
+
const receipt = await run(setupRequest, 1);
|
|
156
|
+
setup = verificationDiagnostics(receipt);
|
|
157
|
+
if (infrastructureFailure(receipt))
|
|
158
|
+
return { outcome: 'failed', ...executionError(receipt), output: { valid: false, candidateHash: identity.candidateHash, reason: receipt.reason ?? 'A setup command could not complete', setup }, verified: null };
|
|
159
|
+
const failed = receipt.commands.find(command => command.exitCode !== 0);
|
|
160
|
+
if (failed)
|
|
161
|
+
return blocked(setupBlocker(deps, failed), { reason: 'A setup command failed', setup });
|
|
162
|
+
}
|
|
109
163
|
const memoryKey = contentDigest(identity);
|
|
110
164
|
const known = await advisoryMemory(context, () => deps.memory(context).get(['verification', 'known-commands'], memoryKey));
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
165
|
+
let receipt = await run(request, concurrency);
|
|
166
|
+
let blocker = receipt.valid ? undefined : preconditionBlock(deps.context, receipt);
|
|
167
|
+
// A missing toolchain or dependency (exit 127, "command not found", a
|
|
168
|
+
// Playwright browser build) is the host's to fix, not the fixer's: a
|
|
169
|
+
// fresh worktree has no node_modules whatever wrote the code. Install
|
|
170
|
+
// once where the failing command runs and verify again; only the second
|
|
171
|
+
// receipt becomes feedback.
|
|
172
|
+
let environmentRepair;
|
|
173
|
+
if (!blocker && !receipt.valid && guardrailEnabled(deps.config.guardrails, 'environment-repair') && receipt.commands.some(command => isEnvironmentFailure(command.exitCode, command.output))) {
|
|
174
|
+
const repair = repairEnvironment(deps.context, receipt, deps.config.guardrails, progress);
|
|
175
|
+
environmentRepair = { attempted: true, reverified: false, installs: repair.installs.map(install => ({ command: install.command, args: install.args, root: checkoutRelative(deps.context, install.root), ok: install.ok, detail: boundedText(install.detail, 1_000) })) };
|
|
176
|
+
if (repair.refused)
|
|
177
|
+
blocker = repair.refused;
|
|
178
|
+
else if (repair.installs.some(install => install.ok)) {
|
|
179
|
+
progress('[environment] Verification failed on the environment (missing dependencies or tools); the host installed them and is verifying again.');
|
|
180
|
+
receipt = await run(request, concurrency);
|
|
181
|
+
environmentRepair.reverified = true;
|
|
182
|
+
blocker = receipt.valid ? undefined : preconditionBlock(deps.context, receipt);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
116
185
|
await advisoryMemory(context, () => deps.memory(context).put(['verification', 'known-commands'], memoryKey, {
|
|
117
186
|
...identity, observations: Math.min(1_000_000, typeof known?.value.observations === 'number' ? known.value.observations + 1 : 1),
|
|
118
187
|
lastAttemptId: context.frame.attemptId, lastReceiptId: receipt.id, lastValid: receipt.valid,
|
|
119
188
|
commands: receipt.commands.map(command => ({ repositoryId: command.repositoryId, command: command.command, exitCode: command.exitCode })),
|
|
120
189
|
}));
|
|
121
|
-
const infrastructure = receipt
|
|
190
|
+
const infrastructure = infrastructureFailure(receipt);
|
|
122
191
|
const outcome = infrastructure ? 'failed' : receipt.valid ? 'pass' : 'fail';
|
|
123
192
|
const progressKey = 'verification-' + contentDigest(context.frame.nodePath).slice(0, 20);
|
|
124
193
|
const fingerprint = contentDigest(json({ candidateHash: identity.candidateHash,
|
|
@@ -127,8 +196,14 @@ export function verifyPiece(bindings) {
|
|
|
127
196
|
const count = receipt.valid ? 0 : previous?.fingerprint === fingerprint ? (previous.count ?? 0) + 1 : 1;
|
|
128
197
|
const stalled = !infrastructure && !receipt.valid && count >= 3;
|
|
129
198
|
const verified = receipt.valid && receipt.commands.length > 0 && !receipt.unverifiedRepositories?.length;
|
|
130
|
-
|
|
131
|
-
|
|
199
|
+
const output = { receiptId: receipt.id, valid: receipt.valid, candidateHash: identity.candidateHash, noProgressCount: count, ...(receipt.reason ? { reason: receipt.reason } : {}), ...(stalled ? { reason: 'Verification failed three times without candidate changes' } : {}),
|
|
200
|
+
...(setup ? { setup } : {}), ...(environmentRepair ? { environmentRepair } : {}), ...verificationDiagnostics(receipt) };
|
|
201
|
+
const vars = { [progressKey]: { fingerprint, count } };
|
|
202
|
+
// Without the opt-in outcome a blocker still travels in the output, and the no-progress stop keeps bounding the loop.
|
|
203
|
+
if (blocker && (params.hostBlockers === true || !stalled))
|
|
204
|
+
return { ...blocked(blocker, output), vars, receipt: receiptEvidence(receipt) };
|
|
205
|
+
return { outcome: stalled ? 'failed' : outcome, vars, ...(stalled ? { status: 'failed', error: { code: 'verification_no_progress', message: 'Verification failed three times on the same unchanged candidate. Stopping automatic corrections; inspect the failed checks.' } } : {}), ...(infrastructure ? executionError(receipt) : {}),
|
|
206
|
+
output: { ...output, ...(blocker ? { blocker: json(blocker) } : {}) },
|
|
132
207
|
receipt: receiptEvidence(receipt), ...(verified ? { verified: { receiptId: receipt.id, candidateHash: receipt.candidateHash, atTransition: context.frame.transition, revision: context.frame.transition } } : { verified: null }) };
|
|
133
208
|
},
|
|
134
209
|
};
|
|
@@ -109,6 +109,8 @@ export interface RuntimeConfig {
|
|
|
109
109
|
idleTimeoutMs?: number;
|
|
110
110
|
};
|
|
111
111
|
verification: VerificationCommand[];
|
|
112
|
+
/** Idempotent commands the `verify` piece runs sequentially before each verification plan (same shape as `verification`; `policy` is ignored). */
|
|
113
|
+
setup?: VerificationCommand[];
|
|
112
114
|
approvalBeforeArchive?: boolean;
|
|
113
115
|
/** Review gate thresholds (0–100); unset fields keep Core's defaults. */
|
|
114
116
|
review?: {
|