@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
|
@@ -71,6 +71,7 @@ const STAGE_ORDER = [
|
|
|
71
71
|
'review-plan',
|
|
72
72
|
'code',
|
|
73
73
|
'review-code',
|
|
74
|
+
'validation-run',
|
|
74
75
|
'task',
|
|
75
76
|
'other'
|
|
76
77
|
] as const;
|
|
@@ -81,6 +82,7 @@ function stageOf(name: string): string {
|
|
|
81
82
|
if (stem.startsWith('review-analysis')) return 'review-analysis';
|
|
82
83
|
if (stem.startsWith('review-plan')) return 'review-plan';
|
|
83
84
|
if (stem.startsWith('review-code')) return 'review-code';
|
|
85
|
+
if (stem.startsWith('validation-run')) return 'validation-run';
|
|
84
86
|
if (stem.startsWith('analysis')) return 'analysis';
|
|
85
87
|
if (stem.startsWith('plan')) return 'plan';
|
|
86
88
|
if (stem.startsWith('code')) return 'code';
|
|
@@ -0,0 +1,282 @@
|
|
|
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.ts';
|
|
7
|
+
import { detectEngine } from '../../sandbox/engine.ts';
|
|
8
|
+
import { runEngine } from '../../sandbox/shell.ts';
|
|
9
|
+
import { sandboxBranchLabel, sandboxLabel } from '../../sandbox/constants.ts';
|
|
10
|
+
import { fetchSandboxRows, selectSandboxContainer } from '../../sandbox/commands/list-running.ts';
|
|
11
|
+
import { containerNameCandidates } from '../../sandbox/constants.ts';
|
|
12
|
+
import { resolveSandboxTarget } from '../../sandbox/workspace-identity.ts';
|
|
13
|
+
import { sandboxControlPaths } from '../../sandbox/workspace-view.ts';
|
|
14
|
+
import {
|
|
15
|
+
appendSandboxControlAudit,
|
|
16
|
+
readActiveLease,
|
|
17
|
+
readSandboxControlStatus
|
|
18
|
+
} from '../../sandbox/control/state.ts';
|
|
19
|
+
import type { SandboxControlLease, SandboxControlManifest } from '../../sandbox/control/protocol.ts';
|
|
20
|
+
import {
|
|
21
|
+
SANDBOX_CONTROL_FUTURE_SKEW_MS,
|
|
22
|
+
SANDBOX_CONTROL_STATUS_STALE_MS
|
|
23
|
+
} from '../../sandbox/control/protocol.ts';
|
|
24
|
+
import { getProcessStartTime } from '../../server/process-state.ts';
|
|
25
|
+
import { assertGitWorktreeBinding } from '../../git/worktree-identity.ts';
|
|
26
|
+
|
|
27
|
+
const USAGE = `Usage: ai task validate <branch | TASK-id | N> [--scope snapshot|inplace] [--timeout <ms>] [--format text|json] -- <command> [args...]`;
|
|
28
|
+
const MAX_TIMEOUT_MS = 60 * 60 * 1000;
|
|
29
|
+
const RECOVERY_GRACE_MS = 30_000;
|
|
30
|
+
|
|
31
|
+
type ValidateOptions = {
|
|
32
|
+
target: string;
|
|
33
|
+
scope: 'snapshot' | 'inplace';
|
|
34
|
+
timeoutMs: number;
|
|
35
|
+
format: 'text' | 'json';
|
|
36
|
+
command: string[];
|
|
37
|
+
help: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function parseValidateArgs(args: string[]): ValidateOptions {
|
|
41
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
42
|
+
return { target: '', scope: 'snapshot', timeoutMs: 300_000, format: 'text', command: [], help: true };
|
|
43
|
+
}
|
|
44
|
+
const separator = args.indexOf('--');
|
|
45
|
+
if (separator < 0) throw new Error(`${USAGE}\nA literal -- must separate validation options from the command.`);
|
|
46
|
+
const optionArgs = args.slice(0, separator);
|
|
47
|
+
const command = args.slice(separator + 1);
|
|
48
|
+
let scope: ValidateOptions['scope'] = 'snapshot';
|
|
49
|
+
let timeoutMs = 300_000;
|
|
50
|
+
let format: ValidateOptions['format'] = 'text';
|
|
51
|
+
const positionals: string[] = [];
|
|
52
|
+
for (let index = 0; index < optionArgs.length; index += 1) {
|
|
53
|
+
const arg = optionArgs[index]!;
|
|
54
|
+
if (arg === '--scope') {
|
|
55
|
+
const value = optionArgs[++index];
|
|
56
|
+
if (value !== 'snapshot' && value !== 'inplace') throw new Error('validate --scope must be snapshot or inplace');
|
|
57
|
+
scope = value;
|
|
58
|
+
} else if (arg === '--timeout') {
|
|
59
|
+
const value = Number(optionArgs[++index]);
|
|
60
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMEOUT_MS) throw new Error(`validate --timeout must be 1..${MAX_TIMEOUT_MS}`);
|
|
61
|
+
timeoutMs = value;
|
|
62
|
+
} else if (arg === '--format') {
|
|
63
|
+
const value = optionArgs[++index];
|
|
64
|
+
if (value !== 'text' && value !== 'json') throw new Error('validate --format must be text or json');
|
|
65
|
+
format = value;
|
|
66
|
+
} else if (arg.startsWith('-')) {
|
|
67
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
68
|
+
} else {
|
|
69
|
+
positionals.push(arg);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (positionals.length !== 1 || command.length === 0) throw new Error(USAGE);
|
|
73
|
+
return { target: positionals[0]!, scope, timeoutMs, format, command, help: false };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function runValidationCommand(command: string[], cwd: string, scope: string, timeoutMs: number, json: boolean) {
|
|
77
|
+
const [file, ...args] = command;
|
|
78
|
+
const result = spawnSync(file!, args, {
|
|
79
|
+
cwd,
|
|
80
|
+
env: { ...process.env, AGENT_INFRA_VALIDATION_SCOPE: scope },
|
|
81
|
+
encoding: 'utf8',
|
|
82
|
+
timeout: timeoutMs,
|
|
83
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
84
|
+
shell: process.platform === 'win32' && /\.(?:bat|cmd)$/i.test(file!)
|
|
85
|
+
});
|
|
86
|
+
if (json) {
|
|
87
|
+
if (result.stdout) process.stderr.write(result.stdout);
|
|
88
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
89
|
+
} else {
|
|
90
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
91
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
92
|
+
}
|
|
93
|
+
return { exitCode: result.status ?? 1, signal: result.signal ?? null };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function git(root: string, args: string[]): string {
|
|
97
|
+
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function waitForState(statusDir: string, predicate: (state: ReturnType<typeof readSandboxControlStatus>) => boolean, timeoutMs = 5_000): void {
|
|
101
|
+
const deadline = Date.now() + timeoutMs;
|
|
102
|
+
while (Date.now() < deadline) {
|
|
103
|
+
try {
|
|
104
|
+
const status = readSandboxControlStatus(statusDir);
|
|
105
|
+
if (predicate(status)) return;
|
|
106
|
+
} catch {
|
|
107
|
+
// Atomic status publication may be between renames.
|
|
108
|
+
}
|
|
109
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
110
|
+
}
|
|
111
|
+
throw new Error('SANDBOX_VALIDATION_BROKER_STATE_TIMEOUT');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function snapshotValidation(repoRoot: string, commit: string, options: ValidateOptions) {
|
|
115
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-infra-validation-'));
|
|
116
|
+
const worktree = path.join(tempRoot, 'worktree');
|
|
117
|
+
let cleanup = 'not-created';
|
|
118
|
+
try {
|
|
119
|
+
git(repoRoot, ['worktree', 'add', '--detach', worktree, commit]);
|
|
120
|
+
cleanup = 'pending';
|
|
121
|
+
return { ...runValidationCommand(options.command, worktree, 'snapshot', options.timeoutMs, options.format === 'json'), cleanup: () => cleanup };
|
|
122
|
+
} finally {
|
|
123
|
+
let cleanupError: unknown = null;
|
|
124
|
+
if (fs.existsSync(worktree)) {
|
|
125
|
+
try {
|
|
126
|
+
git(repoRoot, ['worktree', 'remove', '--force', worktree]);
|
|
127
|
+
cleanup = 'completed';
|
|
128
|
+
} catch (error) {
|
|
129
|
+
cleanupError = error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
git(repoRoot, ['worktree', 'prune']);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
cleanupError ??= error;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
139
|
+
} catch (error) {
|
|
140
|
+
cleanupError ??= error;
|
|
141
|
+
}
|
|
142
|
+
if (cleanupError) throw cleanupError;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function inplaceValidation(config: ReturnType<typeof loadConfig>, target: ReturnType<typeof resolveSandboxTarget>, options: ValidateOptions) {
|
|
147
|
+
const engine = detectEngine(config);
|
|
148
|
+
const { running, nonRunning } = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config));
|
|
149
|
+
const row = selectSandboxContainer([...running, ...nonRunning], containerNameCandidates(config, target.branch));
|
|
150
|
+
if (!row) throw new Error('SANDBOX_VALIDATION_CONTAINER_NOT_FOUND');
|
|
151
|
+
const control = sandboxControlPaths({
|
|
152
|
+
base: config.controlBase, project: config.project,
|
|
153
|
+
container: row.name, identity: target.workspace
|
|
154
|
+
});
|
|
155
|
+
const manifest = JSON.parse(fs.readFileSync(control.manifestPath, 'utf8')) as SandboxControlManifest;
|
|
156
|
+
const initial = readSandboxControlStatus(control.statusDir);
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
if (initial.generation !== manifest.generation
|
|
159
|
+
|| now - initial.updatedAt > SANDBOX_CONTROL_STATUS_STALE_MS
|
|
160
|
+
|| initial.updatedAt > now + SANDBOX_CONTROL_FUTURE_SKEW_MS) {
|
|
161
|
+
throw new Error('SANDBOX_VALIDATION_BROKER_STATUS_STALE');
|
|
162
|
+
}
|
|
163
|
+
if (initial.state !== 'healthy' || initial.activeRequestId !== null) {
|
|
164
|
+
throw new Error('SANDBOX_VALIDATION_BROKER_NOT_IDLE');
|
|
165
|
+
}
|
|
166
|
+
const startTime = getProcessStartTime(process.pid);
|
|
167
|
+
if (!startTime) throw new Error('SANDBOX_VALIDATION_OWNER_IDENTITY_UNAVAILABLE');
|
|
168
|
+
const leasePath = path.join(control.root, 'lease.json');
|
|
169
|
+
const nonce = randomUUID();
|
|
170
|
+
const issuedAt = Date.now();
|
|
171
|
+
const lease: SandboxControlLease = {
|
|
172
|
+
version: 1, generation: manifest.generation, nonce,
|
|
173
|
+
owner: { pid: process.pid, startTime }, issuedAt,
|
|
174
|
+
expiresAt: issuedAt + Math.min(options.timeoutMs + RECOVERY_GRACE_MS, MAX_TIMEOUT_MS + RECOVERY_GRACE_MS),
|
|
175
|
+
taskId: target.workspace.mode === 'task-bound' ? target.workspace.taskId : null,
|
|
176
|
+
branch: target.branch, reason: 'manual-validation'
|
|
177
|
+
};
|
|
178
|
+
const originalBranch = git(manifest.worktreeRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
179
|
+
const originalHead = git(manifest.worktreeRoot, ['rev-parse', 'HEAD']);
|
|
180
|
+
fs.writeFileSync(leasePath, `${JSON.stringify(lease)}\n`, { flag: 'wx', mode: 0o600 });
|
|
181
|
+
let stopped = false;
|
|
182
|
+
let restored = false;
|
|
183
|
+
try {
|
|
184
|
+
appendSandboxControlAudit(manifest, 'lease-acquire', { ownerPid: process.pid });
|
|
185
|
+
waitForState(control.statusDir, (status) => status.state === 'parked' && status.reasonCode === 'SANDBOX_CONTROL_HANDOFF_ACTIVE');
|
|
186
|
+
if (row.running) {
|
|
187
|
+
runEngine(engine, 'docker', ['stop', row.name]);
|
|
188
|
+
stopped = true;
|
|
189
|
+
}
|
|
190
|
+
const refreshedRows = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config));
|
|
191
|
+
const refreshed = selectSandboxContainer(
|
|
192
|
+
[...refreshedRows.running, ...refreshedRows.nonRunning],
|
|
193
|
+
containerNameCandidates(config, target.branch)
|
|
194
|
+
);
|
|
195
|
+
if (!refreshed || refreshed.running) throw new Error('SANDBOX_VALIDATION_CONTAINER_STILL_RUNNING');
|
|
196
|
+
const activeLease = readActiveLease(manifest);
|
|
197
|
+
if (!activeLease || activeLease.nonce !== nonce) throw new Error('SANDBOX_VALIDATION_LEASE_OWNERSHIP_LOST');
|
|
198
|
+
assertGitWorktreeBinding(config.repoRoot, manifest.worktreeRoot, target.branch);
|
|
199
|
+
const result = runValidationCommand(options.command, manifest.worktreeRoot, 'inplace', options.timeoutMs, options.format === 'json');
|
|
200
|
+
return { ...result, cleanup: () => restored ? 'completed' : 'pending' };
|
|
201
|
+
} finally {
|
|
202
|
+
const recoveryErrors: string[] = [];
|
|
203
|
+
try {
|
|
204
|
+
const currentBranch = git(manifest.worktreeRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
205
|
+
if (currentBranch !== originalBranch) git(manifest.worktreeRoot, ['switch', originalBranch]);
|
|
206
|
+
} catch {
|
|
207
|
+
try {
|
|
208
|
+
git(manifest.worktreeRoot, ['checkout', '--detach', originalHead]);
|
|
209
|
+
git(manifest.worktreeRoot, ['switch', originalBranch]);
|
|
210
|
+
} catch (fallbackError) {
|
|
211
|
+
recoveryErrors.push(`git binding restore failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (stopped) {
|
|
215
|
+
try {
|
|
216
|
+
runEngine(engine, 'docker', ['start', row.name]);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
recoveryErrors.push(`container start failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const currentLease = JSON.parse(fs.readFileSync(leasePath, 'utf8')) as { nonce?: unknown };
|
|
223
|
+
if (currentLease.nonce !== nonce) throw new Error('SANDBOX_VALIDATION_LEASE_OWNERSHIP_LOST');
|
|
224
|
+
} catch (error) {
|
|
225
|
+
recoveryErrors.push(error instanceof Error ? error.message : String(error));
|
|
226
|
+
}
|
|
227
|
+
if (recoveryErrors.length === 0) {
|
|
228
|
+
try {
|
|
229
|
+
fs.unlinkSync(leasePath);
|
|
230
|
+
appendSandboxControlAudit(manifest, 'lease-release', { ownerPid: process.pid });
|
|
231
|
+
waitForState(control.statusDir, (status) => status.state === 'healthy');
|
|
232
|
+
restored = true;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
try {
|
|
235
|
+
if (!fs.existsSync(leasePath)) {
|
|
236
|
+
fs.writeFileSync(leasePath, `${JSON.stringify(lease)}\n`, { flag: 'wx', mode: 0o600 });
|
|
237
|
+
}
|
|
238
|
+
appendSandboxControlAudit(manifest, 'lease-release-failed', { ownerPid: process.pid });
|
|
239
|
+
} catch (leaseError) {
|
|
240
|
+
recoveryErrors.push(`lease preservation failed: ${leaseError instanceof Error ? leaseError.message : String(leaseError)}`);
|
|
241
|
+
}
|
|
242
|
+
recoveryErrors.push(error instanceof Error ? error.message : String(error));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (recoveryErrors.length > 0) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`SANDBOX_VALIDATION_RECOVERY_INCOMPLETE: ${recoveryErrors.join('; ')}; run 'ai sandbox start ${target.branch}'`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function validate(args: string[]): Promise<number> {
|
|
254
|
+
const options = parseValidateArgs(args);
|
|
255
|
+
if (options.help) {
|
|
256
|
+
process.stdout.write(`${USAGE}\n`);
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
const config = loadConfig();
|
|
260
|
+
const target = resolveSandboxTarget(options.target, config.repoRoot);
|
|
261
|
+
const commit = git(config.repoRoot, ['rev-parse', target.branch]);
|
|
262
|
+
const startedAt = new Date().toISOString();
|
|
263
|
+
const result = options.scope === 'snapshot'
|
|
264
|
+
? snapshotValidation(config.repoRoot, commit, options)
|
|
265
|
+
: inplaceValidation(config, target, options);
|
|
266
|
+
const evidence = {
|
|
267
|
+
version: 1,
|
|
268
|
+
taskId: target.workspace.mode === 'task-bound' ? target.workspace.taskId : null,
|
|
269
|
+
branch: target.branch,
|
|
270
|
+
scope: options.scope,
|
|
271
|
+
commit,
|
|
272
|
+
command: path.basename(options.command[0]!),
|
|
273
|
+
startedAt,
|
|
274
|
+
completedAt: new Date().toISOString(),
|
|
275
|
+
exitCode: result.exitCode,
|
|
276
|
+
signal: result.signal,
|
|
277
|
+
cleanup: result.cleanup()
|
|
278
|
+
};
|
|
279
|
+
if (options.format === 'json') process.stdout.write(`${JSON.stringify(evidence)}\n`);
|
|
280
|
+
else process.stdout.write(`Validation ${result.exitCode === 0 ? 'passed' : 'failed'} (${options.scope}, ${evidence.command}, cleanup=${evidence.cleanup}).\n`);
|
|
281
|
+
return result.exitCode;
|
|
282
|
+
}
|
|
@@ -12,7 +12,7 @@ import type { CommitAttempt } from './activity-log.ts';
|
|
|
12
12
|
import { commitIntentPath, digest, readCommitIntent } from './commit-intent.ts';
|
|
13
13
|
import type { CommitIntent } from './commit-intent.ts';
|
|
14
14
|
import { parseTypedTaskFrontmatter } from './frontmatter.ts';
|
|
15
|
-
import { parseReviewSummary } from './review-artifacts.ts';
|
|
15
|
+
import { artifactName, maxRound, parseReviewSummary } from './review-artifacts.ts';
|
|
16
16
|
import {
|
|
17
17
|
extractReviewBaseline,
|
|
18
18
|
extractReviewedSnapshotTree,
|
|
@@ -43,6 +43,7 @@ type CommitFinalizationInspection = Readonly<{
|
|
|
43
43
|
currentHead: string;
|
|
44
44
|
committedHead: string | null;
|
|
45
45
|
commitNote: string | null;
|
|
46
|
+
activityNote: string | null;
|
|
46
47
|
needsAnchor: boolean;
|
|
47
48
|
needsLog: boolean;
|
|
48
49
|
intent: CommitIntent | null;
|
|
@@ -87,6 +88,28 @@ function isAncestor(repoRoot: string, ancestor: string, descendant: string): boo
|
|
|
87
88
|
}).status === 0;
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
function hasMatchingApprovedReview(
|
|
92
|
+
taskDir: string,
|
|
93
|
+
beforeRound: number,
|
|
94
|
+
baseline: string,
|
|
95
|
+
tree: string
|
|
96
|
+
): boolean {
|
|
97
|
+
const entries = fs.readdirSync(taskDir);
|
|
98
|
+
for (let round = Math.min(beforeRound - 1, maxRound(entries, 'review-code')); round >= 1; round -= 1) {
|
|
99
|
+
const file = path.join(taskDir, artifactName('review-code', round));
|
|
100
|
+
if (!fs.existsSync(file)) continue;
|
|
101
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
102
|
+
const summary = parseReviewSummary(content);
|
|
103
|
+
if (
|
|
104
|
+
summary.ok
|
|
105
|
+
&& summary.summary.verdict === 'Approved'
|
|
106
|
+
&& extractReviewBaseline(content) === baseline
|
|
107
|
+
&& extractReviewedSnapshotTree(content) === tree
|
|
108
|
+
) return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
90
113
|
function outcome(
|
|
91
114
|
disposition: CommitFinalizationDisposition,
|
|
92
115
|
overrides: Partial<CommitFinalizationInspection> = {}
|
|
@@ -98,6 +121,7 @@ function outcome(
|
|
|
98
121
|
currentHead: '',
|
|
99
122
|
committedHead: null,
|
|
100
123
|
commitNote: null,
|
|
124
|
+
activityNote: null,
|
|
101
125
|
needsAnchor: false,
|
|
102
126
|
needsLog: false,
|
|
103
127
|
intent: null,
|
|
@@ -212,6 +236,12 @@ function inspectCommitFinalization(
|
|
|
212
236
|
});
|
|
213
237
|
}
|
|
214
238
|
|
|
239
|
+
const commitNote = git(repoRoot, ['show', '-s', '--format=%h%x20%s', intent.committedHead]);
|
|
240
|
+
const matchingDone = commitRows.filter((row) => row.done !== '' && row.note === commitNote);
|
|
241
|
+
const pushOnlyRetry = intent.phase === 'pushed'
|
|
242
|
+
&& intent.baselineHead === intent.committedHead
|
|
243
|
+
&& matchingDone.length === 1
|
|
244
|
+
&& openRows.length === 1;
|
|
215
245
|
const review = findAuthoritativeReviewCodeArtifact(taskDir);
|
|
216
246
|
let needsAnchor = false;
|
|
217
247
|
if (review.ok && review.path) {
|
|
@@ -228,7 +258,16 @@ function inspectCommitFinalization(
|
|
|
228
258
|
return invalid('authoritative review-code evidence is not a valid approved snapshot', currentHead, 'COMMIT_FINALIZATION_EVIDENCE_INVALID', shared);
|
|
229
259
|
}
|
|
230
260
|
const committedTree = git(repoRoot, ['rev-parse', `${intent.committedHead}^{tree}`]);
|
|
231
|
-
|
|
261
|
+
const matchingCurrentReview = reviewBaseline === intent.baselineHead && reviewTree === committedTree;
|
|
262
|
+
if (
|
|
263
|
+
!matchingCurrentReview
|
|
264
|
+
&& !hasMatchingApprovedReview(
|
|
265
|
+
taskDir,
|
|
266
|
+
review.round,
|
|
267
|
+
intent.baselineHead,
|
|
268
|
+
committedTree
|
|
269
|
+
)
|
|
270
|
+
) {
|
|
232
271
|
return outcome('conflict', {
|
|
233
272
|
...shared,
|
|
234
273
|
code: 'COMMIT_FINALIZATION_CONFLICT',
|
|
@@ -237,18 +276,16 @@ function inspectCommitFinalization(
|
|
|
237
276
|
}
|
|
238
277
|
|
|
239
278
|
const anchor = String(metadata.last_reviewed_commit ?? '').trim();
|
|
240
|
-
if (anchor && anchor !== intent.committedHead) {
|
|
279
|
+
if (anchor && anchor !== intent.committedHead && anchor !== intent.baselineHead) {
|
|
241
280
|
return outcome('conflict', {
|
|
242
281
|
...shared,
|
|
243
282
|
code: 'COMMIT_FINALIZATION_CONFLICT',
|
|
244
283
|
message: 'task review anchor conflicts with the committed intent'
|
|
245
284
|
});
|
|
246
285
|
}
|
|
247
|
-
needsAnchor = anchor
|
|
286
|
+
needsAnchor = anchor !== intent.committedHead;
|
|
248
287
|
}
|
|
249
|
-
const
|
|
250
|
-
const matchingDone = commitRows.filter((row) => row.done !== '' && row.note === commitNote);
|
|
251
|
-
const needsLog = matchingDone.length === 0 && openRows.length === 1;
|
|
288
|
+
const needsLog = (matchingDone.length === 0 && openRows.length === 1) || pushOnlyRetry;
|
|
252
289
|
if (!(needsLog || (matchingDone.length === 1 && openRows.length === 0))) {
|
|
253
290
|
return outcome('conflict', {
|
|
254
291
|
...shared,
|
|
@@ -261,6 +298,7 @@ function inspectCommitFinalization(
|
|
|
261
298
|
...shared,
|
|
262
299
|
message: 'commit finalization can be recovered',
|
|
263
300
|
commitNote,
|
|
301
|
+
activityNote: pushOnlyRetry ? `Pushed ${commitNote}` : commitNote,
|
|
264
302
|
needsAnchor,
|
|
265
303
|
needsLog
|
|
266
304
|
});
|
|
@@ -272,7 +310,12 @@ function planCommitTaskFinalization(
|
|
|
272
310
|
agent: string,
|
|
273
311
|
timestamp: string
|
|
274
312
|
): CommitTaskFinalizationPlan {
|
|
275
|
-
if (
|
|
313
|
+
if (
|
|
314
|
+
inspection.disposition !== 'recoverable'
|
|
315
|
+
|| !inspection.committedHead
|
|
316
|
+
|| !inspection.commitNote
|
|
317
|
+
|| !inspection.activityNote
|
|
318
|
+
) {
|
|
276
319
|
throw new Error('commit finalization task mutations require a recoverable inspection');
|
|
277
320
|
}
|
|
278
321
|
const taskContent = fs.readFileSync(path.join(taskDir, 'task.md'), 'utf8');
|
|
@@ -293,7 +336,7 @@ function planCommitTaskFinalization(
|
|
|
293
336
|
time: timestamp,
|
|
294
337
|
step: 'Commit',
|
|
295
338
|
agent,
|
|
296
|
-
note: inspection.
|
|
339
|
+
note: inspection.activityNote
|
|
297
340
|
})
|
|
298
341
|
});
|
|
299
342
|
}
|
|
@@ -6,6 +6,14 @@ import { normalizeAgentToken } from '../agent-clients/tokens.ts';
|
|
|
6
6
|
type DelegationRole = 'executor' | 'reviewer';
|
|
7
7
|
type DelegationStage = 'analysis' | 'review-analysis' | 'plan' | 'review-plan' | 'code' | 'review-code' | 'commit';
|
|
8
8
|
type DelegationStatus = 'prepared' | 'activated' | 'stage-completed' | 'sealed' | 'consumed' | 'aborted' | 'expired';
|
|
9
|
+
type DelegationHostEvidence = Readonly<{
|
|
10
|
+
kind: 'codex-lifecycle-v1';
|
|
11
|
+
hookDefinitionHash: string;
|
|
12
|
+
startRevision: number;
|
|
13
|
+
stopRevision: number | null;
|
|
14
|
+
consumer: string | null;
|
|
15
|
+
consumedAt: string | null;
|
|
16
|
+
}>;
|
|
9
17
|
|
|
10
18
|
type DelegationReceipt = Readonly<{
|
|
11
19
|
id: string;
|
|
@@ -28,6 +36,7 @@ type DelegationReceipt = Readonly<{
|
|
|
28
36
|
agent: string | null;
|
|
29
37
|
status: DelegationStatus;
|
|
30
38
|
workspaceSnapshotScope?: 'task';
|
|
39
|
+
hostEvidence?: DelegationHostEvidence | null;
|
|
31
40
|
beforeFingerprint: string;
|
|
32
41
|
afterFingerprint: string | null;
|
|
33
42
|
changedPaths: readonly string[];
|
|
@@ -55,7 +64,7 @@ function fail(code: string, message: string): ReceiptFailure {
|
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
function prepareDelegation(
|
|
58
|
-
input: Omit<DelegationReceipt, 'id' | 'requestedModel' | 'requestedReasoningEffort' | 'actualModel' | 'actualReasoningEffort' | 'modelFallbackReason' | 'reasoningEffortFallbackReason' | 'parentId' | 'childId' | 'spawnMode' | 'agent' | 'status' | 'afterFingerprint' | 'changedPaths' | 'createdAt' | 'activatedAt' | 'sealedAt' | 'consumedAt'> & Readonly<{ requestedModel: string; requestedReasoningEffort: string }>,
|
|
67
|
+
input: Omit<DelegationReceipt, 'id' | 'requestedModel' | 'requestedReasoningEffort' | 'actualModel' | 'actualReasoningEffort' | 'modelFallbackReason' | 'reasoningEffortFallbackReason' | 'parentId' | 'childId' | 'spawnMode' | 'agent' | 'status' | 'hostEvidence' | 'afterFingerprint' | 'changedPaths' | 'createdAt' | 'activatedAt' | 'sealedAt' | 'consumedAt'> & Readonly<{ requestedModel: string; requestedReasoningEffort: string }>,
|
|
59
68
|
options: { id?: () => string; now?: () => string } = {}
|
|
60
69
|
): DelegationReceipt {
|
|
61
70
|
return Object.freeze({
|
|
@@ -70,6 +79,7 @@ function prepareDelegation(
|
|
|
70
79
|
spawnMode: null,
|
|
71
80
|
agent: null,
|
|
72
81
|
status: 'prepared' as const,
|
|
82
|
+
hostEvidence: null,
|
|
73
83
|
afterFingerprint: null,
|
|
74
84
|
changedPaths: Object.freeze([]),
|
|
75
85
|
createdAt: (options.now ?? (() => new Date().toISOString()))(),
|
|
@@ -90,6 +100,11 @@ function activateDelegation(
|
|
|
90
100
|
actualReasoningEffort?: string;
|
|
91
101
|
modelFallbackReason?: string;
|
|
92
102
|
reasoningEffortFallbackReason?: string;
|
|
103
|
+
hostEvidence?: Readonly<{
|
|
104
|
+
kind: 'codex-lifecycle-v1';
|
|
105
|
+
hookDefinitionHash: string;
|
|
106
|
+
startRevision: number;
|
|
107
|
+
}>;
|
|
93
108
|
}>,
|
|
94
109
|
options: { now?: () => string } = {}
|
|
95
110
|
): ReceiptResult {
|
|
@@ -124,6 +139,15 @@ function activateDelegation(
|
|
|
124
139
|
if (actualReasoningEffort === receipt.requestedReasoningEffort && event.reasoningEffortFallbackReason) {
|
|
125
140
|
return fail('DELEGATION_REASONING_EFFORT_FALLBACK_INVALID', 'reasoning-effort fallback reason is only valid when actual effort differs');
|
|
126
141
|
}
|
|
142
|
+
if (
|
|
143
|
+
event.hostEvidence
|
|
144
|
+
&& (
|
|
145
|
+
receipt.client !== 'codex'
|
|
146
|
+
|| !event.hostEvidence.hookDefinitionHash.trim()
|
|
147
|
+
|| !Number.isSafeInteger(event.hostEvidence.startRevision)
|
|
148
|
+
|| event.hostEvidence.startRevision < 1
|
|
149
|
+
)
|
|
150
|
+
) return fail('DELEGATION_HOST_EVIDENCE_INVALID', 'Codex start evidence reference is invalid');
|
|
127
151
|
return { ok: true, receipt: Object.freeze({
|
|
128
152
|
...receipt,
|
|
129
153
|
status: 'activated',
|
|
@@ -134,6 +158,12 @@ function activateDelegation(
|
|
|
134
158
|
actualReasoningEffort,
|
|
135
159
|
modelFallbackReason: event.modelFallbackReason ?? null,
|
|
136
160
|
reasoningEffortFallbackReason: event.reasoningEffortFallbackReason ?? null,
|
|
161
|
+
hostEvidence: event.hostEvidence ? Object.freeze({
|
|
162
|
+
...event.hostEvidence,
|
|
163
|
+
stopRevision: null,
|
|
164
|
+
consumer: null,
|
|
165
|
+
consumedAt: null
|
|
166
|
+
}) : receipt.hostEvidence ?? null,
|
|
137
167
|
activatedAt: (options.now ?? (() => new Date().toISOString()))()
|
|
138
168
|
}) };
|
|
139
169
|
}
|
|
@@ -154,7 +184,13 @@ function completeDelegationStage(
|
|
|
154
184
|
|
|
155
185
|
function sealDelegation(
|
|
156
186
|
receipt: DelegationReceipt,
|
|
157
|
-
event: Readonly<{
|
|
187
|
+
event: Readonly<{
|
|
188
|
+
childId: string;
|
|
189
|
+
exitCode: number;
|
|
190
|
+
afterFingerprint: string;
|
|
191
|
+
changedPaths: readonly string[];
|
|
192
|
+
hostEvidence?: Readonly<{ stopRevision: number; consumer: string; consumedAt: string }>;
|
|
193
|
+
}>,
|
|
158
194
|
options: { now?: () => string } = {}
|
|
159
195
|
): ReceiptResult {
|
|
160
196
|
if (receipt.status !== 'stage-completed') return fail('DELEGATION_STATE_INVALID', `delegation ${receipt.id} is ${receipt.status}, expected stage-completed`);
|
|
@@ -169,11 +205,24 @@ function sealDelegation(
|
|
|
169
205
|
const disallowed = event.changedPaths.find((entry) => !allowed.has(entry));
|
|
170
206
|
if (disallowed) return fail('DELEGATION_REVIEWER_WRITE_FORBIDDEN', `reviewer changed forbidden path '${disallowed}'`);
|
|
171
207
|
}
|
|
208
|
+
if (event.hostEvidence) {
|
|
209
|
+
if (
|
|
210
|
+
!receipt.hostEvidence
|
|
211
|
+
|| !Number.isSafeInteger(event.hostEvidence.stopRevision)
|
|
212
|
+
|| event.hostEvidence.stopRevision <= receipt.hostEvidence.startRevision
|
|
213
|
+
|| event.hostEvidence.consumer !== receipt.id
|
|
214
|
+
|| !event.hostEvidence.consumedAt.trim()
|
|
215
|
+
) return fail('DELEGATION_HOST_EVIDENCE_INVALID', 'Codex stop evidence reference is invalid');
|
|
216
|
+
}
|
|
172
217
|
return { ok: true, receipt: Object.freeze({
|
|
173
218
|
...receipt,
|
|
174
219
|
status: 'sealed',
|
|
175
220
|
afterFingerprint: event.afterFingerprint,
|
|
176
221
|
changedPaths: Object.freeze([...event.changedPaths]),
|
|
222
|
+
hostEvidence: event.hostEvidence && receipt.hostEvidence ? Object.freeze({
|
|
223
|
+
...receipt.hostEvidence,
|
|
224
|
+
...event.hostEvidence
|
|
225
|
+
}) : receipt.hostEvidence ?? null,
|
|
177
226
|
sealedAt: (options.now ?? (() => new Date().toISOString()))()
|
|
178
227
|
}) };
|
|
179
228
|
}
|
|
@@ -196,4 +245,4 @@ export {
|
|
|
196
245
|
prepareDelegation,
|
|
197
246
|
sealDelegation
|
|
198
247
|
};
|
|
199
|
-
export type { DelegationReceipt, DelegationRole, DelegationStage, DelegationStatus, ReceiptResult };
|
|
248
|
+
export type { DelegationHostEvidence, DelegationReceipt, DelegationRole, DelegationStage, DelegationStatus, ReceiptResult };
|
package/lib/task/events.ts
CHANGED
|
@@ -35,7 +35,8 @@ const eventCatalog = [
|
|
|
35
35
|
'review-plan.started', 'review-plan.completed',
|
|
36
36
|
'code.started', 'code.completed',
|
|
37
37
|
'review-code.started', 'review-code.completed',
|
|
38
|
-
'manual-validation.started', 'manual-validation.completed'
|
|
38
|
+
'manual-validation.started', 'manual-validation.completed',
|
|
39
|
+
'validation-run.started', 'validation-run.completed'
|
|
39
40
|
] as const;
|
|
40
41
|
type TaskEventName = (typeof eventCatalog)[number];
|
|
41
42
|
type Verdict = 'approved' | 'changes-requested' | 'rejected';
|
|
@@ -83,7 +84,9 @@ const SCHEMAS: Record<TaskEventName, { required?: string[]; optional?: string[]
|
|
|
83
84
|
'review-code.started': { optional: ['round'] },
|
|
84
85
|
'review-code.completed': { required: ['artifact', 'verdict', 'blockers', 'major', 'minor', 'manualValidation'], optional: ['round', 'orchestrated'] },
|
|
85
86
|
'manual-validation.started': { optional: ['round'] },
|
|
86
|
-
'manual-validation.completed': { required: ['artifact', 'summaryResult'], optional: ['round'] }
|
|
87
|
+
'manual-validation.completed': { required: ['artifact', 'summaryResult'], optional: ['round'] },
|
|
88
|
+
'validation-run.started': { optional: ['round'] },
|
|
89
|
+
'validation-run.completed': { required: ['artifact'], optional: ['round'] }
|
|
87
90
|
};
|
|
88
91
|
|
|
89
92
|
function validateTaskEventRequest(request: TaskEventRequest): TaskEventError | null {
|
|
@@ -121,7 +124,8 @@ const FAMILY = {
|
|
|
121
124
|
'review-plan': { artifact: 'review-plan', started: ['technical-design', 'technical-design-review'], completed: ['technical-design', 'technical-design-review'], target: 'technical-design-review', label: 'Review Plan' },
|
|
122
125
|
code: { artifact: 'code', started: ['technical-design-review', 'code-review'], completed: ['technical-design-review', 'code-review'], target: 'code', label: 'Code Task' },
|
|
123
126
|
'review-code': { artifact: 'review-code', started: ['code', 'code-review', 'commit'], completed: ['code', 'code-review', 'commit'], target: 'code-review', label: 'Review Code' },
|
|
124
|
-
'manual-validation': { artifact: 'manual-validation', started: ['code-review', 'commit'], completed: ['code-review', 'commit'], target: null, label: 'Complete Manual Validation' }
|
|
127
|
+
'manual-validation': { artifact: 'manual-validation', started: ['code-review', 'commit'], completed: ['code-review', 'commit'], target: null, label: 'Complete Manual Validation' },
|
|
128
|
+
'validation-run': { artifact: 'validation-run', started: ['code-review', 'commit'], completed: ['code-review', 'commit'], target: null, label: 'Run Manual Validation' }
|
|
125
129
|
} as const;
|
|
126
130
|
type EventFamily = keyof typeof FAMILY;
|
|
127
131
|
|
|
@@ -217,6 +221,7 @@ function identity(request: TaskEventRequest) {
|
|
|
217
221
|
else if (family === 'plan') note = `Plan completed, awaiting human review → ${request.artifact}`;
|
|
218
222
|
else if (family === 'code' && request.fixFor) note = `Fixed ${request.blockers} blockers, ${request.major} major, ${request.minor} minor issues${request.manualValidation ? `, skipped ${request.manualValidation} manual-validation` : ''} → ${request.artifact}`;
|
|
219
223
|
else if (family === 'code') note = `Code implemented, ${request.filesModified} files modified, ${request.testsPassed} tests passed → ${request.artifact}`;
|
|
224
|
+
else if (family === 'validation-run') note = `Validation evidence recorded → ${request.artifact}`;
|
|
220
225
|
else {
|
|
221
226
|
const verdict = request.verdict === 'approved' ? 'Approved' : request.verdict === 'changes-requested' ? 'Changes Requested' : 'Rejected';
|
|
222
227
|
note = `Verdict: ${verdict}, blockers: ${request.blockers}, major: ${request.major}, minor: ${request.minor}, Manual-validation: ${request.manualValidation} → ${request.artifact}`;
|
|
@@ -342,7 +347,7 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
|
|
|
342
347
|
);
|
|
343
348
|
if (findingCountError) return failed(normalized, findingCountError, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
|
|
344
349
|
let orchestrationCompletion: OrchestrationStageCompletion | null = null;
|
|
345
|
-
if (eventIdentity.phase === 'completed' && eventIdentity.family !== 'manual-validation') {
|
|
350
|
+
if (eventIdentity.phase === 'completed' && eventIdentity.family !== 'manual-validation' && eventIdentity.family !== 'validation-run') {
|
|
346
351
|
const orchestrationStage = eventIdentity.family === 'analyze' ? 'analysis' : eventIdentity.family;
|
|
347
352
|
const execution = validateLifecycleExecution(normalized.taskRef, {
|
|
348
353
|
mode: normalized.orchestrated ? 'orchestrated' : 'standalone',
|
|
@@ -131,13 +131,9 @@ function finalizeImplementationInput(
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
function selectPendingImplementationInput(
|
|
134
|
-
rows: readonly ImplementationInput[]
|
|
134
|
+
rows: readonly ImplementationInput[]
|
|
135
135
|
): ImplementationInput | null {
|
|
136
|
-
const reviewEpoch = Date.parse(reviewCompletedAt.replace(' ', 'T'));
|
|
137
|
-
if (!Number.isFinite(reviewEpoch)) throw new Error('latest review completion time is invalid');
|
|
138
136
|
const pending = rows.filter((row) => row.needsImplementation && row.status === 'pending');
|
|
139
|
-
const stale = pending.find((row) => Date.parse(row.decidedAt.replace(' ', 'T')) <= reviewEpoch);
|
|
140
|
-
if (stale) throw new Error(`implementation input ${stale.id} is not later than the latest approved review`);
|
|
141
137
|
return [...pending].sort((left, right) => {
|
|
142
138
|
const time = Date.parse(left.decidedAt.replace(' ', 'T')) - Date.parse(right.decidedAt.replace(' ', 'T'));
|
|
143
139
|
return time || Number.parseInt(left.id.slice(3), 10) - Number.parseInt(right.id.slice(3), 10);
|
package/lib/task/index.ts
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
|
|
|
@@ -93,6 +95,12 @@ export async function runTask(args: string[]): Promise<void> {
|
|
|
93
95
|
status(rest);
|
|
94
96
|
break;
|
|
95
97
|
}
|
|
98
|
+
case 'validate': {
|
|
99
|
+
const { validate } = await import('./commands/validate.ts');
|
|
100
|
+
const exitCode = await validate(rest);
|
|
101
|
+
if (exitCode !== 0) process.exitCode = exitCode;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
96
104
|
default:
|
|
97
105
|
process.stderr.write(`Unknown task command: ${subcommand}\n\n`);
|
|
98
106
|
process.stdout.write(`${USAGE}\n`);
|