project-autopilot 12.0.0 → 12.0.1
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/README.md +10 -10
- package/SKILL.md +1 -3
- package/adapters/agents.json +11 -1
- package/config.example.json +5 -3
- package/config.schema.json +28 -9
- package/docs/configuration.md +5 -3
- package/package.json +1 -1
- package/scripts/adapter-drivers.mjs +209 -22
- package/scripts/agent-contract.mjs +24 -2
- package/scripts/agent-invocation.mjs +32 -5
- package/scripts/autopilot.mjs +787 -222
- package/scripts/config-layout.mjs +6 -0
- package/scripts/config-migration.mjs +3 -3
- package/scripts/config-validation.mjs +12 -4
- package/scripts/e2e-detect.mjs +30 -0
- package/scripts/escalation-naming.mjs +19 -0
- package/scripts/failure-contract.mjs +25 -9
- package/scripts/gen-config-schema.mjs +7 -0
- package/scripts/idle-hang-watchdog.mjs +34 -7
- package/scripts/implementation-baseline.mjs +95 -4
- package/scripts/model-discovery.mjs +176 -38
- package/scripts/opencode-generation.mjs +116 -0
- package/scripts/per-task-budget.mjs +11 -7
- package/scripts/plan-review-rounds.mjs +164 -9
- package/scripts/planning-boundary.mjs +16 -6
- package/scripts/planning-continuation.mjs +43 -23
- package/scripts/planning-review-decision.mjs +3 -0
- package/scripts/planning-review-wiring.mjs +12 -8
- package/scripts/relay-guards.mjs +133 -11
- package/scripts/review-canary.mjs +5 -2
- package/scripts/role-fallback.mjs +23 -8
- package/scripts/run-history.mjs +32 -1
- package/scripts/run-observability.mjs +46 -2
- package/scripts/setup-discovery.mjs +2 -1
- package/scripts/setup.mjs +183 -81
- package/scripts/skill-copy.mjs +49 -0
- package/scripts/task-requirements.mjs +24 -0
- package/scripts/watch-report.mjs +146 -21
- package/scripts/watch-summary.mjs +26 -3
- package/scripts/plan-mode-model-note.mjs +0 -27
|
@@ -749,6 +749,10 @@ export function toRuntimeConfig(layout = {}) {
|
|
|
749
749
|
else if (has(legacyCapabilities.verification || {}, "browser")) verification.browser = legacyCapabilities.verification.browser;
|
|
750
750
|
if (has(tests, "require_browser")) verification.require_browser = tests.require_browser;
|
|
751
751
|
else if (has(legacyCapabilities.verification || {}, "require_browser")) verification.require_browser = legacyCapabilities.verification.require_browser;
|
|
752
|
+
if (has(tests, "e2e")) verification.e2e = tests.e2e;
|
|
753
|
+
else if (has(legacyCapabilities.verification || {}, "e2e")) verification.e2e = legacyCapabilities.verification.e2e;
|
|
754
|
+
if (has(tests, "e2e_setup_pending")) verification.e2e_setup_pending = tests.e2e_setup_pending;
|
|
755
|
+
else if (has(legacyCapabilities.verification || {}, "e2e_setup_pending")) verification.e2e_setup_pending = legacyCapabilities.verification.e2e_setup_pending;
|
|
752
756
|
if (Object.keys(verification).length) capabilities.verification = verification;
|
|
753
757
|
if (Object.keys(capabilities).length) out.capabilities = capabilities;
|
|
754
758
|
|
|
@@ -885,6 +889,8 @@ export function toNewLayoutConfig(runtimeConfig = {}, schemaRef = "./config.sche
|
|
|
885
889
|
const verification = isObject(capabilities.verification) ? capabilities.verification : {};
|
|
886
890
|
if (has(verification, "browser")) layout.tests.browser = verification.browser;
|
|
887
891
|
if (has(verification, "require_browser")) layout.tests.require_browser = verification.require_browser;
|
|
892
|
+
if (has(verification, "e2e")) layout.tests.e2e = verification.e2e;
|
|
893
|
+
if (has(verification, "e2e_setup_pending")) layout.tests.e2e_setup_pending = verification.e2e_setup_pending;
|
|
888
894
|
if (has(planning, "verify_denylist")) layout.tests.verify_denylist = planning.verify_denylist;
|
|
889
895
|
|
|
890
896
|
// safety
|
|
@@ -226,8 +226,8 @@ function migrateLegacyRuntime(input, existingMeta = {}) {
|
|
|
226
226
|
// the policy invalid — a wrap-up at/above its cap, or a non-planner role cap
|
|
227
227
|
// at/above the global ceiling. Degrade the offending value to its default so
|
|
228
228
|
// migrateConfig never returns a config validatePolicyConfig rejects (the
|
|
229
|
-
// comparisons mirror validatePolicyConfig exactly, planner
|
|
230
|
-
// global-ceiling check like
|
|
229
|
+
// comparisons mirror validatePolicyConfig exactly, planner and plan_reviewer
|
|
230
|
+
// exempt from the global-ceiling check like they are there).
|
|
231
231
|
const effectiveCeiling = Number.isInteger(out.policy.per_task_max_tokens) && out.policy.per_task_max_tokens > 0
|
|
232
232
|
? out.policy.per_task_max_tokens
|
|
233
233
|
: PER_TASK_MAX_TOKENS_DEFAULT;
|
|
@@ -239,7 +239,7 @@ function migrateLegacyRuntime(input, existingMeta = {}) {
|
|
|
239
239
|
for (const role of reviewRoles) {
|
|
240
240
|
const roleCaps = out.policy.caps?.[role];
|
|
241
241
|
if (!roleCaps || typeof roleCaps !== "object" || Array.isArray(roleCaps)) continue;
|
|
242
|
-
if (role !== "planner") {
|
|
242
|
+
if (role !== "planner" && role !== "plan_reviewer") {
|
|
243
243
|
const capTokens = Number(roleCaps.max_tokens);
|
|
244
244
|
if (Number.isInteger(capTokens) && capTokens > 0 && capTokens >= effectiveCeiling) {
|
|
245
245
|
roleCaps.max_tokens = PER_ROLE_CAPS_DEFAULT[role].maxTokens;
|
|
@@ -260,7 +260,8 @@ export function validatePolicyConfig(policy = {}) {
|
|
|
260
260
|
// Mission 031 Phase 1 task 1.4: validate policy.caps per-role entries.
|
|
261
261
|
// Review roles: planner, plan_reviewer, code_reviewer, verifier.
|
|
262
262
|
// Each entry requires positive integer max_turns, max_tokens, extra_sessions;
|
|
263
|
-
// max_tokens < effective token ceiling
|
|
263
|
+
// max_tokens < effective token ceiling (planner and plan_reviewer exempt);
|
|
264
|
+
// extra_sessions >= 0;
|
|
264
265
|
// wrap_up_tokens_threshold optional, if present must be < max_tokens for review roles.
|
|
265
266
|
const caps = policy.caps;
|
|
266
267
|
if (caps !== undefined) {
|
|
@@ -288,12 +289,14 @@ export function validatePolicyConfig(policy = {}) {
|
|
|
288
289
|
const maxTokens = roleCaps.max_tokens;
|
|
289
290
|
if (maxTokens == null || !Number.isInteger(maxTokens) || maxTokens <= 0) {
|
|
290
291
|
errors.push(`policy.caps.${roleName}.max_tokens must be a positive integer.`);
|
|
291
|
-
} else if (maxTokens >= globalCeiling && roleName !== "planner") {
|
|
292
|
+
} else if (maxTokens >= globalCeiling && roleName !== "planner" && roleName !== "plan_reviewer") {
|
|
292
293
|
// Mission 039 task 1.4: planner is exempt from the sub-ceiling
|
|
293
294
|
// check. Its code default (200K per-turn) intentionally exceeds the
|
|
294
295
|
// ~180K implementer-oriented global default, and the relay enforces
|
|
295
296
|
// planner caps via resolvePerRoleLimits, so the planner cap fires
|
|
296
|
-
// at its own max regardless of the global ceiling.
|
|
297
|
+
// at its own max regardless of the global ceiling. Backlog #8:
|
|
298
|
+
// plan_reviewer is exempt for the same reason — its code default is
|
|
299
|
+
// now 250K so a full plan re-check no longer splits at ~135K.
|
|
297
300
|
errors.push(`policy.caps.${roleName}.max_tokens (${maxTokens}) must be strictly below the effective token ceiling (${globalCeiling}); otherwise the cap can never fire.`);
|
|
298
301
|
}
|
|
299
302
|
const extraSessions = roleCaps.extra_sessions;
|
|
@@ -564,7 +567,7 @@ const LEGACY_KEY_TARGETS = {
|
|
|
564
567
|
role_requirements: "advanced.capabilities.role_requirements",
|
|
565
568
|
safety_denied: "advanced.capabilities.safety_denied",
|
|
566
569
|
legacy_text_inference: "advanced.capabilities.legacy_text_inference",
|
|
567
|
-
verification: "tests.browser, tests.require_browser",
|
|
570
|
+
verification: "tests.browser, tests.require_browser, tests.e2e",
|
|
568
571
|
},
|
|
569
572
|
};
|
|
570
573
|
|
|
@@ -640,6 +643,8 @@ const TESTS_KEYS = new Set([
|
|
|
640
643
|
"require_reviewer",
|
|
641
644
|
"browser",
|
|
642
645
|
"require_browser",
|
|
646
|
+
"e2e",
|
|
647
|
+
"e2e_setup_pending",
|
|
643
648
|
"verify_denylist",
|
|
644
649
|
]);
|
|
645
650
|
|
|
@@ -696,6 +701,7 @@ const FREE_MODELS_KEYS = new Set(["auto_fallback", "max_turns"]);
|
|
|
696
701
|
// shipped config.schema.json cannot drift.
|
|
697
702
|
const BROWSER_VALUES = new Set(SCHEMA_ENUMS["tests.browser"]);
|
|
698
703
|
const REQUIRE_BROWSER_VALUES = new Set(SCHEMA_ENUMS["tests.require_browser"]);
|
|
704
|
+
const E2E_VALUES = new Set(SCHEMA_ENUMS["tests.e2e"]);
|
|
699
705
|
const PLANNING_FRAMEWORK_VALUES = SCHEMA_ENUMS["project.planning_framework"];
|
|
700
706
|
|
|
701
707
|
function isObject(value) {
|
|
@@ -1002,6 +1008,8 @@ function validateTestsConfig(tests, path, errors, warnings) {
|
|
|
1002
1008
|
if (tests.require_reviewer !== undefined) validateType(tests.require_reviewer, "boolean", `${path}.require_reviewer`, errors);
|
|
1003
1009
|
if (tests.browser !== undefined) validateType(tests.browser, "string", `${path}.browser`, errors, Array.from(BROWSER_VALUES));
|
|
1004
1010
|
if (tests.require_browser !== undefined) validateType(tests.require_browser, "string", `${path}.require_browser`, errors, Array.from(REQUIRE_BROWSER_VALUES));
|
|
1011
|
+
if (tests.e2e !== undefined && tests.e2e !== null) validateType(tests.e2e, "string", `${path}.e2e`, errors, Array.from(E2E_VALUES));
|
|
1012
|
+
if (tests.e2e_setup_pending !== undefined) validateType(tests.e2e_setup_pending, "boolean", `${path}.e2e_setup_pending`, errors);
|
|
1005
1013
|
if (tests.verify_denylist !== undefined) {
|
|
1006
1014
|
if (!Array.isArray(tests.verify_denylist)) {
|
|
1007
1015
|
errors.push(`${path}.verify_denylist: must be an array.`);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Detects whether a repo is a UI project and which e2e tool it already uses.
|
|
2
|
+
// Reads BOTH dependencies and devDependencies of package.json.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const UI_PACKAGES = ["react", "next", "vue", "svelte", "angular", "@angular/core"];
|
|
7
|
+
const E2E_TOOLS = [
|
|
8
|
+
["@playwright/test", "playwright"],
|
|
9
|
+
["cypress", "cypress"],
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function readDeps(repo) {
|
|
13
|
+
try {
|
|
14
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(repo, "package.json"), "utf8"));
|
|
15
|
+
return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
16
|
+
} catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function detectUiProject(repo) {
|
|
22
|
+
const deps = readDeps(repo);
|
|
23
|
+
return UI_PACKAGES.some((name) => Object.hasOwn(deps, name));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function detectE2eTool(repo) {
|
|
27
|
+
const deps = readDeps(repo);
|
|
28
|
+
for (const [name, tool] of E2E_TOOLS) if (Object.hasOwn(deps, name)) return tool;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
@@ -50,6 +50,25 @@ export function decideEscalationName({failureClass, retryable, used, limit, budg
|
|
|
50
50
|
: "RATE_LIMITED is not retryable; resume with `ap run` after the limit lifts";
|
|
51
51
|
return {step: "rate_limited_escalation", detail};
|
|
52
52
|
}
|
|
53
|
+
// The CLI's own credentials are unusable. Same-adapter retry (and any
|
|
54
|
+
// same-adapter fallback) cannot fix it; the operator must log the CLI in
|
|
55
|
+
// again. The adapter/binary and the CLI's own reason travel on the auth
|
|
56
|
+
// observation's evidence.
|
|
57
|
+
if (failureClass === "AUTH_REQUIRED") {
|
|
58
|
+
let adapter = null;
|
|
59
|
+
let reason = null;
|
|
60
|
+
if (Array.isArray(evidence)) {
|
|
61
|
+
for (const entry of evidence) {
|
|
62
|
+
if (!entry || typeof entry !== "object") continue;
|
|
63
|
+
if (adapter == null && typeof entry.adapter === "string" && entry.adapter !== "") adapter = entry.adapter;
|
|
64
|
+
if (reason == null && typeof entry.reason === "string" && entry.reason !== "") reason = entry.reason;
|
|
65
|
+
if (adapter != null && reason != null) break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const cli = adapter || "the CLI";
|
|
69
|
+
const why = reason ? ` (${reason})` : "";
|
|
70
|
+
return {step: "auth_required_escalation", detail: `${cli} is logged out${why} — run \`${cli}\` and log in, then \`ap run\``};
|
|
71
|
+
}
|
|
53
72
|
return {step: "non_retryable_failure", detail: `${failureClass} is not retryable`};
|
|
54
73
|
}
|
|
55
74
|
if (budget == null) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {renderFinding} from "./review-feedback.mjs";
|
|
1
|
+
import {normalizeFindings, renderFinding} from "./review-feedback.mjs";
|
|
2
2
|
|
|
3
3
|
export const FAILURE_SCHEMA = "autopilot-failure.v1";
|
|
4
4
|
|
|
@@ -11,7 +11,7 @@ export const FAILURE_CLASSES = Object.freeze([
|
|
|
11
11
|
"PERMISSION_DENIED", "PATH_UNSUPPORTED", "INVOCATION_MISMATCH",
|
|
12
12
|
"STRUCTURED_OUTPUT_MISSING", "STRUCTURED_OUTPUT_INVALID",
|
|
13
13
|
"READ_ONLY_VIOLATION", "GIT_MUTATION_BLOCKED", "GIT_COMMIT_DETECTED",
|
|
14
|
-
"COMMAND_FAILED", "SERVER_UNAVAILABLE", "RATE_LIMITED", "PROVIDER_ERROR", "IDLE_HANG", "IMPLEMENTATION_REJECTED",
|
|
14
|
+
"COMMAND_FAILED", "SERVER_UNAVAILABLE", "RATE_LIMITED", "AUTH_REQUIRED", "PROVIDER_ERROR", "IDLE_HANG", "IMPLEMENTATION_REJECTED",
|
|
15
15
|
"VERIFICATION_REJECTED", "PLANNING_REJECTED", "INTERNAL_CONTRACT_ERROR"
|
|
16
16
|
]);
|
|
17
17
|
|
|
@@ -107,6 +107,9 @@ export function parseReviewContract(output) {
|
|
|
107
107
|
// Backlog #118: optional per-previous-finding fixed/not_fixed record
|
|
108
108
|
// (code-review rounds 2+). Tolerant: absent/malformed -> [].
|
|
109
109
|
previous: normalizePreviousStatuses(review.previous),
|
|
110
|
+
// Backlog #8: optional minor notes from a plan review. They never
|
|
111
|
+
// fail the review; strings only, bounded like findings.
|
|
112
|
+
notes: normalizeFindings(Array.isArray(review.notes) ? review.notes.filter(note => typeof note === "string") : []),
|
|
110
113
|
},
|
|
111
114
|
failure: null
|
|
112
115
|
};
|
|
@@ -211,9 +214,20 @@ export function classifyObservedFailure(observation) {
|
|
|
211
214
|
// 4. Structured terminal rate limit — a CLI-protocol status field, not
|
|
212
215
|
// text scraped from prose, so it is authoritative regardless of exit code.
|
|
213
216
|
if (eventTypes.has("rate_limited")) return failure("RATE_LIMITED", "infrastructure", false, evidence);
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
+
// 4a. The CLI itself is logged out (claude 401 / revoked or expired OAuth).
|
|
218
|
+
// Non-retryable and adapter-domain: retrying the same CLI cannot restore
|
|
219
|
+
// credentials. Placed beside RATE_LIMITED, before every generic adapter
|
|
220
|
+
// error, so it can never degrade to a retryable PROCESS_EXIT_NONZERO.
|
|
221
|
+
if (eventTypes.has("auth_required")) return failure("AUTH_REQUIRED", "adapter", false, evidence);
|
|
222
|
+
// 4b. OpenCode 2.x without --standalone cannot resolve the config-defined
|
|
223
|
+
// agent ("Agent not found: ..."). That is an invocation/config failure, not
|
|
224
|
+
// a provider limit and not an idle hang: name it explicitly and do not
|
|
225
|
+
// retry the same invocation.
|
|
226
|
+
if (eventTypes.has("agent_not_found")) return failure("INVOCATION_MISMATCH", "adapter", false, evidence);
|
|
227
|
+
// 4c. Backlog #106: structured provider-side error (opencode UnknownError,
|
|
228
|
+
// and 2.x provider.no-route / "Model unavailable"). Retryable (one
|
|
229
|
+
// same-model retry may be spent) but also a role-fallback trigger like
|
|
230
|
+
// RATE_LIMITED, so a configured fallback is tried first.
|
|
217
231
|
if (eventTypes.has("provider_error")) return failure("PROVIDER_ERROR", "infrastructure", true, evidence);
|
|
218
232
|
// 5. Structured failed permission/path/server evidence — trusted because
|
|
219
233
|
// it comes from the adapter's own canonicalized tool-call outcome
|
|
@@ -259,7 +273,7 @@ export function classifyObservedFailure(observation) {
|
|
|
259
273
|
// error line (which stays PROCESS_EXIT_NONZERO); only silence or explicit
|
|
260
274
|
// limit text is reclassified, so the normal fallback path runs instead of
|
|
261
275
|
// burning the adapter retry budget and stopping.
|
|
262
|
-
const OPENCODE_LIMIT_TEXT = /(?:\b429\b|rate[_\s-]?limit|too many requests|insufficient[_\s-]?(?:quota|funds)|quota[_\s-]?(?:exceeded|exhausted))/i;
|
|
276
|
+
const OPENCODE_LIMIT_TEXT = /(?:\b429\b|rate[_\s-]?limit|too many requests|insufficient[_\s-]?(?:quota|funds)|quota[_\s-]?(?:exceeded|exhausted)|free usage limit reached|usage limit reached|subscribe to [^\n]{0,80}to keep going)/i;
|
|
263
277
|
export function reclassifySilentOpenCodeFailure(observed, { adapter, output, stderr } = {}) {
|
|
264
278
|
if (!observed || observed.class !== "PROCESS_EXIT_NONZERO" || adapter !== "opencode") return observed;
|
|
265
279
|
const answer = String(output || "").trim();
|
|
@@ -318,7 +332,7 @@ export function verdictFailure(roleName, verdict) {
|
|
|
318
332
|
// deliberately absent here — so a warning-only observation set on a clean
|
|
319
333
|
// exit classifies to null below and the relay mapping falls through to the
|
|
320
334
|
// missing-sentinel STRUCTURED_OUTPUT_MISSING path.
|
|
321
|
-
const TRUSTED_FAILURE_EVENT_TYPES = new Set(["rate_limited", "provider_error", "permission_denied", "path_unsupported", "server_unavailable"]);
|
|
335
|
+
const TRUSTED_FAILURE_EVENT_TYPES = new Set(["rate_limited", "auth_required", "agent_not_found", "provider_error", "permission_denied", "path_unsupported", "server_unavailable"]);
|
|
322
336
|
// Reviewer FAIL verdicts live in the failure class (their findings are plain
|
|
323
337
|
// strings, not typed events), so they are exempt from the evidence check.
|
|
324
338
|
const VERDICT_FAILURE_CLASSES = new Set(["IMPLEMENTATION_REJECTED", "VERIFICATION_REJECTED", "PLANNING_REJECTED"]);
|
|
@@ -630,8 +644,10 @@ export function recordFailureBudget(state, policy, observedFailure, scope) {
|
|
|
630
644
|
const limit = positiveInteger(policy?.failure_budgets?.[name], defaultBudget(name));
|
|
631
645
|
// RATE_LIMITED escalates immediately without consuming the budget: the
|
|
632
646
|
// counter is never incremented, so the key stays unset and every such
|
|
633
|
-
// failure reports allowed:false.
|
|
634
|
-
|
|
647
|
+
// failure reports allowed:false. AUTH_REQUIRED is the same shape — logging
|
|
648
|
+
// in again is an operator action, not a retry — so it must not burn the
|
|
649
|
+
// adapter budget either.
|
|
650
|
+
if (observedFailure?.class === "RATE_LIMITED" || observedFailure?.class === "AUTH_REQUIRED") return {allowed: false, budget: name, used: 0, limit, scope};
|
|
635
651
|
const key = `${name}:${scope}`;
|
|
636
652
|
state.failureBudgets = state.failureBudgets || {};
|
|
637
653
|
state.failureBudgets[key] = (state.failureBudgets[key] || 0) + 1;
|
|
@@ -132,6 +132,8 @@ const DESCRIPTIONS = {
|
|
|
132
132
|
"tests.require_reviewer": "Intended to require a reviewer verdict before a phase passes. Fixed in this version; changing it has no effect.",
|
|
133
133
|
"tests.browser": "Browser capability used for verification: native (the agent's own browser), playwright or both.",
|
|
134
134
|
"tests.require_browser": "How browser capabilities are required for the verifier: always adds them to every call; when-task-requires and never add them only when the task itself needs a browser.",
|
|
135
|
+
"tests.e2e": "End-to-end test tool for UI projects: playwright, cypress or none. Unset (null) = setup asks once; treated as none until answered.",
|
|
136
|
+
"tests.e2e_setup_pending": "True while an end-to-end tool setup is still pending for the project; unset or false means nothing is pending.",
|
|
135
137
|
"tests.verify_denylist": "Commands or paths that a task or verification step may never run; empty adds no bans beyond the built-in full-suite ban.",
|
|
136
138
|
|
|
137
139
|
safety: "What roles may do and which safety checks are enforced.",
|
|
@@ -227,6 +229,7 @@ export const ENUMS = {
|
|
|
227
229
|
"runtime.mode": ["auto", "linux-strong", "wsl2-strong", "windows-compatibility"],
|
|
228
230
|
"tests.browser": ["native", "playwright", "both"],
|
|
229
231
|
"tests.require_browser": ["always", "when-task-requires", "never"],
|
|
232
|
+
"tests.e2e": ["playwright", "cypress", "none"],
|
|
230
233
|
"roles.*.cli": CLI_VALUES,
|
|
231
234
|
"roles.*.reasoning": REASONING_VALUES,
|
|
232
235
|
"roles.*.fallbacks[].cli": CLI_VALUES,
|
|
@@ -426,6 +429,10 @@ export function buildConfigSchema() {
|
|
|
426
429
|
for (const role of ESCALATION_MODEL_ROLES) {
|
|
427
430
|
properties.roles.properties[role].properties.escalation_model = escalationModelSchema();
|
|
428
431
|
}
|
|
432
|
+
// Schema-only keys: not in DEFAULTS, so they stay out of config.example.json.
|
|
433
|
+
// Unset (null) means never asked; setup/config asks the operator once.
|
|
434
|
+
properties.tests.properties.e2e = {type: ["string", "null"], description: descriptionFor("tests.e2e"), default: null, enum: [...enumFor("tests.e2e"), null]};
|
|
435
|
+
properties.tests.properties.e2e_setup_pending = {type: "boolean", description: descriptionFor("tests.e2e_setup_pending"), default: false};
|
|
429
436
|
return {
|
|
430
437
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
431
438
|
$id: "https://project-autopilot.dev/config.schema.json",
|
|
@@ -76,6 +76,10 @@ export const RATE_LIMIT_CONFIRM_MATCHES = 1;
|
|
|
76
76
|
export const RATE_LIMIT_CONFIRM_GAP_MS = 60_000;
|
|
77
77
|
// Bounded log reads: only appended bytes are ever scanned, capped.
|
|
78
78
|
export const RATE_LIMIT_LOG_TAIL_BYTES = 256 * 1024;
|
|
79
|
+
// The reason value a free-tier quota/billing rejection records. Exported so
|
|
80
|
+
// call sites (autopilot.mjs, adapter-drivers.mjs) never hand-build the
|
|
81
|
+
// literal; it is a label on the unchanged RATE_LIMITED path, not a new class.
|
|
82
|
+
export const FREE_QUOTA_REASON = "free_quota_exhausted";
|
|
79
83
|
|
|
80
84
|
// opencode's OWN log (logfmt), not model prose: `AI_APICallError` and
|
|
81
85
|
// `AI_RetryError` are engine-level error values. Observed live in
|
|
@@ -85,8 +89,26 @@ export const RATE_LIMIT_LOG_TAIL_BYTES = 256 * 1024;
|
|
|
85
89
|
// exceeded. Please retry after a brief wait."
|
|
86
90
|
// Bare AI_RetryError ALSO fires for non-rate-limit errors ("Cannot connect
|
|
87
91
|
// to API"), so the rate-limit marker is required in both branches.
|
|
88
|
-
|
|
92
|
+
// Backlog #14: a free-tier quota rejection has no status code at all:
|
|
93
|
+
// error.error="AI_APICallError: Free usage limit reached. Free models work
|
|
94
|
+
// in OpenCode (https://opencode.ai); subscribe to OpenCode Go to keep
|
|
95
|
+
// going: https://opencode.ai/go"
|
|
96
|
+
// It is the same operator-visible state as a 429 (stop, switch model), so
|
|
97
|
+
// the marker covers quota/billing exhaustion too. The source check below
|
|
98
|
+
// still requires an engine error token, so model prose never matches.
|
|
99
|
+
const FREE_QUOTA_MARKER = /free usage limit reached|usage limit reached|insufficient[_\s-]?quota|quota[_\s-]?(?:exceeded|exhausted)|subscribe to [^\n]{0,80}to keep going/i;
|
|
100
|
+
const RATE_LIMIT_MARKER = new RegExp(`rate[_\\s]limit[_\\s]exceeded|endpoint is unavailable|insufficient account funds|status_?code[=:"\\s]+429\\b|${FREE_QUOTA_MARKER.source}`, "i");
|
|
101
|
+
// Backlog #107: the small title-generation model's stream errors
|
|
102
|
+
// (small=true agent=title) say nothing about the role's own model. Shared
|
|
103
|
+
// with adapter-drivers.mjs so both stderr scanners ignore the same lines.
|
|
104
|
+
export const OPENCODE_TITLE_MODEL_LINE_RE = /\bsmall=true\b|\bagent=title\b/;
|
|
89
105
|
const RATE_LIMIT_SOURCE = /AI_APICallError|AI_RetryError/;
|
|
106
|
+
// 2.x logfmt ERROR lines do not name the 1.x AI SDK error class; they carry
|
|
107
|
+
// server-role/cause structure instead (real 2.2.0 capture in
|
|
108
|
+
// scripts/test-fixtures/opencode-2x-run-bad-model.stderr.txt). Accept those
|
|
109
|
+
// too, but only as a logfmt line — the JSON-stdout and no-source guards in
|
|
110
|
+
// rateLimitLineInfo() below still keep model prose/tool text out.
|
|
111
|
+
const OPENCODE_2X_ENGINE_ERROR_LINE = /\blevel=ERROR\b[^\n]*(?:\brole=server\b|\bcause=)/;
|
|
90
112
|
const PROVIDER_ID_RE = /providerID=([^\s]+)/;
|
|
91
113
|
const MODEL_ID_RE = /modelID=([^\s]+)/;
|
|
92
114
|
const LOG_TIMESTAMP_RE = /timestamp=([0-9T:.\-+Z]+)/;
|
|
@@ -131,11 +153,15 @@ export function rateLimitLineInfo(line) {
|
|
|
131
153
|
const text = String(line ?? "");
|
|
132
154
|
// Backlog #107: the small title-generation model's stream errors (small=true
|
|
133
155
|
// agent=title) say nothing about the role's own model.
|
|
134
|
-
if (
|
|
135
|
-
|
|
156
|
+
if (OPENCODE_TITLE_MODEL_LINE_RE.test(text)) return null;
|
|
157
|
+
// Only opencode's own logfmt ERROR lines count. A JSON event is the model's
|
|
158
|
+
// stdout channel (tool text can quote a log line verbatim), never the
|
|
159
|
+
// engine log, so it must not be mistaken for a provider rejection.
|
|
160
|
+
if (/^\s*\{/.test(text)) return null;
|
|
161
|
+
if (!(RATE_LIMIT_SOURCE.test(text) || OPENCODE_2X_ENGINE_ERROR_LINE.test(text)) || !RATE_LIMIT_MARKER.test(text)) return null;
|
|
136
162
|
const provider = text.match(PROVIDER_ID_RE)?.[1] ?? null;
|
|
137
163
|
const model = text.match(MODEL_ID_RE)?.[1] ?? null;
|
|
138
|
-
return {provider, model};
|
|
164
|
+
return {provider, model, reason: FREE_QUOTA_MARKER.test(text) ? FREE_QUOTA_REASON : "rate_limit"};
|
|
139
165
|
}
|
|
140
166
|
|
|
141
167
|
// Newest *.log under <baseDir>/log/ (opencode rotates there). Null when the
|
|
@@ -590,11 +616,12 @@ export function formatIdleHangMessage({role, adapter, model, idleMs, maxSilence
|
|
|
590
616
|
return `IDLE_HANG: ${who} produced no output, ran no command and wrote no file for ${formatIdleMinutes(idleMs)}`;
|
|
591
617
|
}
|
|
592
618
|
|
|
593
|
-
export function formatRateLimitedMessage({role, adapter, model, provider}) {
|
|
619
|
+
export function formatRateLimitedMessage({role, adapter, model, provider, freeQuota = false}) {
|
|
594
620
|
const modelLabel = model || adapter;
|
|
595
621
|
const providerLabel = provider && provider !== adapter ? `${provider}/${modelLabel}` : modelLabel;
|
|
622
|
+
const quotaLabel = freeQuota ? " — free quota used up" : "";
|
|
596
623
|
return (
|
|
597
|
-
`RATE_LIMITED: ${role} (${adapter}, ${model || "unknown model"}) hit a provider rate limit (${providerLabel}); ` +
|
|
624
|
+
`RATE_LIMITED: ${role} (${adapter}, ${model || "unknown model"}) hit a provider rate limit${quotaLabel} (${providerLabel}); ` +
|
|
598
625
|
`opencode retried silently in its own log. The operator may need to wait or re-login ` +
|
|
599
626
|
`(\`opencode auth login\` inside WSL — the WSL and Windows opencode accounts are separate).`
|
|
600
627
|
);
|
|
@@ -854,7 +881,7 @@ export function evaluateIdleWatch(watch, {now = Date.now(), platform = process.p
|
|
|
854
881
|
/* log tailing is best-effort; the idle watchdog still applies */
|
|
855
882
|
}
|
|
856
883
|
if (rateLimitConfirmed(watch, now)) {
|
|
857
|
-
return {status: "rate_limited", provider: watch.rateLimitDetail?.provider ?? null, model: watch.rateLimitDetail?.model ?? null};
|
|
884
|
+
return {status: "rate_limited", provider: watch.rateLimitDetail?.provider ?? null, model: watch.rateLimitDetail?.model ?? null, reason: watch.rateLimitDetail?.reason ?? null};
|
|
858
885
|
}
|
|
859
886
|
}
|
|
860
887
|
const quietFor = now - watch.lastActivityAt;
|
|
@@ -223,6 +223,60 @@ function manifestPath(repo, key) {
|
|
|
223
223
|
return path.join(baselineDir(repo, key), "manifest.json");
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
// The repo-relative path of the NEAREST ancestor of `rel` that is a symlink
|
|
227
|
+
// in the working tree, or null. lstat only, so links are never followed.
|
|
228
|
+
// A path under a symlink is not an independent working-tree entry: the
|
|
229
|
+
// symlink itself is the only real thing on disk, and Git can still report
|
|
230
|
+
// tracked paths beneath it (e.g. committed on Windows with
|
|
231
|
+
// core.symlinks=false) as deleted/untracked. Capture and diff must therefore
|
|
232
|
+
// represent the symlink once and skip its descendants, never read a file
|
|
233
|
+
// "through" the link.
|
|
234
|
+
function symlinkAncestor(repo, rel) {
|
|
235
|
+
const segments = String(rel).split("/").filter(Boolean);
|
|
236
|
+
let cur = repo;
|
|
237
|
+
let curRel = "";
|
|
238
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
239
|
+
curRel = curRel ? `${curRel}/${segments[i]}` : segments[i];
|
|
240
|
+
cur = path.join(cur, segments[i]);
|
|
241
|
+
let st;
|
|
242
|
+
try {
|
|
243
|
+
st = fs.lstatSync(cur);
|
|
244
|
+
} catch (e) {
|
|
245
|
+
if (e.code === "ENOENT" || e.code === "ENOTDIR") return null;
|
|
246
|
+
throw new Error(`implementation-baseline: failed to inspect '${cur}': ${e.message}`);
|
|
247
|
+
}
|
|
248
|
+
if (st.isSymbolicLink()) return curRel;
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// The kind of whatever already occupies a baseline snapshot path, or null if
|
|
254
|
+
// nothing does. Defensive: a previous interrupted capture can leave stale
|
|
255
|
+
// snapshot entries behind (the manifest is written last), and recreating a
|
|
256
|
+
// symlink on top of an existing directory otherwise fails with a raw EEXIST.
|
|
257
|
+
function existingSnapshotKind(snapshotPath) {
|
|
258
|
+
let st;
|
|
259
|
+
try {
|
|
260
|
+
st = fs.lstatSync(snapshotPath);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
if (e.code === "ENOENT" || e.code === "ENOTDIR") return null;
|
|
263
|
+
throw new Error(`implementation-baseline: failed to inspect baseline snapshot '${snapshotPath}': ${e.message}`);
|
|
264
|
+
}
|
|
265
|
+
if (st.isSymbolicLink()) return "symlink";
|
|
266
|
+
if (st.isDirectory()) return "directory";
|
|
267
|
+
if (st.isFile()) return "file";
|
|
268
|
+
return "other";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function snapshotConflictError(rel, snapshotPath, existingKind) {
|
|
272
|
+
return new Error(
|
|
273
|
+
`implementation-baseline: refusing to overwrite existing baseline snapshot '${snapshotPath}' for repository path '${rel}' (found ${existingKind}). ` +
|
|
274
|
+
`This usually means the working tree has a symlink at or above '${rel}' while Git tracks regular file(s) beneath that path ` +
|
|
275
|
+
`(for example commits made on Windows with core.symlinks=false); the symlink must be recorded once as a symlink and its descendants skipped, ` +
|
|
276
|
+
`never captured as separate files through the link.`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
226
280
|
// Symlink-safe classification of a filesystem path: NEVER follows a
|
|
227
281
|
// symlink to inspect what it points to. Returns one of:
|
|
228
282
|
// {kind:"absent"} -- nothing at this path
|
|
@@ -247,8 +301,11 @@ function classifyEntry(abs) {
|
|
|
247
301
|
// Captures the CURRENT dirty/untracked state as the baseline for `key`, but
|
|
248
302
|
// only the first time it is called for that key — every later call
|
|
249
303
|
// (a retry of the same phase, or a resumed process) is a no-op, so a
|
|
250
|
-
// failed attempt's own changes are never folded into the baseline.
|
|
251
|
-
//
|
|
304
|
+
// failed attempt's own changes are never folded into the baseline. A
|
|
305
|
+
// baseline directory WITHOUT manifest.json is an incomplete capture (the
|
|
306
|
+
// manifest is written last), so its stale snapshot copies are discarded and
|
|
307
|
+
// recaptured rather than layered on top of; a complete baseline is never
|
|
308
|
+
// deleted or recreated.
|
|
252
309
|
export function ensureBaseline(repo, key, opts={}) {
|
|
253
310
|
const mission = opts.mission ?? null;
|
|
254
311
|
const rawDeclared = Array.isArray(opts.declaredPaths) ? opts.declaredPaths : [];
|
|
@@ -267,6 +324,11 @@ export function ensureBaseline(repo, key, opts={}) {
|
|
|
267
324
|
}
|
|
268
325
|
const dir = baselineDir(repo, key);
|
|
269
326
|
const filesDir = path.join(dir, "files");
|
|
327
|
+
// No manifest means an interrupted capture (manifest.json is written
|
|
328
|
+
// LAST). Its snapshot copies may include stale REAL directories where a
|
|
329
|
+
// symlink now belongs, which would otherwise collide on resume. Discard
|
|
330
|
+
// the incomplete snapshot and capture cleanly from the working tree.
|
|
331
|
+
fs.rmSync(filesDir, {recursive: true, force: true});
|
|
270
332
|
const entries = {};
|
|
271
333
|
for (const entry of statusEntries(repo)) {
|
|
272
334
|
recordBaselineEntry(repo, filesDir, entries, entry.path);
|
|
@@ -398,6 +460,14 @@ function readManifest(file){
|
|
|
398
460
|
|
|
399
461
|
function recordBaselineEntry(repo, filesDir, manifest, rel) {
|
|
400
462
|
if (manifest[rel]) return;
|
|
463
|
+
// Deterministic regardless of the order paths arrive in: any path under a
|
|
464
|
+
// working-tree symlink collapses to that symlink (captured once); the
|
|
465
|
+
// descendant itself is never recorded as a separate file through the link.
|
|
466
|
+
const ancestor = symlinkAncestor(repo, rel);
|
|
467
|
+
if (ancestor) {
|
|
468
|
+
recordBaselineEntry(repo, filesDir, manifest, ancestor);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
401
471
|
const abs = path.join(repo, rel);
|
|
402
472
|
const classified = classifyEntry(abs);
|
|
403
473
|
if (classified.kind === "absent") {
|
|
@@ -405,8 +475,12 @@ function recordBaselineEntry(repo, filesDir, manifest, rel) {
|
|
|
405
475
|
return;
|
|
406
476
|
}
|
|
407
477
|
if (classified.kind === "file") {
|
|
408
|
-
manifest[rel] = {type: "file"};
|
|
409
478
|
const snapshotPath = path.join(filesDir, rel);
|
|
479
|
+
const existing = existingSnapshotKind(snapshotPath);
|
|
480
|
+
if (existing && existing !== "file") {
|
|
481
|
+
throw snapshotConflictError(rel, snapshotPath, existing);
|
|
482
|
+
}
|
|
483
|
+
manifest[rel] = {type: "file"};
|
|
410
484
|
fs.mkdirSync(path.dirname(snapshotPath), {recursive: true});
|
|
411
485
|
fs.copyFileSync(abs, snapshotPath);
|
|
412
486
|
return;
|
|
@@ -419,8 +493,18 @@ function recordBaselineEntry(repo, filesDir, manifest, rel) {
|
|
|
419
493
|
// handing this snapshot symlink straight to `git diff --no-index`
|
|
420
494
|
// later compares target strings safely, with no code in this module
|
|
421
495
|
// ever opening the pointed-to file on either side.
|
|
422
|
-
manifest[rel] = {type: "symlink", target: classified.target};
|
|
423
496
|
const snapshotPath = path.join(filesDir, rel);
|
|
497
|
+
const existing = existingSnapshotKind(snapshotPath);
|
|
498
|
+
if (existing) {
|
|
499
|
+
let existingTarget = null;
|
|
500
|
+
try { existingTarget = fs.readlinkSync(snapshotPath); } catch { /* not a link */ }
|
|
501
|
+
if (existing === "symlink" && existingTarget === classified.target) {
|
|
502
|
+
manifest[rel] = {type: "symlink", target: classified.target};
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
throw snapshotConflictError(rel, snapshotPath, existing);
|
|
506
|
+
}
|
|
507
|
+
manifest[rel] = {type: "symlink", target: classified.target};
|
|
424
508
|
fs.mkdirSync(path.dirname(snapshotPath), {recursive: true});
|
|
425
509
|
fs.symlinkSync(classified.target, snapshotPath);
|
|
426
510
|
return;
|
|
@@ -521,6 +605,13 @@ export function scopedDeltaChunks(repo, key, opts={}) {
|
|
|
521
605
|
const allPaths = new Set([...Object.keys(manifestEntries), ...entries.map(e => e.path), ...declaredPaths]);
|
|
522
606
|
const chunks = [];
|
|
523
607
|
for (const rel of allPaths) {
|
|
608
|
+
// A path under a working-tree symlink is represented by that symlink
|
|
609
|
+
// alone (see recordBaselineEntry). Git status still lists the tracked
|
|
610
|
+
// files beneath such a path as deleted/untracked, and diffing them
|
|
611
|
+
// "through" the link would invent changes that are really just the
|
|
612
|
+
// pre-existing symlink-vs-tracked-files shape. The symlink's own entry
|
|
613
|
+
// (if any) is processed normally below.
|
|
614
|
+
if (symlinkAncestor(repo, rel)) continue;
|
|
524
615
|
const baseEntry = manifestEntries[rel] || null;
|
|
525
616
|
const currentAbs = path.join(repo, rel);
|
|
526
617
|
|