@frankzhang2026/opencode-android-orchestrator 1.0.5 → 1.1.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/CHANGELOG.md +67 -0
- package/README.md +76 -15
- package/dist/config/queue-policy.d.ts.map +1 -1
- package/dist/config/queue-policy.js +1 -2
- package/dist/config/queue-policy.js.map +1 -1
- package/dist/doctor/index.d.ts.map +1 -1
- package/dist/doctor/index.js +14 -3
- package/dist/doctor/index.js.map +1 -1
- package/dist/doctor/installation.d.ts +1 -1
- package/dist/doctor/installation.d.ts.map +1 -1
- package/dist/doctor/installation.js +10 -1
- package/dist/doctor/installation.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/installer/adaptive-templates.d.ts +4 -1
- package/dist/installer/adaptive-templates.d.ts.map +1 -1
- package/dist/installer/adaptive-templates.js +38 -13
- package/dist/installer/adaptive-templates.js.map +1 -1
- package/dist/installer/android-project.d.ts +3 -1
- package/dist/installer/android-project.d.ts.map +1 -1
- package/dist/installer/android-project.js.map +1 -1
- package/dist/installer/capabilities-script.d.ts +2 -0
- package/dist/installer/capabilities-script.d.ts.map +1 -0
- package/dist/installer/capabilities-script.js +77 -0
- package/dist/installer/capabilities-script.js.map +1 -0
- package/dist/installer/gradle-verification.d.ts.map +1 -1
- package/dist/installer/gradle-verification.js +13 -6
- package/dist/installer/gradle-verification.js.map +1 -1
- package/dist/installer/init.d.ts.map +1 -1
- package/dist/installer/init.js +2 -1
- package/dist/installer/init.js.map +1 -1
- package/dist/installer/opencode-config.d.ts +3 -3
- package/dist/installer/opencode-config.js +1 -1
- package/dist/installer/project-capabilities.d.ts +35 -0
- package/dist/installer/project-capabilities.d.ts.map +1 -0
- package/dist/installer/project-capabilities.js +82 -0
- package/dist/installer/project-capabilities.js.map +1 -0
- package/dist/installer/upgrade.d.ts.map +1 -1
- package/dist/installer/upgrade.js +9 -1
- package/dist/installer/upgrade.js.map +1 -1
- package/dist/queue/cli.d.ts.map +1 -1
- package/dist/queue/cli.js +32 -5
- package/dist/queue/cli.js.map +1 -1
- package/dist/queue/executor.d.ts +1 -0
- package/dist/queue/executor.d.ts.map +1 -1
- package/dist/queue/executor.js +155 -11
- package/dist/queue/executor.js.map +1 -1
- package/dist/queue/process-ownership.d.ts +12 -0
- package/dist/queue/process-ownership.d.ts.map +1 -0
- package/dist/queue/process-ownership.js +58 -0
- package/dist/queue/process-ownership.js.map +1 -0
- package/dist/queue/queue.d.ts +25 -1
- package/dist/queue/queue.d.ts.map +1 -1
- package/dist/queue/queue.js +96 -11
- package/dist/queue/queue.js.map +1 -1
- package/dist/queue/service.d.ts.map +1 -1
- package/dist/queue/service.js +19 -2
- package/dist/queue/service.js.map +1 -1
- package/dist/queue/storage.d.ts +3 -1
- package/dist/queue/storage.d.ts.map +1 -1
- package/dist/queue/storage.js +25 -15
- package/dist/queue/storage.js.map +1 -1
- package/dist/queue/supervision.d.ts +38 -0
- package/dist/queue/supervision.d.ts.map +1 -0
- package/dist/queue/supervision.js +247 -0
- package/dist/queue/supervision.js.map +1 -0
- package/dist/queue/tools.d.ts.map +1 -1
- package/dist/queue/tools.js +4 -2
- package/dist/queue/tools.js.map +1 -1
- package/docs/MIGRATION.md +110 -9
- package/docs/QUEUE.md +350 -16
- package/docs/SECURITY.md +5 -2
- package/docs/TROUBLESHOOTING.md +82 -8
- package/package.json +1 -1
- package/templates/.opencode/agents/scheduled-coder.md +14 -1
- package/templates/.opencode/agents/scheduled-planner.md +64 -3
- package/templates/.opencode/skills/scheduled-quality-coder/SKILL.md +29 -3
- package/templates/.opencode/skills/scheduled-quality-orchestrator/SKILL.md +65 -4
- package/templates/.opencode/skills/scheduled-quality-reviewer/SKILL.md +19 -1
- package/templates/AGENTS.md.fragment +10 -4
- package/templates/README.md +3 -1
- package/templates/automation/config.schema.json +212 -22
- package/templates/automation/task-contract.schema.json +573 -49
- package/templates/automation/tasks/TASK-TEMPLATE.json.example +9 -4
- package/templates/automation/verification/collect.init.gradle +98 -0
- package/templates/automation/verification/contract.cjs +93 -0
- package/templates/automation/verification/inventory.cjs +433 -0
- package/templates/automation/verification/project.cjs +172 -0
- package/templates/automation/verification/recovery.cjs +426 -0
- package/templates/scripts/automation/acceptance-report.sh +17 -0
- package/templates/scripts/automation/claim-task.sh +35 -2
- package/templates/scripts/automation/integration-scope-gate.sh +3 -3
- package/templates/scripts/automation/lib.sh +61 -19
- package/templates/scripts/automation/orchestrate-task.sh +7 -1
- package/templates/scripts/automation/preflight.sh +1 -2
- package/templates/scripts/automation/quality-gate.sh +10 -1
- package/templates/scripts/automation/record-red.sh +13 -1
- package/templates/scripts/automation/resume-task.sh +3 -0
- package/templates/scripts/automation/scope-gate.sh +4 -4
- package/templates/scripts/automation/show-acceptance-review.sh +1 -0
- package/templates/scripts/automation/status.sh +32 -0
- package/templates/scripts/automation/submit-review.sh +10 -1
- package/templates/scripts/automation/validate-contract.sh +10 -1
- package/templates/scripts/automation/verify-integration.sh +3 -0
- package/templates/scripts/automation/verify-task.sh +3 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const cp = require("node:child_process");
|
|
6
|
+
const { createHash, randomUUID } = require("node:crypto");
|
|
7
|
+
const hash = value => createHash("sha256").update(value).digest("hex");
|
|
8
|
+
const digest = value => hash(JSON.stringify(value));
|
|
9
|
+
const fileHash = file => {
|
|
10
|
+
assert(fs.lstatSync(file).isFile(), `Recovery input is not a regular file: ${file}`);
|
|
11
|
+
return hash(fs.readFileSync(file));
|
|
12
|
+
};
|
|
13
|
+
const assert = (condition, message) => { if (!condition) throw new Error(message); };
|
|
14
|
+
const read = file => {
|
|
15
|
+
assert(!fs.lstatSync(file).isSymbolicLink(), `Symbolic-link recovery evidence: ${file}`);
|
|
16
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
17
|
+
};
|
|
18
|
+
function atomic(file, value, exclusive = false) {
|
|
19
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
20
|
+
const fd = fs.openSync(temporary, "wx", 0o600);
|
|
21
|
+
try { fs.writeFileSync(fd, JSON.stringify(value, null, 2) + "\n"); fs.fsyncSync(fd); }
|
|
22
|
+
finally { fs.closeSync(fd); }
|
|
23
|
+
try { if (exclusive) fs.linkSync(temporary, file); else fs.renameSync(temporary, file); }
|
|
24
|
+
finally { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); }
|
|
25
|
+
const dir = fs.openSync(path.dirname(file), "r");
|
|
26
|
+
try { fs.fsyncSync(dir); } finally { fs.closeSync(dir); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
function validateExecutionPolicy(policy) {
|
|
31
|
+
const keys = ["version", "maxRunMs", "maxStageMs", "terminationGraceMs"];
|
|
32
|
+
assert(policy && typeof policy === "object" && !Array.isArray(policy) &&
|
|
33
|
+
Object.keys(policy).length === keys.length && keys.every(key => Object.hasOwn(policy, key)), "Invalid Worker execution policy fields");
|
|
34
|
+
assert(policy.version === 1, "Unsupported Worker supervision protocol");
|
|
35
|
+
for (const key of ["maxRunMs", "maxStageMs"])
|
|
36
|
+
assert(Number.isInteger(policy[key]) && policy[key] >= 1000 && policy[key] <= 86400000, `Invalid ${key}`);
|
|
37
|
+
assert(policy.maxStageMs <= policy.maxRunMs, "Stage timeout must not exceed the run timeout");
|
|
38
|
+
assert(Number.isInteger(policy.terminationGraceMs) && policy.terminationGraceMs >= 1000 && policy.terminationGraceMs <= 60000,
|
|
39
|
+
"Invalid Worker termination grace period");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateRecoveryPolicy(policy) {
|
|
43
|
+
const keys = ["version", "scope", "maxEnvironmentRetries", "maxManualRetries", "maxElapsedMs", "initialDelayMs", "maxDelayMs", "maxSameFailureRetries"];
|
|
44
|
+
assert(policy && typeof policy === "object" && !Array.isArray(policy) &&
|
|
45
|
+
Object.keys(policy).length === keys.length && keys.every(key => Object.hasOwn(policy, key)), "Invalid recovery policy fields");
|
|
46
|
+
assert(policy.version === 1 && policy.scope === "baseline", "Only recovery protocol 1 for baseline capture is supported");
|
|
47
|
+
for (const key of ["maxEnvironmentRetries", "maxManualRetries", "maxSameFailureRetries"])
|
|
48
|
+
assert(Number.isInteger(policy[key]) && policy[key] >= 0 && policy[key] <= 3, `Invalid ${key}`);
|
|
49
|
+
assert(Number.isInteger(policy.maxElapsedMs) && policy.maxElapsedMs >= 1000 && policy.maxElapsedMs <= 86400000, "Invalid recovery elapsed-time limit");
|
|
50
|
+
assert(Number.isInteger(policy.initialDelayMs) && policy.initialDelayMs >= 1000 && policy.initialDelayMs <= 600000, "Invalid recovery initial delay");
|
|
51
|
+
assert(Number.isInteger(policy.maxDelayMs) && policy.maxDelayMs >= policy.initialDelayMs && policy.maxDelayMs <= 600000, "Invalid recovery maximum delay");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Positive, narrow signatures only. An assertion, compilation or integrity
|
|
55
|
+
// failure takes precedence over incidental network text in the same log.
|
|
56
|
+
function classifyFailure({ phase, reasonCode = "", log = "", exitCode = null }) {
|
|
57
|
+
let category = "unknown", code = reasonCode || "UNKNOWN_FAILURE", retryable = false;
|
|
58
|
+
if (["EVIDENCE_CHANGED", "INPUT_CHANGED_DURING_RUN", "UNSUPPORTED_TEST_INPUT"].includes(reasonCode)) category = "integrity";
|
|
59
|
+
else if (["BASELINE_NOT_GREEN", "BASELINE_SKIPPED", "EMPTY_BASELINE"].includes(reasonCode) ||
|
|
60
|
+
/ORCHESTRATOR_TEST_RESULT\|[^\n|]+\|\d+\|[1-9]\d*\||There were failing tests|\bAssertionError\b/.test(log)) {
|
|
61
|
+
category = phase.startsWith("baseline") ? "baselineFailure" : "implementation"; code = reasonCode || "TEST_FAILURE";
|
|
62
|
+
} else if (/Compilation (?:failed|error)|Unresolved reference|cannot find symbol|Could not compile/.test(log)) {
|
|
63
|
+
category = phase === "red" ? "testPreparation" : "buildFailure"; code = "COMPILATION_FAILURE";
|
|
64
|
+
} else if (["CASE_EXPECTATION_MISMATCH", "COVERAGE_MISMATCH", "AMBIGUOUS_IDENTITY"].includes(reasonCode)) {
|
|
65
|
+
category = phase === "red" ? "testPreparation" : "implementation";
|
|
66
|
+
} else if (/status code (?:401|403)\b|AuthenticationError|Unauthorized|invalid (?:API key|credentials)/i.test(log)) {
|
|
67
|
+
category = "configuration"; code = "AUTHENTICATION_FAILURE";
|
|
68
|
+
} else if (Number.isInteger(exitCode) && exitCode !== 0 && /(?:Received status code 429\b|HTTP(?:\/\d(?:\.\d)?)?\s+429\b|Too Many Requests)/i.test(log)) {
|
|
69
|
+
category = "environment"; code = "RATE_LIMIT"; retryable = true;
|
|
70
|
+
} else if (Number.isInteger(exitCode) && exitCode !== 0 && /java\.net\.(?:SocketTimeoutException|ConnectException|UnknownHostException)|\b(?:ECONNRESET|ETIMEDOUT|EAI_AGAIN)\b/.test(log)) {
|
|
71
|
+
category = "environment"; code = "TEMPORARY_NETWORK"; retryable = true;
|
|
72
|
+
}
|
|
73
|
+
return { phase, category, reasonCode: code, retryable, exitCode,
|
|
74
|
+
fingerprint: digest([phase, category, code]) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function retryDecision(policy, record, mode, now = Date.now(), random = Math.random) {
|
|
78
|
+
validateRecoveryPolicy(policy);
|
|
79
|
+
const last = record.attempts.at(-1);
|
|
80
|
+
if (!last || last.state !== "FAILED") return { allowed: false, reason: "No completed failed attempt" };
|
|
81
|
+
if (mode !== "auto" && mode !== "manual") return { allowed: false, reason: "Invalid retry mode" };
|
|
82
|
+
if (!Number.isFinite(record.startedAt) || now < record.startedAt || now >= record.startedAt + policy.maxElapsedMs)
|
|
83
|
+
return { allowed: false, reason: "Recovery elapsed-time budget exhausted or clock moved backwards" };
|
|
84
|
+
if (["integrity", "baselineFailure", "implementation", "testPreparation", "buildFailure", "configuration"].includes(last.failure.category))
|
|
85
|
+
return { allowed: false, reason: "Failure requires correction or a revised contract" };
|
|
86
|
+
const used = record.attempts.filter(attempt => attempt.mode === mode).length;
|
|
87
|
+
const limit = mode === "auto" ? policy.maxEnvironmentRetries : policy.maxManualRetries;
|
|
88
|
+
if (used >= limit) return { allowed: false, reason: `${mode} retry budget exhausted` };
|
|
89
|
+
if (mode === "auto" && !last.failure.retryable) return { allowed: false, reason: "Unknown or non-environment failures require explicit recovery approval" };
|
|
90
|
+
let repeated = 0;
|
|
91
|
+
for (const attempt of [...record.attempts].reverse()) {
|
|
92
|
+
if (attempt.state !== "FAILED" || attempt.failure.fingerprint !== last.failure.fingerprint) break;
|
|
93
|
+
repeated++;
|
|
94
|
+
}
|
|
95
|
+
if (mode === "auto" && repeated > policy.maxSameFailureRetries)
|
|
96
|
+
return { allowed: false, reason: "Repeated failure without stage progress" };
|
|
97
|
+
const backoff = Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** used);
|
|
98
|
+
const delay = mode === "manual" ? 0 : Math.floor(backoff * (0.75 + 0.25 * random()));
|
|
99
|
+
const nextRunAt = now + delay;
|
|
100
|
+
if (nextRunAt >= record.startedAt + policy.maxElapsedMs) return { allowed: false, reason: "Backoff exceeds recovery deadline" };
|
|
101
|
+
return { allowed: true, nextRunAt, reason: null };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function git(root, args) {
|
|
105
|
+
const result = cp.spawnSync("git", ["-C", root, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
106
|
+
assert(result.status === 0, result.stderr || "Cannot inspect baseline repository");
|
|
107
|
+
return result.stdout;
|
|
108
|
+
}
|
|
109
|
+
function helper(root, name, args = []) {
|
|
110
|
+
const result = cp.spawnSync("bash", ["-euc", 'source "$1"; shift; "$@"', "baseline-recovery", path.join(root, "scripts/automation/lib.sh"), name, ...args],
|
|
111
|
+
{ cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
112
|
+
assert(result.status === 0, result.stderr || `Baseline helper failed: ${name}`);
|
|
113
|
+
return result.stdout.trimEnd();
|
|
114
|
+
}
|
|
115
|
+
function inputBinding(root, contractFile, configFile, contract, allowProduct = false) {
|
|
116
|
+
helper(root, "automation_assert_planning_artifacts_sealed", [contract.id, root]);
|
|
117
|
+
const changed = helper(root, "automation_changed_paths_at", [root]).split("\n").filter(Boolean).sort();
|
|
118
|
+
assert(allowProduct || JSON.stringify(changed) === JSON.stringify([`automation/tasks/${contract.id}.json`, contract.planPath].sort()),
|
|
119
|
+
"Baseline recovery requires exactly the sealed plan and contract; product inputs changed");
|
|
120
|
+
const excluded = [".automation-worktree-allowlist", "automation/automation-commit-prefix",
|
|
121
|
+
...JSON.parse(helper(root, "automation_effective_worktree_allowlist_json_at", [root]))].sort();
|
|
122
|
+
const files = git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"])
|
|
123
|
+
.split("\0").filter(file => file && !excluded.includes(file)).sort();
|
|
124
|
+
const inputs = [...new Set(files)].map(file => {
|
|
125
|
+
const absolute = path.join(root, file);
|
|
126
|
+
let stat;
|
|
127
|
+
try { stat = fs.lstatSync(absolute); }
|
|
128
|
+
catch (error) {
|
|
129
|
+
// Tracked deletions are valid product changes after baseline capture.
|
|
130
|
+
// Keep their identities in the binding so restoring them breaks backoff.
|
|
131
|
+
if (allowProduct && error.code === "ENOENT") return [file, "deleted"];
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
return [file, stat.mode & 0o777, hash(stat.isSymbolicLink() ? fs.readlinkSync(absolute) : fs.readFileSync(absolute))];
|
|
135
|
+
});
|
|
136
|
+
const java = cp.spawnSync("java", ["-version"], { encoding: "utf8", timeout: 10000 });
|
|
137
|
+
assert(java.status === 0, "Cannot identify the Java toolchain for baseline recovery");
|
|
138
|
+
const environment = Object.fromEntries(["JAVA_HOME", "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "GRADLE_OPTS", "GRADLE_USER_HOME", "ANDROID_HOME", "ANDROID_SDK_ROOT"].map(key => [key, process.env[key] ?? null]));
|
|
139
|
+
const localProperties = path.join(require("./project.cjs").gradleBuildRoot(read(configFile), root), "local.properties");
|
|
140
|
+
return { contractSha256: fileHash(contractFile), configSha256: fileHash(configFile), head: git(root, ["rev-parse", "HEAD"]).trim(),
|
|
141
|
+
inputsSha256: digest(inputs), excludedPathsSha256: digest(excluded), environmentSha256: digest(environment), javaSha256: hash(java.stdout + java.stderr), node: process.version,
|
|
142
|
+
localPropertiesSha256: fs.existsSync(localProperties) ? fileHash(localProperties) : null };
|
|
143
|
+
}
|
|
144
|
+
function artifactHashes(evidence, directory) {
|
|
145
|
+
const entries = [];
|
|
146
|
+
function visit(file) {
|
|
147
|
+
const stat = fs.lstatSync(file);
|
|
148
|
+
assert(!stat.isSymbolicLink(), "Symlink in recovery evidence");
|
|
149
|
+
if (stat.isDirectory()) for (const name of fs.readdirSync(file).sort()) visit(path.join(file, name));
|
|
150
|
+
else entries.push([path.relative(evidence, file), fileHash(file)]);
|
|
151
|
+
}
|
|
152
|
+
visit(directory);
|
|
153
|
+
return entries;
|
|
154
|
+
}
|
|
155
|
+
function checkArtifacts(evidence, record) {
|
|
156
|
+
for (const attempt of record.attempts) {
|
|
157
|
+
assert(attempt.state !== "RUNNING", "Previous capture has no durable outcome; explicit process recovery is required");
|
|
158
|
+
for (const [file, sha] of attempt.files ?? []) {
|
|
159
|
+
assert(!path.isAbsolute(file) && !file.split(path.sep).includes(".."), "Invalid recovery artifact path");
|
|
160
|
+
const absolute = path.join(evidence, file);
|
|
161
|
+
assert(fs.realpathSync(absolute) === absolute, `Recovery evidence path changed: ${file}`);
|
|
162
|
+
assert(fileHash(absolute) === sha, `Recovery evidence changed: ${file}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const [file, sha] of Object.entries(record.checkpoints))
|
|
166
|
+
assert(fileHash(path.join(evidence, file)) === sha, `Baseline checkpoint changed: ${file}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function captureBaseline(contractFile, configFile, root, evidence, mode, expectedHash) {
|
|
170
|
+
const contract = read(contractFile), config = read(configFile);
|
|
171
|
+
require("./contract.cjs").validateContract(contract, config);
|
|
172
|
+
assert(contract.schemaVersion >= 5, "Baseline checkpoint recovery requires a V5 contract");
|
|
173
|
+
root = fs.realpathSync(root);
|
|
174
|
+
const ledger = path.join(evidence, "baseline-recovery.json");
|
|
175
|
+
const binding = inputBinding(root, contractFile, configFile, contract);
|
|
176
|
+
let record;
|
|
177
|
+
if (fs.existsSync(ledger)) {
|
|
178
|
+
assert(expectedHash && fileHash(ledger) === expectedHash, "Recovery ledger differs from the queue's sealed checkpoint");
|
|
179
|
+
record = read(ledger);
|
|
180
|
+
assert(digest(record.binding) === digest(binding), "Baseline inputs, HEAD, contract, configuration or toolchain changed");
|
|
181
|
+
checkArtifacts(evidence, record);
|
|
182
|
+
assert(record.state !== "COMPLETE" && !fs.existsSync(path.join(evidence, "baseline.json")), "Successful baseline cannot be overwritten");
|
|
183
|
+
const decision = retryDecision(contract.recovery, record, mode);
|
|
184
|
+
assert(decision.allowed, decision.reason);
|
|
185
|
+
if (mode === "auto") assert(record.nextRunAt !== null && Date.now() >= record.nextRunAt, "Persisted retry backoff has not elapsed");
|
|
186
|
+
} else {
|
|
187
|
+
assert(mode === "initial" && !expectedHash, "Recovery ledger is missing; budgets cannot be reset");
|
|
188
|
+
for (const file of ["baseline.json", "baseline-full.json", "baseline-discovery.json", "baseline-inventory.json", "red.json"])
|
|
189
|
+
assert(!fs.existsSync(path.join(evidence, file)), `Unexpected existing baseline evidence: ${file}`);
|
|
190
|
+
record = { version: 1, binding, startedAt: Date.now(), state: "RUNNING", attempts: [], checkpoints: {}, nextRunAt: null, modelInvocations: 0 };
|
|
191
|
+
}
|
|
192
|
+
const checkpoint = name => { record.checkpoints[name] = fileHash(path.join(evidence, name)); };
|
|
193
|
+
const persist = () => atomic(ledger, record);
|
|
194
|
+
// One retry pays once even when it completes more than one remaining stage.
|
|
195
|
+
let attemptMode = mode;
|
|
196
|
+
for (const stage of ["baseline-full", "baseline-inventory"]) {
|
|
197
|
+
if (record.checkpoints[`${stage}.json`]) continue;
|
|
198
|
+
assert(Date.now() < record.startedAt + contract.recovery.maxElapsedMs, "Recovery elapsed-time budget exhausted");
|
|
199
|
+
const attemptDir = path.join(evidence, `baseline-attempt-${record.attempts.length + 1}-${randomUUID()}`);
|
|
200
|
+
fs.mkdirSync(attemptDir);
|
|
201
|
+
const attempt = { stage, mode: attemptMode, state: "RUNNING", queueRunId: process.env.AUTOMATION_QUEUE_RUN_ID,
|
|
202
|
+
startedAt: Date.now(), directory: path.basename(attemptDir) };
|
|
203
|
+
attemptMode = "continuation";
|
|
204
|
+
record.attempts.push(attempt); record.state = "RUNNING"; record.nextRunAt = null; persist();
|
|
205
|
+
const logFile = path.join(attemptDir, "command.log"), fd = fs.openSync(logFile, "wx");
|
|
206
|
+
let result;
|
|
207
|
+
try {
|
|
208
|
+
const args = stage === "baseline-full"
|
|
209
|
+
? ["-euc", 'source "$1"; automation_run_gradle_group fullUnitTestTasks "$2"', "baseline-capture", path.join(root, "scripts/automation/lib.sh"), root]
|
|
210
|
+
: [path.join(root, "automation/verification/inventory.cjs"), "baseline", contractFile, configFile, root, evidence];
|
|
211
|
+
result = cp.spawnSync(stage === "baseline-full" ? "bash" : process.execPath, args, { cwd: root, stdio: ["ignore", fd, fd] });
|
|
212
|
+
} finally { fs.closeSync(fd); }
|
|
213
|
+
attempt.finishedAt = Date.now(); attempt.exitCode = result.status; attempt.state = result.status === 0 ? "PASSED" : "FAILED";
|
|
214
|
+
let log = fs.readFileSync(logFile, "utf8"), reasonCode = "";
|
|
215
|
+
if (stage === "baseline-inventory") {
|
|
216
|
+
if (fs.existsSync(path.join(evidence, "baseline-discovery.json"))) checkpoint("baseline-discovery.json");
|
|
217
|
+
const status = path.join(evidence, "inventory-status.json");
|
|
218
|
+
if (fs.existsSync(status)) {
|
|
219
|
+
const detail = read(status);
|
|
220
|
+
if (detail.queueRunId === process.env.AUTOMATION_QUEUE_RUN_ID && detail.attemptPath) {
|
|
221
|
+
assert(/^inventory-attempts\/baseline-[0-9]+-[a-f0-9-]+$/.test(detail.attemptPath), "Invalid baseline inventory attempt path");
|
|
222
|
+
attempt.inventoryFiles = artifactHashes(evidence, path.join(evidence, detail.attemptPath));
|
|
223
|
+
for (const [file] of attempt.inventoryFiles) if (file.endsWith("gradle.log")) log += fs.readFileSync(path.join(evidence, file), "utf8");
|
|
224
|
+
reasonCode = detail.valid ? "" : detail.reasonCode;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (digest(inputBinding(root, contractFile, configFile, contract)) !== digest(binding)) {
|
|
229
|
+
attempt.state = "FAILED"; reasonCode = "INPUT_CHANGED_DURING_RUN";
|
|
230
|
+
}
|
|
231
|
+
atomic(path.join(attemptDir, "process.json"), { exitCode: result.status, signal: result.signal, error: result.error?.message ?? null });
|
|
232
|
+
attempt.files = [...artifactHashes(evidence, attemptDir), ...(attempt.inventoryFiles ?? [])]; delete attempt.inventoryFiles;
|
|
233
|
+
if (attempt.state === "FAILED") {
|
|
234
|
+
attempt.failure = classifyFailure({ phase: stage, reasonCode, log, exitCode: result.status });
|
|
235
|
+
record.state = "FAILED";
|
|
236
|
+
const decision = retryDecision(contract.recovery, record, "auto");
|
|
237
|
+
record.nextRunAt = decision.allowed ? decision.nextRunAt : null;
|
|
238
|
+
record.waitingReason = decision.reason;
|
|
239
|
+
persist();
|
|
240
|
+
return { ok: false, record };
|
|
241
|
+
}
|
|
242
|
+
if (stage === "baseline-full") {
|
|
243
|
+
atomic(path.join(evidence, "baseline-full.json"), { taskId: contract.id, startedAt: new Date(attempt.startedAt).toISOString(),
|
|
244
|
+
finishedAt: new Date(attempt.finishedAt).toISOString(), head: binding.head, worktree: root,
|
|
245
|
+
cwd: require("./project.cjs").gradleBuildRoot(config, root),
|
|
246
|
+
command: ["./gradlew", ...config.gradleVerification.fullUnitTestTasks, ...(process.env.AUTOMATION_WORKER_TOKEN ? ["--no-daemon"] : [])], unitTestsEnabled: true, exitCode: 0 }, true);
|
|
247
|
+
const { matchesPath } = require("./contract.cjs");
|
|
248
|
+
const protectedFiles = git(root, ["ls-files", "-z"]).split("\0").filter(file => file && config.protectedPaths.some(pattern => matchesPath(pattern, file)));
|
|
249
|
+
fs.writeFileSync(path.join(evidence, "protected.sha256"), protectedFiles.map(file => `${fileHash(path.join(root, file))} ${path.join(root, file)}\n`).join(""), { flag: "wx" });
|
|
250
|
+
checkpoint("protected.sha256");
|
|
251
|
+
}
|
|
252
|
+
checkpoint(`${stage}.json`); persist();
|
|
253
|
+
}
|
|
254
|
+
// Final baseline bytes match the full-test checkpoint used by the inventory
|
|
255
|
+
// binding. Only successful complete capture publishes baseline.json.
|
|
256
|
+
fs.copyFileSync(path.join(evidence, "baseline-full.json"), path.join(evidence, "baseline.json"), fs.constants.COPYFILE_EXCL);
|
|
257
|
+
checkpoint("baseline.json"); record.state = "COMPLETE"; record.nextRunAt = null; record.waitingReason = null; persist();
|
|
258
|
+
return { ok: true, record };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function validateStageRecoveryPolicy(policy) {
|
|
262
|
+
const keys = ["version", "maxEnvironmentRetries", "maxElapsedMs", "initialDelayMs", "maxDelayMs", "maxSameFailureRetries"];
|
|
263
|
+
assert(policy && Object.keys(policy).length === keys.length && keys.every(key => Object.hasOwn(policy, key)), "Invalid stage recovery policy fields");
|
|
264
|
+
validateRecoveryPolicy({ ...policy, scope: "baseline", maxManualRetries: 0 });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function stageRetryDecision(policy, record, now = Date.now(), random = Math.random) {
|
|
268
|
+
validateStageRecoveryPolicy(policy);
|
|
269
|
+
if (!record.lastFailure?.retryable) return { allowed: false, reason: "Failure is not an identified transient environment error" };
|
|
270
|
+
if (now < record.startedAt || now < record.lastObservedAt || now >= record.startedAt + policy.maxElapsedMs)
|
|
271
|
+
return { allowed: false, reason: "Stage recovery window exhausted or clock moved backwards" };
|
|
272
|
+
if (record.retriesUsed >= policy.maxEnvironmentRetries) return { allowed: false, reason: "Stage environment retry budget exhausted" };
|
|
273
|
+
if (record.sameFailureCount > policy.maxSameFailureRetries) return { allowed: false, reason: "Repeated failure without verification progress" };
|
|
274
|
+
const delay = Math.floor(Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** record.retriesUsed) * (0.75 + 0.25 * random()));
|
|
275
|
+
const nextRunAt = now + delay;
|
|
276
|
+
if (nextRunAt >= record.startedAt + policy.maxElapsedMs) return { allowed: false, reason: "Backoff exceeds stage recovery window" };
|
|
277
|
+
return { allowed: true, nextRunAt, reason: null };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// The trusted entry points retry deterministic verification only, never a model
|
|
281
|
+
// invocation or a commit. One durable budget is shared by all calls in a phase.
|
|
282
|
+
function persistStageRecord(ledger, record, previous, checkpoint) {
|
|
283
|
+
if (previous) assert(fileHash(ledger) === previous, "Stage recovery ledger changed before update");
|
|
284
|
+
else assert(!fs.existsSync(ledger), "Stage recovery ledger appeared outside its owner");
|
|
285
|
+
// Bind the intended bytes, not a reread that could adopt an external edit.
|
|
286
|
+
const next = hash(JSON.stringify(record, null, 2) + "\n");
|
|
287
|
+
atomic(ledger, record);
|
|
288
|
+
assert(fileHash(ledger) === next, "Stage recovery bytes changed before checkpoint");
|
|
289
|
+
checkpoint(previous, next);
|
|
290
|
+
assert(fileHash(ledger) === next, "Stage recovery bytes changed during checkpoint");
|
|
291
|
+
return next;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function runStage(phase, contractFile, configFile, root, evidence) {
|
|
295
|
+
assert(["red", "green", "review"].includes(phase), "Unsupported recovery phase");
|
|
296
|
+
const contract = read(contractFile), config = read(configFile);
|
|
297
|
+
require("./contract.cjs").validateContract(contract, config);
|
|
298
|
+
assert(contract.schemaVersion >= 7, "Stage recovery requires a new V7 approval");
|
|
299
|
+
root = fs.realpathSync(root); evidence = fs.realpathSync(evidence);
|
|
300
|
+
const policy = contract.stageRecovery;
|
|
301
|
+
const authorize = () => helper(root, "automation_require_queue_execution", [contract.id]);
|
|
302
|
+
authorize();
|
|
303
|
+
const directory = path.join(evidence, "stage-recovery"); fs.mkdirSync(directory, { recursive: true });
|
|
304
|
+
assert(fs.realpathSync(directory) === directory, "Stage evidence path changed");
|
|
305
|
+
const ledger = path.join(directory, `${phase}.json`), lock = path.join(directory, `${phase}.lock`);
|
|
306
|
+
const bridge = process.env.OPENCODE_ANDROID_ORCHESTRATOR_QUEUE_CLI;
|
|
307
|
+
assert(bridge && path.isAbsolute(bridge), "Trusted queue checkpoint bridge is missing");
|
|
308
|
+
let sealedHash = fs.existsSync(ledger) ? fileHash(ledger) : "";
|
|
309
|
+
const checkpoint = (previous, next) => {
|
|
310
|
+
const result = cp.spawnSync(process.execPath, [bridge, "_stage-record", root, phase, previous, next], { cwd: root, encoding: "utf8" });
|
|
311
|
+
assert(result.status === 0, result.stderr || "Cannot seal stage recovery checkpoint");
|
|
312
|
+
};
|
|
313
|
+
if (sealedHash) checkpoint(sealedHash, sealedHash);
|
|
314
|
+
const descriptor = fs.openSync(lock, "wx", 0o600);
|
|
315
|
+
try { fs.writeFileSync(descriptor, JSON.stringify({ pid: process.pid, queueRunId: process.env.AUTOMATION_QUEUE_RUN_ID })); fs.fsyncSync(descriptor); }
|
|
316
|
+
finally { fs.closeSync(descriptor); }
|
|
317
|
+
let record, writableRecord = false;
|
|
318
|
+
try {
|
|
319
|
+
const binding = () => ({ ...inputBinding(root, contractFile, configFile, contract, true),
|
|
320
|
+
evidence: Object.fromEntries(["baseline.json", "baseline-inventory.json", ...(phase === "red" ? [] : ["red.json", "test-manifest.json", "test-preflight.json"]), ...(phase === "review" ? ["ready.json"] : [])]
|
|
321
|
+
.map(file => [file, fileHash(path.join(evidence, file))])) });
|
|
322
|
+
const currentBinding = binding(), now = Date.now();
|
|
323
|
+
record = fs.existsSync(ledger) ? read(ledger) : { version: 1, phase, taskId: contract.id,
|
|
324
|
+
contractSha256: fileHash(contractFile), policySha256: digest(policy), startedAt: now, lastObservedAt: now,
|
|
325
|
+
retriesUsed: 0, sameFailureCount: 0, lastFailure: null, nextRunAt: null, state: "IDLE", attempts: [] };
|
|
326
|
+
assert(record.version === 1 && record.phase === phase && record.taskId === contract.id && record.policySha256 === digest(policy) &&
|
|
327
|
+
record.contractSha256 === fileHash(contractFile), "Stage recovery authorization changed");
|
|
328
|
+
assert(record.state !== "RUNNING", "Previous verification has no durable outcome; explicit ownership recovery is required");
|
|
329
|
+
for (const attempt of record.attempts) {
|
|
330
|
+
assert(/^attempt-[a-f0-9-]+\.log$/.test(attempt.log), "Invalid stage attempt evidence path");
|
|
331
|
+
assert(fileHash(path.join(directory, attempt.log)) === attempt.logSha256, "Stage attempt evidence changed");
|
|
332
|
+
}
|
|
333
|
+
checkArtifacts(evidence, { attempts: record.attempts, checkpoints: {} });
|
|
334
|
+
assert(!["EXHAUSTED", "BLOCKED"].includes(record.state), "Stage recovery stopped; inspect evidence and approve a revised contract");
|
|
335
|
+
writableRecord = true;
|
|
336
|
+
if (record.state === "WAITING") assert(digest(record.binding) === digest(currentBinding), "Verification inputs changed during backoff");
|
|
337
|
+
else { record.binding = currentBinding; record.state = "IDLE"; }
|
|
338
|
+
const save = () => {
|
|
339
|
+
sealedHash = persistStageRecord(ledger, record, sealedHash, checkpoint);
|
|
340
|
+
};
|
|
341
|
+
for (;;) {
|
|
342
|
+
authorize();
|
|
343
|
+
if (sealedHash) assert(fileHash(ledger) === sealedHash, "Stage recovery ledger changed during execution");
|
|
344
|
+
const clock = Date.now();
|
|
345
|
+
assert(clock >= record.lastObservedAt && clock >= record.startedAt, "Clock moved backwards during stage recovery");
|
|
346
|
+
record.lastObservedAt = clock;
|
|
347
|
+
assert(digest(binding()) === digest(record.binding), "Verification inputs or sealed evidence changed");
|
|
348
|
+
checkArtifacts(evidence, { attempts: record.attempts, checkpoints: {} });
|
|
349
|
+
for (const previous of record.attempts)
|
|
350
|
+
assert(fileHash(path.join(directory, previous.log)) === previous.logSha256, "Stage attempt evidence changed");
|
|
351
|
+
if (record.state === "WAITING") {
|
|
352
|
+
if (clock >= record.startedAt + policy.maxElapsedMs) {
|
|
353
|
+
record.state = "EXHAUSTED"; record.nextRunAt = null; record.waitingReason = "Stage recovery window exhausted"; save(); return 75;
|
|
354
|
+
}
|
|
355
|
+
if (clock < record.nextRunAt) {
|
|
356
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(1000, record.nextRunAt - clock)); continue;
|
|
357
|
+
}
|
|
358
|
+
record.retriesUsed++;
|
|
359
|
+
}
|
|
360
|
+
const attempt = { id: randomUUID(), queueRunId: process.env.AUTOMATION_QUEUE_RUN_ID, startedAt: clock,
|
|
361
|
+
retry: record.state === "WAITING", log: `attempt-${randomUUID()}.log`, state: "RUNNING" };
|
|
362
|
+
record.state = "RUNNING"; record.nextRunAt = null; record.attempts.push(attempt); save();
|
|
363
|
+
const logPath = path.join(directory, attempt.log), fd = fs.openSync(logPath, "wx", 0o600);
|
|
364
|
+
const inventoryBefore = fs.existsSync(path.join(evidence, "inventory-status.json")) ? fileHash(path.join(evidence, "inventory-status.json")) : null;
|
|
365
|
+
const command = phase === "red" ? process.execPath : "bash";
|
|
366
|
+
const args = phase === "red" ? [path.join(__dirname, "inventory.cjs"), "red", contractFile, configFile, root, evidence]
|
|
367
|
+
: [path.join(root, "scripts/automation/verify-task.sh"), contract.id];
|
|
368
|
+
let result;
|
|
369
|
+
try { result = cp.spawnSync(command, args, { cwd: root, stdio: ["ignore", fd, fd],
|
|
370
|
+
env: { ...process.env, AUTOMATION_STAGE_RECOVERY_PHASE: phase } }); }
|
|
371
|
+
finally { fs.closeSync(fd); }
|
|
372
|
+
attempt.finishedAt = Date.now(); attempt.exitCode = result.status; attempt.signal = result.signal;
|
|
373
|
+
attempt.state = result.status === 0 ? "PASSED" : "FAILED"; attempt.logSha256 = fileHash(logPath);
|
|
374
|
+
const log = fs.readFileSync(logPath, "utf8"); process.stdout.write(log);
|
|
375
|
+
assert(digest(binding()) === digest(record.binding), "Verification changed its inputs or sealed evidence");
|
|
376
|
+
let failure = classifyFailure({ phase, log, exitCode: result.status });
|
|
377
|
+
const statusPath = path.join(evidence, "inventory-status.json");
|
|
378
|
+
if (result.status !== 0 && fs.existsSync(statusPath) && fileHash(statusPath) !== inventoryBefore) {
|
|
379
|
+
const status = read(statusPath);
|
|
380
|
+
if (status.attemptPath) {
|
|
381
|
+
assert(new RegExp(`^inventory-attempts/${phase === "red" ? "red" : "green"}-[0-9]+-[a-f0-9-]+$`).test(status.attemptPath), "Unexpected verification attempt path");
|
|
382
|
+
attempt.files = artifactHashes(evidence, path.join(evidence, status.attemptPath));
|
|
383
|
+
}
|
|
384
|
+
if (!status.valid && status.queueRunId === process.env.AUTOMATION_QUEUE_RUN_ID && status.failure) failure = status.failure;
|
|
385
|
+
else if (!status.valid) failure = classifyFailure({ phase, reasonCode: status.reasonCode, log, exitCode: result.status });
|
|
386
|
+
}
|
|
387
|
+
// Scope/integrity exits and signal termination can never grant retries.
|
|
388
|
+
if (result.signal || result.error || [40, 41].includes(result.status)) failure = { ...failure, category: "integrity", retryable: false };
|
|
389
|
+
attempt.failure = result.status === 0 ? null : failure;
|
|
390
|
+
if (result.status === 0) {
|
|
391
|
+
record.state = "PASSED"; record.lastFailure = null; record.sameFailureCount = 0; record.waitingReason = null; save(); return 0;
|
|
392
|
+
}
|
|
393
|
+
record.sameFailureCount = record.lastFailure?.fingerprint === failure.fingerprint ? record.sameFailureCount + 1 : 1;
|
|
394
|
+
record.lastFailure = failure;
|
|
395
|
+
const decision = stageRetryDecision(policy, record, Date.now());
|
|
396
|
+
record.waitingReason = decision.reason;
|
|
397
|
+
if (!decision.allowed) {
|
|
398
|
+
record.state = failure.retryable ? "EXHAUSTED" : "FAILED"; record.nextRunAt = null; save();
|
|
399
|
+
return failure.retryable ? 75 : ["unknown", "configuration", "integrity"].includes(failure.category) ? 76 : 1;
|
|
400
|
+
}
|
|
401
|
+
record.state = "WAITING"; record.nextRunAt = decision.nextRunAt; save();
|
|
402
|
+
}
|
|
403
|
+
} catch (error) {
|
|
404
|
+
if (writableRecord) {
|
|
405
|
+
record.state = "BLOCKED"; record.nextRunAt = null; record.waitingReason = error.message;
|
|
406
|
+
// A concurrently altered ledger must remain intact for diagnosis.
|
|
407
|
+
if (!sealedHash || fileHash(ledger) === sealedHash) {
|
|
408
|
+
sealedHash = persistStageRecord(ledger, record, sealedHash, checkpoint);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
throw error;
|
|
412
|
+
} finally { fs.unlinkSync(lock); }
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
module.exports = { validateExecutionPolicy, validateRecoveryPolicy, validateStageRecoveryPolicy, stageRetryDecision, persistStageRecord, runStage, classifyFailure, retryDecision, captureBaseline, fileHash };
|
|
416
|
+
if (require.main === module) {
|
|
417
|
+
const argv = process.argv.slice(2);
|
|
418
|
+
const [contract, config, root, evidence, mode, expectedHash] = argv;
|
|
419
|
+
try {
|
|
420
|
+
if (contract === "--stage") process.exitCode = runStage(...argv.slice(1));
|
|
421
|
+
else {
|
|
422
|
+
const result = captureBaseline(contract, config, root, evidence, mode, expectedHash || null);
|
|
423
|
+
if (!result.ok) { console.error(JSON.stringify(result.record.attempts.at(-1).failure)); process.exitCode = 75; }
|
|
424
|
+
}
|
|
425
|
+
} catch (error) { console.error(error.message); process.exitCode = 76; }
|
|
426
|
+
}
|
|
@@ -27,6 +27,21 @@ review_verification_exit_code="$(jq -er '.verificationExitCode' "$review_file")"
|
|
|
27
27
|
[[ "$red_exit_code" -ne 0 ]] || automation_die "RED evidence does not contain a failing test result"
|
|
28
28
|
[[ "$review_verification_exit_code" -eq 0 ]] || automation_die "independent review verification did not pass"
|
|
29
29
|
structured_red=null
|
|
30
|
+
inventory_verification=null
|
|
31
|
+
if [[ "$(jq -er '.schemaVersion' "$contract")" -ge "4" ]]; then
|
|
32
|
+
automation_run_inventory check "$task_id" >/dev/null
|
|
33
|
+
inventory_verification="$(jq -n \
|
|
34
|
+
--slurpfile baseline "$evidence_dir/baseline-inventory.json" \
|
|
35
|
+
--slurpfile red "$red_file" \
|
|
36
|
+
--slurpfile manifest "$evidence_dir/test-manifest.json" \
|
|
37
|
+
--slurpfile green "$evidence_dir/green-inventory.json" \
|
|
38
|
+
'{valid: $green[0].valid, baseline: $baseline[0].summary,
|
|
39
|
+
red: {summary: $manifest[0].summary, processExitCode: $red[0].processExitCode,
|
|
40
|
+
expectedFailureCount: $red[0].expectedFailureCount, exitCodeMeaning: $red[0].exitCodeMeaning},
|
|
41
|
+
green: $green[0].summary, binding: $green[0].binding,
|
|
42
|
+
verifiedHead: $green[0].verifiedHead, worktreeSha256: $green[0].worktreeSha256,
|
|
43
|
+
cases: [$manifest[0].cases[] | {id, taskPath, className, name, classification, intent, allowSkip}] }')"
|
|
44
|
+
fi
|
|
30
45
|
if [[ "$(jq -er '.schemaVersion' "$contract")" == "3" ]]; then
|
|
31
46
|
preflight_file="$evidence_dir/test-preflight.json"
|
|
32
47
|
manifest_file="$evidence_dir/test-manifest.json"
|
|
@@ -101,6 +116,7 @@ jq -n \
|
|
|
101
116
|
--argjson nonGoals "$(jq -c '.nonGoals' "$contract")" \
|
|
102
117
|
--argjson targetTests "$(jq -c '.targetTests' "$contract")" \
|
|
103
118
|
--argjson structuredRed "$structured_red" \
|
|
119
|
+
--argjson inventoryVerification "$inventory_verification" \
|
|
104
120
|
'{taskId: $taskId, title: $title, state: $state,
|
|
105
121
|
generatedAt: $generatedAt, originalBranch: $originalBranch,
|
|
106
122
|
originalHeadBeforeContract: $originalHeadBeforeContract,
|
|
@@ -122,6 +138,7 @@ jq -n \
|
|
|
122
138
|
redRecorded: true,
|
|
123
139
|
redExitCode: $redExitCode,
|
|
124
140
|
structuredRed: $structuredRed,
|
|
141
|
+
inventoryVerification: $inventoryVerification,
|
|
125
142
|
qualityGate: "PASSED",
|
|
126
143
|
gateAttempts: $gateAttempts,
|
|
127
144
|
codingCycle: $codingCycle,
|
|
@@ -26,12 +26,36 @@ fi
|
|
|
26
26
|
|
|
27
27
|
automation_transition_state "$task_id" "PENDING" "CODING" "coder-launcher" "preflight passed; capturing baseline"
|
|
28
28
|
|
|
29
|
+
if [[ "$(jq -r '.schemaVersion' "$(automation_contract_path "$task_id")")" -ge "5" ]]; then
|
|
30
|
+
queue_file="$AUTOMATION_RUNTIME_ROOT/inbox/queue.json"
|
|
31
|
+
run_kind="$(jq -er --arg run "$AUTOMATION_QUEUE_RUN_ID" 'select(.active.id == $run) | .active.kind' "$queue_file")"
|
|
32
|
+
case "$run_kind" in
|
|
33
|
+
execute) recovery_mode=initial ;;
|
|
34
|
+
retry-baseline) recovery_mode=auto ;;
|
|
35
|
+
resume) recovery_mode=manual ;;
|
|
36
|
+
*) automation_die "V5 baseline capture requires a queue execution or recovery reservation"; exit 1 ;;
|
|
37
|
+
esac
|
|
38
|
+
recovery_sha="$(jq -r --arg task "$task_id" '.items[] | select(.taskId == $task) | .baselineRecovery.sha256 // ""' "$queue_file")"
|
|
39
|
+
set +e
|
|
40
|
+
node "$AUTOMATION_ROOT/automation/verification/recovery.cjs" \
|
|
41
|
+
"$(automation_contract_path "$task_id")" "$AUTOMATION_CONFIG" "$AUTOMATION_ROOT" "$evidence_dir" "$recovery_mode" "$recovery_sha"
|
|
42
|
+
recovery_status=$?
|
|
43
|
+
set -e
|
|
44
|
+
if [[ "$recovery_status" -ne 0 ]]; then
|
|
45
|
+
automation_transition_state "$task_id" "CODING" "BLOCKED" "preflight" "V5 baseline capture stopped; inspect baseline-recovery.json"
|
|
46
|
+
exit "$recovery_status"
|
|
47
|
+
fi
|
|
48
|
+
automation_info "$task_id claimed; complete V5 baseline sealed"
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
29
52
|
baseline_log="$evidence_dir/baseline.log"
|
|
30
53
|
baseline_meta="$evidence_dir/baseline.json"
|
|
31
54
|
protected_hashes="$evidence_dir/protected.sha256"
|
|
32
55
|
started_at="$(automation_now)"
|
|
33
56
|
head_commit="$(git -C "$AUTOMATION_ROOT" rev-parse HEAD)"
|
|
34
57
|
unit_tests_enabled="$(automation_config_value '.unitTestsEnabled')"
|
|
58
|
+
baseline_cwd="$(automation_gradle_build_root)"
|
|
35
59
|
baseline_command='[]'
|
|
36
60
|
if [[ "$unit_tests_enabled" == "true" ]]; then
|
|
37
61
|
baseline_command="$(automation_gradle_group_command_json "fullUnitTestTasks")"
|
|
@@ -53,10 +77,11 @@ if [[ "$baseline_status" -ne 0 ]]; then
|
|
|
53
77
|
--arg startedAt "$started_at" \
|
|
54
78
|
--arg finishedAt "$(automation_now)" \
|
|
55
79
|
--arg head "$head_commit" \
|
|
80
|
+
--arg cwd "$baseline_cwd" \
|
|
56
81
|
--argjson command "$baseline_command" \
|
|
57
82
|
--argjson unitTestsEnabled "$unit_tests_enabled" \
|
|
58
83
|
--argjson exitCode "$baseline_status" \
|
|
59
|
-
'{taskId: $taskId, startedAt: $startedAt, finishedAt: $finishedAt, head: $head, command: $command, unitTestsEnabled: $unitTestsEnabled, exitCode: $exitCode}' \
|
|
84
|
+
'{taskId: $taskId, startedAt: $startedAt, finishedAt: $finishedAt, head: $head, command: $command, cwd: $cwd, unitTestsEnabled: $unitTestsEnabled, exitCode: $exitCode}' \
|
|
60
85
|
| automation_record_json "$baseline_meta"
|
|
61
86
|
automation_transition_state "$task_id" "CODING" "BLOCKED" "preflight" "baseline unit tests failed"
|
|
62
87
|
automation_die "baseline unit tests failed"
|
|
@@ -78,11 +103,19 @@ jq -n \
|
|
|
78
103
|
--arg finishedAt "$(automation_now)" \
|
|
79
104
|
--arg head "$head_commit" \
|
|
80
105
|
--arg worktree "$AUTOMATION_ROOT" \
|
|
106
|
+
--arg cwd "$baseline_cwd" \
|
|
81
107
|
--argjson command "$baseline_command" \
|
|
82
108
|
--argjson unitTestsEnabled "$unit_tests_enabled" \
|
|
83
|
-
'{taskId: $taskId, startedAt: $startedAt, finishedAt: $finishedAt, head: $head, worktree: $worktree, command: $command, unitTestsEnabled: $unitTestsEnabled, exitCode: 0}' \
|
|
109
|
+
'{taskId: $taskId, startedAt: $startedAt, finishedAt: $finishedAt, head: $head, worktree: $worktree, command: $command, cwd: $cwd, unitTestsEnabled: $unitTestsEnabled, exitCode: 0}' \
|
|
84
110
|
| automation_record_json "$baseline_meta"
|
|
85
111
|
|
|
112
|
+
if [[ "$(jq -r '.schemaVersion' "$(automation_contract_path "$task_id")")" == "4" ]]; then
|
|
113
|
+
if ! automation_run_inventory baseline "$task_id"; then
|
|
114
|
+
automation_transition_state "$task_id" "CODING" "BLOCKED" "preflight" "baseline inventory failed; inspect inventory-status.json"
|
|
115
|
+
automation_die "baseline inventory failed; Coder must not edit tests"
|
|
116
|
+
fi
|
|
117
|
+
fi
|
|
118
|
+
|
|
86
119
|
if [[ "$unit_tests_enabled" == "true" ]]; then
|
|
87
120
|
automation_info "$task_id claimed; baseline is green"
|
|
88
121
|
else
|
|
@@ -61,7 +61,7 @@ while IFS= read -r path; do
|
|
|
61
61
|
if automation_array_matches_path "$AUTOMATION_CONFIG" '.androidProject.productionPaths' "$path"; then
|
|
62
62
|
production_changed=1
|
|
63
63
|
fi
|
|
64
|
-
if
|
|
64
|
+
if automation_is_task_test_path "$task_id" "$path"; then
|
|
65
65
|
test_changed=1
|
|
66
66
|
fi
|
|
67
67
|
done <<< "$changed_file_list"
|
|
@@ -69,7 +69,7 @@ done <<< "$changed_file_list"
|
|
|
69
69
|
deleted_tests="$(
|
|
70
70
|
git -C "$AUTOMATION_ROOT" diff --name-only --diff-filter=D "$base" "$head" -- \
|
|
71
71
|
| while IFS= read -r path; do
|
|
72
|
-
if
|
|
72
|
+
if automation_is_task_test_path "$task_id" "$path"; then
|
|
73
73
|
printf '%s\n' "$path"
|
|
74
74
|
fi
|
|
75
75
|
done
|
|
@@ -83,7 +83,7 @@ fi
|
|
|
83
83
|
added_test_lines="$(
|
|
84
84
|
while IFS= read -r path; do
|
|
85
85
|
[[ -n "$path" ]] || continue
|
|
86
|
-
if
|
|
86
|
+
if automation_is_task_test_path "$task_id" "$path"; then
|
|
87
87
|
git -C "$AUTOMATION_ROOT" diff --unified=0 "$base" "$head" -- "$path"
|
|
88
88
|
fi
|
|
89
89
|
done <<< "$changed_file_list" \
|