@fitlab-ai/agent-infra 0.9.5 → 0.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/internal-cli.js +3 -3
- package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +126 -16
- package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -2
- package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +48 -4
- package/dist/lib/agent-clients/adapters/codex.js +1 -1
- package/dist/lib/git/workflow.js +0 -1
- package/dist/lib/internal/codex-lifecycle.js +100 -2
- package/dist/lib/internal/release-workflow.js +108 -11
- package/dist/lib/internal/sandbox-control.js +43 -6
- package/dist/lib/internal/task-create.js +12 -4
- package/dist/lib/internal/task-orchestration.js +5 -4
- package/dist/lib/platform/issue-comments.js +2 -1
- package/dist/lib/release/workflow.js +29 -21
- package/dist/lib/sandbox/commands/create.js +15 -16
- package/dist/lib/sandbox/commands/prune.js +40 -16
- package/dist/lib/sandbox/commands/rm.js +318 -89
- package/dist/lib/sandbox/control/client.js +138 -22
- package/dist/lib/sandbox/control/executor.js +136 -0
- package/dist/lib/sandbox/control/lifecycle.js +348 -0
- package/dist/lib/sandbox/control/protocol.js +62 -26
- package/dist/lib/sandbox/control/server.js +222 -102
- package/dist/lib/sandbox/control/state.js +181 -0
- package/dist/lib/sandbox/index.js +2 -2
- package/dist/lib/sandbox/managed-fs.js +14 -2
- package/dist/lib/sandbox/mounts.js +35 -24
- package/dist/lib/sandbox/recovery.js +262 -45
- package/dist/lib/sandbox/workspace-view.js +73 -10
- package/dist/lib/sandbox/worktree-safety.js +196 -0
- package/dist/lib/server/process-control.js +6 -0
- package/dist/lib/task/artifact-lifecycle.js +5 -4
- package/dist/lib/task/codex-orchestration.js +161 -0
- package/dist/lib/task/commands/status.js +3 -0
- package/dist/lib/task/commands/validate.js +289 -0
- package/dist/lib/task/commit-finalization.js +36 -9
- package/dist/lib/task/delegation-receipts.js +25 -0
- package/dist/lib/task/events.js +10 -4
- package/dist/lib/task/finalization-artifacts.js +2 -1
- package/dist/lib/task/implementation-inputs.js +1 -7
- package/dist/lib/task/index.js +9 -0
- package/dist/lib/task/orchestration.js +133 -4
- package/dist/lib/task/verification-engine.js +38 -6
- package/dist/lib/task/verification.js +1 -0
- package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +142 -15
- package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -3
- package/lib/agent-clients/adapters/codex-lifecycle/store.ts +51 -3
- package/lib/agent-clients/adapters/codex.ts +1 -1
- package/lib/git/workflow.ts +0 -1
- package/lib/internal/codex-lifecycle.ts +107 -3
- package/lib/internal/release-workflow.ts +107 -11
- package/lib/internal/sandbox-control.ts +38 -6
- package/lib/internal/task-create.ts +16 -4
- package/lib/internal/task-orchestration.ts +4 -4
- package/lib/platform/issue-comments.ts +2 -1
- package/lib/release/workflow.ts +69 -20
- package/lib/sandbox/commands/create.ts +17 -21
- package/lib/sandbox/commands/prune.ts +53 -15
- package/lib/sandbox/commands/rm.ts +368 -103
- package/lib/sandbox/control/client.ts +152 -28
- package/lib/sandbox/control/executor.ts +163 -0
- package/lib/sandbox/control/lifecycle.ts +342 -0
- package/lib/sandbox/control/protocol.ts +96 -69
- package/lib/sandbox/control/server.ts +239 -110
- package/lib/sandbox/control/state.ts +196 -0
- package/lib/sandbox/index.ts +2 -2
- package/lib/sandbox/managed-fs.ts +17 -2
- package/lib/sandbox/mounts.ts +31 -13
- package/lib/sandbox/recovery.ts +301 -60
- package/lib/sandbox/workspace-view.ts +81 -11
- package/lib/sandbox/worktree-safety.ts +234 -0
- package/lib/server/process-control.ts +11 -0
- package/lib/task/artifact-lifecycle.ts +5 -4
- package/lib/task/codex-orchestration.ts +233 -0
- package/lib/task/commands/status.ts +2 -0
- package/lib/task/commands/validate.ts +282 -0
- package/lib/task/commit-finalization.ts +52 -9
- package/lib/task/delegation-receipts.ts +52 -3
- package/lib/task/events.ts +9 -4
- package/lib/task/finalization-artifacts.ts +2 -1
- package/lib/task/implementation-inputs.ts +1 -5
- package/lib/task/index.ts +8 -0
- package/lib/task/orchestration.ts +199 -4
- package/lib/task/verification-engine.ts +42 -8
- package/lib/task/verification.ts +2 -1
- package/package.json +1 -1
- package/templates/.agents/hooks/lifecycle-delegation.js +48 -12
- package/templates/.agents/rules/lifecycle-orchestration.en.md +1 -1
- package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +1 -1
- package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
- package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
- package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
- package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
- package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
- package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
- package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -0
- package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -0
- package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +44 -0
- package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +44 -0
- package/templates/.agents/skills/run-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/run-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -3
- package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -3
- package/templates/.claude/commands/run-manual-validation.en.md +9 -0
- package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
- package/templates/.codex/hooks.json +8 -8
- package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
- package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { loadConfig } from '../../sandbox/config.js';
|
|
7
|
+
import { detectEngine } from '../../sandbox/engine.js';
|
|
8
|
+
import { runEngine } from '../../sandbox/shell.js';
|
|
9
|
+
import { sandboxBranchLabel, sandboxLabel } from '../../sandbox/constants.js';
|
|
10
|
+
import { fetchSandboxRows, selectSandboxContainer } from '../../sandbox/commands/list-running.js';
|
|
11
|
+
import { containerNameCandidates } from '../../sandbox/constants.js';
|
|
12
|
+
import { resolveSandboxTarget } from '../../sandbox/workspace-identity.js';
|
|
13
|
+
import { sandboxControlPaths } from '../../sandbox/workspace-view.js';
|
|
14
|
+
import { appendSandboxControlAudit, readActiveLease, readSandboxControlStatus } from '../../sandbox/control/state.js';
|
|
15
|
+
import { SANDBOX_CONTROL_FUTURE_SKEW_MS, SANDBOX_CONTROL_STATUS_STALE_MS } from '../../sandbox/control/protocol.js';
|
|
16
|
+
import { getProcessStartTime } from '../../server/process-state.js';
|
|
17
|
+
import { assertGitWorktreeBinding } from '../../git/worktree-identity.js';
|
|
18
|
+
const USAGE = `Usage: ai task validate <branch | TASK-id | N> [--scope snapshot|inplace] [--timeout <ms>] [--format text|json] -- <command> [args...]`;
|
|
19
|
+
const MAX_TIMEOUT_MS = 60 * 60 * 1000;
|
|
20
|
+
const RECOVERY_GRACE_MS = 30_000;
|
|
21
|
+
export function parseValidateArgs(args) {
|
|
22
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
23
|
+
return { target: '', scope: 'snapshot', timeoutMs: 300_000, format: 'text', command: [], help: true };
|
|
24
|
+
}
|
|
25
|
+
const separator = args.indexOf('--');
|
|
26
|
+
if (separator < 0)
|
|
27
|
+
throw new Error(`${USAGE}\nA literal -- must separate validation options from the command.`);
|
|
28
|
+
const optionArgs = args.slice(0, separator);
|
|
29
|
+
const command = args.slice(separator + 1);
|
|
30
|
+
let scope = 'snapshot';
|
|
31
|
+
let timeoutMs = 300_000;
|
|
32
|
+
let format = 'text';
|
|
33
|
+
const positionals = [];
|
|
34
|
+
for (let index = 0; index < optionArgs.length; index += 1) {
|
|
35
|
+
const arg = optionArgs[index];
|
|
36
|
+
if (arg === '--scope') {
|
|
37
|
+
const value = optionArgs[++index];
|
|
38
|
+
if (value !== 'snapshot' && value !== 'inplace')
|
|
39
|
+
throw new Error('validate --scope must be snapshot or inplace');
|
|
40
|
+
scope = value;
|
|
41
|
+
}
|
|
42
|
+
else if (arg === '--timeout') {
|
|
43
|
+
const value = Number(optionArgs[++index]);
|
|
44
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMEOUT_MS)
|
|
45
|
+
throw new Error(`validate --timeout must be 1..${MAX_TIMEOUT_MS}`);
|
|
46
|
+
timeoutMs = value;
|
|
47
|
+
}
|
|
48
|
+
else if (arg === '--format') {
|
|
49
|
+
const value = optionArgs[++index];
|
|
50
|
+
if (value !== 'text' && value !== 'json')
|
|
51
|
+
throw new Error('validate --format must be text or json');
|
|
52
|
+
format = value;
|
|
53
|
+
}
|
|
54
|
+
else if (arg.startsWith('-')) {
|
|
55
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
positionals.push(arg);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (positionals.length !== 1 || command.length === 0)
|
|
62
|
+
throw new Error(USAGE);
|
|
63
|
+
return { target: positionals[0], scope, timeoutMs, format, command, help: false };
|
|
64
|
+
}
|
|
65
|
+
function runValidationCommand(command, cwd, scope, timeoutMs, json) {
|
|
66
|
+
const [file, ...args] = command;
|
|
67
|
+
const result = spawnSync(file, args, {
|
|
68
|
+
cwd,
|
|
69
|
+
env: { ...process.env, AGENT_INFRA_VALIDATION_SCOPE: scope },
|
|
70
|
+
encoding: 'utf8',
|
|
71
|
+
timeout: timeoutMs,
|
|
72
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
73
|
+
shell: process.platform === 'win32' && /\.(?:bat|cmd)$/i.test(file)
|
|
74
|
+
});
|
|
75
|
+
if (json) {
|
|
76
|
+
if (result.stdout)
|
|
77
|
+
process.stderr.write(result.stdout);
|
|
78
|
+
if (result.stderr)
|
|
79
|
+
process.stderr.write(result.stderr);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
if (result.stdout)
|
|
83
|
+
process.stdout.write(result.stdout);
|
|
84
|
+
if (result.stderr)
|
|
85
|
+
process.stderr.write(result.stderr);
|
|
86
|
+
}
|
|
87
|
+
return { exitCode: result.status ?? 1, signal: result.signal ?? null };
|
|
88
|
+
}
|
|
89
|
+
function git(root, args) {
|
|
90
|
+
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
91
|
+
}
|
|
92
|
+
function waitForState(statusDir, predicate, timeoutMs = 5_000) {
|
|
93
|
+
const deadline = Date.now() + timeoutMs;
|
|
94
|
+
while (Date.now() < deadline) {
|
|
95
|
+
try {
|
|
96
|
+
const status = readSandboxControlStatus(statusDir);
|
|
97
|
+
if (predicate(status))
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Atomic status publication may be between renames.
|
|
102
|
+
}
|
|
103
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
104
|
+
}
|
|
105
|
+
throw new Error('SANDBOX_VALIDATION_BROKER_STATE_TIMEOUT');
|
|
106
|
+
}
|
|
107
|
+
function snapshotValidation(repoRoot, commit, options) {
|
|
108
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-infra-validation-'));
|
|
109
|
+
const worktree = path.join(tempRoot, 'worktree');
|
|
110
|
+
let cleanup = 'not-created';
|
|
111
|
+
try {
|
|
112
|
+
git(repoRoot, ['worktree', 'add', '--detach', worktree, commit]);
|
|
113
|
+
cleanup = 'pending';
|
|
114
|
+
return { ...runValidationCommand(options.command, worktree, 'snapshot', options.timeoutMs, options.format === 'json'), cleanup: () => cleanup };
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
let cleanupError = null;
|
|
118
|
+
if (fs.existsSync(worktree)) {
|
|
119
|
+
try {
|
|
120
|
+
git(repoRoot, ['worktree', 'remove', '--force', worktree]);
|
|
121
|
+
cleanup = 'completed';
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
cleanupError = error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
git(repoRoot, ['worktree', 'prune']);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
cleanupError ??= error;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
cleanupError ??= error;
|
|
138
|
+
}
|
|
139
|
+
if (cleanupError)
|
|
140
|
+
throw cleanupError;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function inplaceValidation(config, target, options) {
|
|
144
|
+
const engine = detectEngine(config);
|
|
145
|
+
const { running, nonRunning } = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config));
|
|
146
|
+
const row = selectSandboxContainer([...running, ...nonRunning], containerNameCandidates(config, target.branch));
|
|
147
|
+
if (!row)
|
|
148
|
+
throw new Error('SANDBOX_VALIDATION_CONTAINER_NOT_FOUND');
|
|
149
|
+
const control = sandboxControlPaths({
|
|
150
|
+
base: config.controlBase, project: config.project,
|
|
151
|
+
container: row.name, identity: target.workspace
|
|
152
|
+
});
|
|
153
|
+
const manifest = JSON.parse(fs.readFileSync(control.manifestPath, 'utf8'));
|
|
154
|
+
const initial = readSandboxControlStatus(control.statusDir);
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
if (initial.generation !== manifest.generation
|
|
157
|
+
|| now - initial.updatedAt > SANDBOX_CONTROL_STATUS_STALE_MS
|
|
158
|
+
|| initial.updatedAt > now + SANDBOX_CONTROL_FUTURE_SKEW_MS) {
|
|
159
|
+
throw new Error('SANDBOX_VALIDATION_BROKER_STATUS_STALE');
|
|
160
|
+
}
|
|
161
|
+
if (initial.state !== 'healthy' || initial.activeRequestId !== null) {
|
|
162
|
+
throw new Error('SANDBOX_VALIDATION_BROKER_NOT_IDLE');
|
|
163
|
+
}
|
|
164
|
+
const startTime = getProcessStartTime(process.pid);
|
|
165
|
+
if (!startTime)
|
|
166
|
+
throw new Error('SANDBOX_VALIDATION_OWNER_IDENTITY_UNAVAILABLE');
|
|
167
|
+
const leasePath = path.join(control.root, 'lease.json');
|
|
168
|
+
const nonce = randomUUID();
|
|
169
|
+
const issuedAt = Date.now();
|
|
170
|
+
const lease = {
|
|
171
|
+
version: 1, generation: manifest.generation, nonce,
|
|
172
|
+
owner: { pid: process.pid, startTime }, issuedAt,
|
|
173
|
+
expiresAt: issuedAt + Math.min(options.timeoutMs + RECOVERY_GRACE_MS, MAX_TIMEOUT_MS + RECOVERY_GRACE_MS),
|
|
174
|
+
taskId: target.workspace.mode === 'task-bound' ? target.workspace.taskId : null,
|
|
175
|
+
branch: target.branch, reason: 'manual-validation'
|
|
176
|
+
};
|
|
177
|
+
const originalBranch = git(manifest.worktreeRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
178
|
+
const originalHead = git(manifest.worktreeRoot, ['rev-parse', 'HEAD']);
|
|
179
|
+
fs.writeFileSync(leasePath, `${JSON.stringify(lease)}\n`, { flag: 'wx', mode: 0o600 });
|
|
180
|
+
let stopped = false;
|
|
181
|
+
let restored = false;
|
|
182
|
+
try {
|
|
183
|
+
appendSandboxControlAudit(manifest, 'lease-acquire', { ownerPid: process.pid });
|
|
184
|
+
waitForState(control.statusDir, (status) => status.state === 'parked' && status.reasonCode === 'SANDBOX_CONTROL_HANDOFF_ACTIVE');
|
|
185
|
+
if (row.running) {
|
|
186
|
+
runEngine(engine, 'docker', ['stop', row.name]);
|
|
187
|
+
stopped = true;
|
|
188
|
+
}
|
|
189
|
+
const refreshedRows = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config));
|
|
190
|
+
const refreshed = selectSandboxContainer([...refreshedRows.running, ...refreshedRows.nonRunning], containerNameCandidates(config, target.branch));
|
|
191
|
+
if (!refreshed || refreshed.running)
|
|
192
|
+
throw new Error('SANDBOX_VALIDATION_CONTAINER_STILL_RUNNING');
|
|
193
|
+
const activeLease = readActiveLease(manifest);
|
|
194
|
+
if (!activeLease || activeLease.nonce !== nonce)
|
|
195
|
+
throw new Error('SANDBOX_VALIDATION_LEASE_OWNERSHIP_LOST');
|
|
196
|
+
assertGitWorktreeBinding(config.repoRoot, manifest.worktreeRoot, target.branch);
|
|
197
|
+
const result = runValidationCommand(options.command, manifest.worktreeRoot, 'inplace', options.timeoutMs, options.format === 'json');
|
|
198
|
+
return { ...result, cleanup: () => restored ? 'completed' : 'pending' };
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
const recoveryErrors = [];
|
|
202
|
+
try {
|
|
203
|
+
const currentBranch = git(manifest.worktreeRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
204
|
+
if (currentBranch !== originalBranch)
|
|
205
|
+
git(manifest.worktreeRoot, ['switch', originalBranch]);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
try {
|
|
209
|
+
git(manifest.worktreeRoot, ['checkout', '--detach', originalHead]);
|
|
210
|
+
git(manifest.worktreeRoot, ['switch', originalBranch]);
|
|
211
|
+
}
|
|
212
|
+
catch (fallbackError) {
|
|
213
|
+
recoveryErrors.push(`git binding restore failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (stopped) {
|
|
217
|
+
try {
|
|
218
|
+
runEngine(engine, 'docker', ['start', row.name]);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
recoveryErrors.push(`container start failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
const currentLease = JSON.parse(fs.readFileSync(leasePath, 'utf8'));
|
|
226
|
+
if (currentLease.nonce !== nonce)
|
|
227
|
+
throw new Error('SANDBOX_VALIDATION_LEASE_OWNERSHIP_LOST');
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
recoveryErrors.push(error instanceof Error ? error.message : String(error));
|
|
231
|
+
}
|
|
232
|
+
if (recoveryErrors.length === 0) {
|
|
233
|
+
try {
|
|
234
|
+
fs.unlinkSync(leasePath);
|
|
235
|
+
appendSandboxControlAudit(manifest, 'lease-release', { ownerPid: process.pid });
|
|
236
|
+
waitForState(control.statusDir, (status) => status.state === 'healthy');
|
|
237
|
+
restored = true;
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
try {
|
|
241
|
+
if (!fs.existsSync(leasePath)) {
|
|
242
|
+
fs.writeFileSync(leasePath, `${JSON.stringify(lease)}\n`, { flag: 'wx', mode: 0o600 });
|
|
243
|
+
}
|
|
244
|
+
appendSandboxControlAudit(manifest, 'lease-release-failed', { ownerPid: process.pid });
|
|
245
|
+
}
|
|
246
|
+
catch (leaseError) {
|
|
247
|
+
recoveryErrors.push(`lease preservation failed: ${leaseError instanceof Error ? leaseError.message : String(leaseError)}`);
|
|
248
|
+
}
|
|
249
|
+
recoveryErrors.push(error instanceof Error ? error.message : String(error));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (recoveryErrors.length > 0) {
|
|
253
|
+
throw new Error(`SANDBOX_VALIDATION_RECOVERY_INCOMPLETE: ${recoveryErrors.join('; ')}; run 'ai sandbox start ${target.branch}'`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
export async function validate(args) {
|
|
258
|
+
const options = parseValidateArgs(args);
|
|
259
|
+
if (options.help) {
|
|
260
|
+
process.stdout.write(`${USAGE}\n`);
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
const config = loadConfig();
|
|
264
|
+
const target = resolveSandboxTarget(options.target, config.repoRoot);
|
|
265
|
+
const commit = git(config.repoRoot, ['rev-parse', target.branch]);
|
|
266
|
+
const startedAt = new Date().toISOString();
|
|
267
|
+
const result = options.scope === 'snapshot'
|
|
268
|
+
? snapshotValidation(config.repoRoot, commit, options)
|
|
269
|
+
: inplaceValidation(config, target, options);
|
|
270
|
+
const evidence = {
|
|
271
|
+
version: 1,
|
|
272
|
+
taskId: target.workspace.mode === 'task-bound' ? target.workspace.taskId : null,
|
|
273
|
+
branch: target.branch,
|
|
274
|
+
scope: options.scope,
|
|
275
|
+
commit,
|
|
276
|
+
command: path.basename(options.command[0]),
|
|
277
|
+
startedAt,
|
|
278
|
+
completedAt: new Date().toISOString(),
|
|
279
|
+
exitCode: result.exitCode,
|
|
280
|
+
signal: result.signal,
|
|
281
|
+
cleanup: result.cleanup()
|
|
282
|
+
};
|
|
283
|
+
if (options.format === 'json')
|
|
284
|
+
process.stdout.write(`${JSON.stringify(evidence)}\n`);
|
|
285
|
+
else
|
|
286
|
+
process.stdout.write(`Validation ${result.exitCode === 0 ? 'passed' : 'failed'} (${options.scope}, ${evidence.command}, cleanup=${evidence.cleanup}).\n`);
|
|
287
|
+
return result.exitCode;
|
|
288
|
+
}
|
|
289
|
+
//# sourceMappingURL=validate.js.map
|
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { locateActivityLog, appendActivityEntry, pairEntries, parseCommitAttemptStarted } from './activity-log.js';
|
|
5
5
|
import { commitIntentPath, digest, readCommitIntent } from './commit-intent.js';
|
|
6
6
|
import { parseTypedTaskFrontmatter } from './frontmatter.js';
|
|
7
|
-
import { parseReviewSummary } from './review-artifacts.js';
|
|
7
|
+
import { artifactName, maxRound, parseReviewSummary } from './review-artifacts.js';
|
|
8
8
|
import { extractReviewBaseline, extractReviewedSnapshotTree, findAuthoritativeReviewCodeArtifact, parseReviewedGitTree } from './review-fingerprint.js';
|
|
9
9
|
const SHA_RE = /^[a-f0-9]{40,64}$/;
|
|
10
10
|
function git(repoRoot, args) {
|
|
@@ -26,6 +26,22 @@ function isAncestor(repoRoot, ancestor, descendant) {
|
|
|
26
26
|
stdio: 'ignore'
|
|
27
27
|
}).status === 0;
|
|
28
28
|
}
|
|
29
|
+
function hasMatchingApprovedReview(taskDir, beforeRound, baseline, tree) {
|
|
30
|
+
const entries = fs.readdirSync(taskDir);
|
|
31
|
+
for (let round = Math.min(beforeRound - 1, maxRound(entries, 'review-code')); round >= 1; round -= 1) {
|
|
32
|
+
const file = path.join(taskDir, artifactName('review-code', round));
|
|
33
|
+
if (!fs.existsSync(file))
|
|
34
|
+
continue;
|
|
35
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
36
|
+
const summary = parseReviewSummary(content);
|
|
37
|
+
if (summary.ok
|
|
38
|
+
&& summary.summary.verdict === 'Approved'
|
|
39
|
+
&& extractReviewBaseline(content) === baseline
|
|
40
|
+
&& extractReviewedSnapshotTree(content) === tree)
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
29
45
|
function outcome(disposition, overrides = {}) {
|
|
30
46
|
return {
|
|
31
47
|
disposition,
|
|
@@ -34,6 +50,7 @@ function outcome(disposition, overrides = {}) {
|
|
|
34
50
|
currentHead: '',
|
|
35
51
|
committedHead: null,
|
|
36
52
|
commitNote: null,
|
|
53
|
+
activityNote: null,
|
|
37
54
|
needsAnchor: false,
|
|
38
55
|
needsLog: false,
|
|
39
56
|
intent: null,
|
|
@@ -130,6 +147,12 @@ function inspectCommitFinalization(taskDir, repoRoot, taskId) {
|
|
|
130
147
|
message: 'repository HEAD or ancestry conflicts with the commit intent'
|
|
131
148
|
});
|
|
132
149
|
}
|
|
150
|
+
const commitNote = git(repoRoot, ['show', '-s', '--format=%h%x20%s', intent.committedHead]);
|
|
151
|
+
const matchingDone = commitRows.filter((row) => row.done !== '' && row.note === commitNote);
|
|
152
|
+
const pushOnlyRetry = intent.phase === 'pushed'
|
|
153
|
+
&& intent.baselineHead === intent.committedHead
|
|
154
|
+
&& matchingDone.length === 1
|
|
155
|
+
&& openRows.length === 1;
|
|
133
156
|
const review = findAuthoritativeReviewCodeArtifact(taskDir);
|
|
134
157
|
let needsAnchor = false;
|
|
135
158
|
if (review.ok && review.path) {
|
|
@@ -147,7 +170,9 @@ function inspectCommitFinalization(taskDir, repoRoot, taskId) {
|
|
|
147
170
|
return invalid('authoritative review-code evidence is not a valid approved snapshot', currentHead, 'COMMIT_FINALIZATION_EVIDENCE_INVALID', shared);
|
|
148
171
|
}
|
|
149
172
|
const committedTree = git(repoRoot, ['rev-parse', `${intent.committedHead}^{tree}`]);
|
|
150
|
-
|
|
173
|
+
const matchingCurrentReview = reviewBaseline === intent.baselineHead && reviewTree === committedTree;
|
|
174
|
+
if (!matchingCurrentReview
|
|
175
|
+
&& !hasMatchingApprovedReview(taskDir, review.round, intent.baselineHead, committedTree)) {
|
|
151
176
|
return outcome('conflict', {
|
|
152
177
|
...shared,
|
|
153
178
|
code: 'COMMIT_FINALIZATION_CONFLICT',
|
|
@@ -155,18 +180,16 @@ function inspectCommitFinalization(taskDir, repoRoot, taskId) {
|
|
|
155
180
|
});
|
|
156
181
|
}
|
|
157
182
|
const anchor = String(metadata.last_reviewed_commit ?? '').trim();
|
|
158
|
-
if (anchor && anchor !== intent.committedHead) {
|
|
183
|
+
if (anchor && anchor !== intent.committedHead && anchor !== intent.baselineHead) {
|
|
159
184
|
return outcome('conflict', {
|
|
160
185
|
...shared,
|
|
161
186
|
code: 'COMMIT_FINALIZATION_CONFLICT',
|
|
162
187
|
message: 'task review anchor conflicts with the committed intent'
|
|
163
188
|
});
|
|
164
189
|
}
|
|
165
|
-
needsAnchor = anchor
|
|
190
|
+
needsAnchor = anchor !== intent.committedHead;
|
|
166
191
|
}
|
|
167
|
-
const
|
|
168
|
-
const matchingDone = commitRows.filter((row) => row.done !== '' && row.note === commitNote);
|
|
169
|
-
const needsLog = matchingDone.length === 0 && openRows.length === 1;
|
|
192
|
+
const needsLog = (matchingDone.length === 0 && openRows.length === 1) || pushOnlyRetry;
|
|
170
193
|
if (!(needsLog || (matchingDone.length === 1 && openRows.length === 0))) {
|
|
171
194
|
return outcome('conflict', {
|
|
172
195
|
...shared,
|
|
@@ -179,12 +202,16 @@ function inspectCommitFinalization(taskDir, repoRoot, taskId) {
|
|
|
179
202
|
...shared,
|
|
180
203
|
message: 'commit finalization can be recovered',
|
|
181
204
|
commitNote,
|
|
205
|
+
activityNote: pushOnlyRetry ? `Pushed ${commitNote}` : commitNote,
|
|
182
206
|
needsAnchor,
|
|
183
207
|
needsLog
|
|
184
208
|
});
|
|
185
209
|
}
|
|
186
210
|
function planCommitTaskFinalization(taskDir, inspection, agent, timestamp) {
|
|
187
|
-
if (inspection.disposition !== 'recoverable'
|
|
211
|
+
if (inspection.disposition !== 'recoverable'
|
|
212
|
+
|| !inspection.committedHead
|
|
213
|
+
|| !inspection.commitNote
|
|
214
|
+
|| !inspection.activityNote) {
|
|
188
215
|
throw new Error('commit finalization task mutations require a recoverable inspection');
|
|
189
216
|
}
|
|
190
217
|
const taskContent = fs.readFileSync(path.join(taskDir, 'task.md'), 'utf8');
|
|
@@ -207,7 +234,7 @@ function planCommitTaskFinalization(taskDir, inspection, agent, timestamp) {
|
|
|
207
234
|
time: timestamp,
|
|
208
235
|
step: 'Commit',
|
|
209
236
|
agent,
|
|
210
|
-
note: inspection.
|
|
237
|
+
note: inspection.activityNote
|
|
211
238
|
})
|
|
212
239
|
});
|
|
213
240
|
}
|
|
@@ -23,6 +23,7 @@ function prepareDelegation(input, options = {}) {
|
|
|
23
23
|
spawnMode: null,
|
|
24
24
|
agent: null,
|
|
25
25
|
status: 'prepared',
|
|
26
|
+
hostEvidence: null,
|
|
26
27
|
afterFingerprint: null,
|
|
27
28
|
changedPaths: Object.freeze([]),
|
|
28
29
|
createdAt: (options.now ?? (() => new Date().toISOString()))(),
|
|
@@ -65,6 +66,12 @@ function activateDelegation(receipt, event, options = {}) {
|
|
|
65
66
|
if (actualReasoningEffort === receipt.requestedReasoningEffort && event.reasoningEffortFallbackReason) {
|
|
66
67
|
return fail('DELEGATION_REASONING_EFFORT_FALLBACK_INVALID', 'reasoning-effort fallback reason is only valid when actual effort differs');
|
|
67
68
|
}
|
|
69
|
+
if (event.hostEvidence
|
|
70
|
+
&& (receipt.client !== 'codex'
|
|
71
|
+
|| !event.hostEvidence.hookDefinitionHash.trim()
|
|
72
|
+
|| !Number.isSafeInteger(event.hostEvidence.startRevision)
|
|
73
|
+
|| event.hostEvidence.startRevision < 1))
|
|
74
|
+
return fail('DELEGATION_HOST_EVIDENCE_INVALID', 'Codex start evidence reference is invalid');
|
|
68
75
|
return { ok: true, receipt: Object.freeze({
|
|
69
76
|
...receipt,
|
|
70
77
|
status: 'activated',
|
|
@@ -75,6 +82,12 @@ function activateDelegation(receipt, event, options = {}) {
|
|
|
75
82
|
actualReasoningEffort,
|
|
76
83
|
modelFallbackReason: event.modelFallbackReason ?? null,
|
|
77
84
|
reasoningEffortFallbackReason: event.reasoningEffortFallbackReason ?? null,
|
|
85
|
+
hostEvidence: event.hostEvidence ? Object.freeze({
|
|
86
|
+
...event.hostEvidence,
|
|
87
|
+
stopRevision: null,
|
|
88
|
+
consumer: null,
|
|
89
|
+
consumedAt: null
|
|
90
|
+
}) : receipt.hostEvidence ?? null,
|
|
78
91
|
activatedAt: (options.now ?? (() => new Date().toISOString()))()
|
|
79
92
|
}) };
|
|
80
93
|
}
|
|
@@ -105,11 +118,23 @@ function sealDelegation(receipt, event, options = {}) {
|
|
|
105
118
|
if (disallowed)
|
|
106
119
|
return fail('DELEGATION_REVIEWER_WRITE_FORBIDDEN', `reviewer changed forbidden path '${disallowed}'`);
|
|
107
120
|
}
|
|
121
|
+
if (event.hostEvidence) {
|
|
122
|
+
if (!receipt.hostEvidence
|
|
123
|
+
|| !Number.isSafeInteger(event.hostEvidence.stopRevision)
|
|
124
|
+
|| event.hostEvidence.stopRevision <= receipt.hostEvidence.startRevision
|
|
125
|
+
|| event.hostEvidence.consumer !== receipt.id
|
|
126
|
+
|| !event.hostEvidence.consumedAt.trim())
|
|
127
|
+
return fail('DELEGATION_HOST_EVIDENCE_INVALID', 'Codex stop evidence reference is invalid');
|
|
128
|
+
}
|
|
108
129
|
return { ok: true, receipt: Object.freeze({
|
|
109
130
|
...receipt,
|
|
110
131
|
status: 'sealed',
|
|
111
132
|
afterFingerprint: event.afterFingerprint,
|
|
112
133
|
changedPaths: Object.freeze([...event.changedPaths]),
|
|
134
|
+
hostEvidence: event.hostEvidence && receipt.hostEvidence ? Object.freeze({
|
|
135
|
+
...receipt.hostEvidence,
|
|
136
|
+
...event.hostEvidence
|
|
137
|
+
}) : receipt.hostEvidence ?? null,
|
|
113
138
|
sealedAt: (options.now ?? (() => new Date().toISOString()))()
|
|
114
139
|
}) };
|
|
115
140
|
}
|
package/dist/lib/task/events.js
CHANGED
|
@@ -18,7 +18,8 @@ const eventCatalog = [
|
|
|
18
18
|
'review-plan.started', 'review-plan.completed',
|
|
19
19
|
'code.started', 'code.completed',
|
|
20
20
|
'review-code.started', 'review-code.completed',
|
|
21
|
-
'manual-validation.started', 'manual-validation.completed'
|
|
21
|
+
'manual-validation.started', 'manual-validation.completed',
|
|
22
|
+
'validation-run.started', 'validation-run.completed'
|
|
22
23
|
];
|
|
23
24
|
const BASE_FIELDS = new Set(['taskRef', 'event', 'agent', 'dryRun']);
|
|
24
25
|
const SCHEMAS = {
|
|
@@ -36,7 +37,9 @@ const SCHEMAS = {
|
|
|
36
37
|
'review-code.started': { optional: ['round'] },
|
|
37
38
|
'review-code.completed': { required: ['artifact', 'verdict', 'blockers', 'major', 'minor', 'manualValidation'], optional: ['round', 'orchestrated'] },
|
|
38
39
|
'manual-validation.started': { optional: ['round'] },
|
|
39
|
-
'manual-validation.completed': { required: ['artifact', 'summaryResult'], optional: ['round'] }
|
|
40
|
+
'manual-validation.completed': { required: ['artifact', 'summaryResult'], optional: ['round'] },
|
|
41
|
+
'validation-run.started': { optional: ['round'] },
|
|
42
|
+
'validation-run.completed': { required: ['artifact'], optional: ['round'] }
|
|
40
43
|
};
|
|
41
44
|
function validateTaskEventRequest(request) {
|
|
42
45
|
if (!eventCatalog.includes(request.event))
|
|
@@ -84,7 +87,8 @@ const FAMILY = {
|
|
|
84
87
|
'review-plan': { artifact: 'review-plan', started: ['technical-design', 'technical-design-review'], completed: ['technical-design', 'technical-design-review'], target: 'technical-design-review', label: 'Review Plan' },
|
|
85
88
|
code: { artifact: 'code', started: ['technical-design-review', 'code-review'], completed: ['technical-design-review', 'code-review'], target: 'code', label: 'Code Task' },
|
|
86
89
|
'review-code': { artifact: 'review-code', started: ['code', 'code-review', 'commit'], completed: ['code', 'code-review', 'commit'], target: 'code-review', label: 'Review Code' },
|
|
87
|
-
'manual-validation': { artifact: 'manual-validation', started: ['code-review', 'commit'], completed: ['code-review', 'commit'], target: null, label: 'Complete Manual Validation' }
|
|
90
|
+
'manual-validation': { artifact: 'manual-validation', started: ['code-review', 'commit'], completed: ['code-review', 'commit'], target: null, label: 'Complete Manual Validation' },
|
|
91
|
+
'validation-run': { artifact: 'validation-run', started: ['code-review', 'commit'], completed: ['code-review', 'commit'], target: null, label: 'Run Manual Validation' }
|
|
88
92
|
};
|
|
89
93
|
const REVIEW_LEDGER_STAGES = {
|
|
90
94
|
'review-analysis': 'analysis',
|
|
@@ -180,6 +184,8 @@ function identity(request) {
|
|
|
180
184
|
note = `Fixed ${request.blockers} blockers, ${request.major} major, ${request.minor} minor issues${request.manualValidation ? `, skipped ${request.manualValidation} manual-validation` : ''} → ${request.artifact}`;
|
|
181
185
|
else if (family === 'code')
|
|
182
186
|
note = `Code implemented, ${request.filesModified} files modified, ${request.testsPassed} tests passed → ${request.artifact}`;
|
|
187
|
+
else if (family === 'validation-run')
|
|
188
|
+
note = `Validation evidence recorded → ${request.artifact}`;
|
|
183
189
|
else {
|
|
184
190
|
const verdict = request.verdict === 'approved' ? 'Approved' : request.verdict === 'changes-requested' ? 'Changes Requested' : 'Rejected';
|
|
185
191
|
note = `Verdict: ${verdict}, blockers: ${request.blockers}, major: ${request.major}, minor: ${request.minor}, Manual-validation: ${request.manualValidation} → ${request.artifact}`;
|
|
@@ -329,7 +335,7 @@ function applyTaskEventUnlocked(request, options = {}) {
|
|
|
329
335
|
if (findingCountError)
|
|
330
336
|
return failed(normalized, findingCountError, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
|
|
331
337
|
let orchestrationCompletion = null;
|
|
332
|
-
if (eventIdentity.phase === 'completed' && eventIdentity.family !== 'manual-validation') {
|
|
338
|
+
if (eventIdentity.phase === 'completed' && eventIdentity.family !== 'manual-validation' && eventIdentity.family !== 'validation-run') {
|
|
333
339
|
const orchestrationStage = eventIdentity.family === 'analyze' ? 'analysis' : eventIdentity.family;
|
|
334
340
|
const execution = validateLifecycleExecution(normalized.taskRef, {
|
|
335
341
|
mode: normalized.orchestrated ? 'orchestrated' : 'standalone',
|
|
@@ -114,14 +114,8 @@ function finalizeImplementationInput(rows, id, decisionEvidence, decidedAt) {
|
|
|
114
114
|
next.forEach(validateInput);
|
|
115
115
|
return next;
|
|
116
116
|
}
|
|
117
|
-
function selectPendingImplementationInput(rows
|
|
118
|
-
const reviewEpoch = Date.parse(reviewCompletedAt.replace(' ', 'T'));
|
|
119
|
-
if (!Number.isFinite(reviewEpoch))
|
|
120
|
-
throw new Error('latest review completion time is invalid');
|
|
117
|
+
function selectPendingImplementationInput(rows) {
|
|
121
118
|
const pending = rows.filter((row) => row.needsImplementation && row.status === 'pending');
|
|
122
|
-
const stale = pending.find((row) => Date.parse(row.decidedAt.replace(' ', 'T')) <= reviewEpoch);
|
|
123
|
-
if (stale)
|
|
124
|
-
throw new Error(`implementation input ${stale.id} is not later than the latest approved review`);
|
|
125
119
|
return [...pending].sort((left, right) => {
|
|
126
120
|
const time = Date.parse(left.decidedAt.replace(' ', 'T')) - Date.parse(right.decidedAt.replace(' ', 'T'));
|
|
127
121
|
return time || Number.parseInt(left.id.slice(3), 10) - Number.parseInt(right.id.slice(3), 10);
|
package/dist/lib/task/index.js
CHANGED
|
@@ -10,6 +10,7 @@ Commands:
|
|
|
10
10
|
ls [--all | --blocked | --completed] List tasks (default: active)
|
|
11
11
|
show [--task <ref>] Print a task.md
|
|
12
12
|
status [--task <ref>] Aggregated status view (metadata / artifacts / git / platform)
|
|
13
|
+
validate <ref> [options] -- <command> Run host validation in snapshot or inplace scope
|
|
13
14
|
|
|
14
15
|
Examples:
|
|
15
16
|
ai task cat 11 analysis
|
|
@@ -29,6 +30,7 @@ Examples:
|
|
|
29
30
|
ai task show 11
|
|
30
31
|
ai task show TASK-20260612-162737
|
|
31
32
|
ai task status 11
|
|
33
|
+
ai task validate 11 -- npm test
|
|
32
34
|
|
|
33
35
|
Run 'ai task <command> --help' for details.`;
|
|
34
36
|
export async function runTask(args) {
|
|
@@ -89,6 +91,13 @@ export async function runTask(args) {
|
|
|
89
91
|
status(rest);
|
|
90
92
|
break;
|
|
91
93
|
}
|
|
94
|
+
case 'validate': {
|
|
95
|
+
const { validate } = await import('./commands/validate.js');
|
|
96
|
+
const exitCode = await validate(rest);
|
|
97
|
+
if (exitCode !== 0)
|
|
98
|
+
process.exitCode = exitCode;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
92
101
|
default:
|
|
93
102
|
process.stderr.write(`Unknown task command: ${subcommand}\n\n`);
|
|
94
103
|
process.stdout.write(`${USAGE}\n`);
|