@loopover/engine 1.0.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 +94 -0
- package/LICENSE +661 -0
- package/README.md +676 -0
- package/dist/advisory/gate-advisory.d.ts +151 -0
- package/dist/advisory/gate-advisory.js +512 -0
- package/dist/ai-policy-map.d.ts +97 -0
- package/dist/ai-policy-map.js +377 -0
- package/dist/ams-policy-spec.d.ts +56 -0
- package/dist/ams-policy-spec.js +165 -0
- package/dist/calibration-dashboard.d.ts +23 -0
- package/dist/calibration-dashboard.js +65 -0
- package/dist/calibration-trend.d.ts +40 -0
- package/dist/calibration-trend.js +60 -0
- package/dist/contributor-fit.d.ts +24 -0
- package/dist/contributor-fit.js +50 -0
- package/dist/discovery-index-contract.d.ts +74 -0
- package/dist/discovery-index-contract.js +184 -0
- package/dist/discovery-soft-claim.d.ts +35 -0
- package/dist/discovery-soft-claim.js +50 -0
- package/dist/duplicate-winner.d.ts +57 -0
- package/dist/duplicate-winner.js +98 -0
- package/dist/feasibility.d.ts +27 -0
- package/dist/feasibility.js +86 -0
- package/dist/finding-severity-calibration.d.ts +116 -0
- package/dist/finding-severity-calibration.js +457 -0
- package/dist/fleet-run-manifest.d.ts +41 -0
- package/dist/fleet-run-manifest.js +151 -0
- package/dist/focus-manifest/guidance.d.ts +21 -0
- package/dist/focus-manifest/guidance.js +222 -0
- package/dist/focus-manifest.d.ts +826 -0
- package/dist/focus-manifest.js +2730 -0
- package/dist/gate-verdict-calibration.d.ts +111 -0
- package/dist/gate-verdict-calibration.js +411 -0
- package/dist/github/constants.d.ts +1 -0
- package/dist/github/constants.js +1 -0
- package/dist/github/sanitize-public-comment.d.ts +1 -0
- package/dist/github/sanitize-public-comment.js +26 -0
- package/dist/goal-model.d.ts +7 -0
- package/dist/goal-model.js +87 -0
- package/dist/governor/action-mode.d.ts +55 -0
- package/dist/governor/action-mode.js +74 -0
- package/dist/governor/budget-cap.d.ts +49 -0
- package/dist/governor/budget-cap.js +34 -0
- package/dist/governor/chokepoint.d.ts +58 -0
- package/dist/governor/chokepoint.js +269 -0
- package/dist/governor/kill-switch.d.ts +38 -0
- package/dist/governor/kill-switch.js +62 -0
- package/dist/governor/rate-limit.d.ts +40 -0
- package/dist/governor/rate-limit.js +63 -0
- package/dist/governor/reputation-throttle.d.ts +50 -0
- package/dist/governor/reputation-throttle.js +94 -0
- package/dist/governor/run-halt.d.ts +39 -0
- package/dist/governor/run-halt.js +83 -0
- package/dist/governor/self-plagiarism.d.ts +47 -0
- package/dist/governor/self-plagiarism.js +200 -0
- package/dist/governor/write-rate-limit.d.ts +52 -0
- package/dist/governor/write-rate-limit.js +132 -0
- package/dist/governor-ledger.d.ts +25 -0
- package/dist/governor-ledger.js +91 -0
- package/dist/index.d.ts +99 -0
- package/dist/index.js +120 -0
- package/dist/issue-plan-decomposition.d.ts +18 -0
- package/dist/issue-plan-decomposition.js +56 -0
- package/dist/issue-rag-query.d.ts +12 -0
- package/dist/issue-rag-query.js +35 -0
- package/dist/local-scorer.d.ts +34 -0
- package/dist/local-scorer.js +36 -0
- package/dist/metadata-best-min-score.d.ts +9 -0
- package/dist/metadata-best-min-score.js +9 -0
- package/dist/metadata-best-pick.d.ts +9 -0
- package/dist/metadata-best-pick.js +9 -0
- package/dist/metadata-min-score.d.ts +9 -0
- package/dist/metadata-min-score.js +13 -0
- package/dist/metadata-top-min-score.d.ts +9 -0
- package/dist/metadata-top-min-score.js +13 -0
- package/dist/metadata-top-pick.d.ts +9 -0
- package/dist/metadata-top-pick.js +13 -0
- package/dist/miner/acceptance-criteria.d.ts +43 -0
- package/dist/miner/acceptance-criteria.js +77 -0
- package/dist/miner/agent-sdk-driver.d.ts +39 -0
- package/dist/miner/agent-sdk-driver.js +162 -0
- package/dist/miner/attempt-log.d.ts +29 -0
- package/dist/miner/attempt-log.js +100 -0
- package/dist/miner/attempt-metering.d.ts +39 -0
- package/dist/miner/attempt-metering.js +67 -0
- package/dist/miner/cli-subprocess-driver.d.ts +68 -0
- package/dist/miner/cli-subprocess-driver.js +272 -0
- package/dist/miner/coding-agent-driver.d.ts +33 -0
- package/dist/miner/coding-agent-driver.js +36 -0
- package/dist/miner/coding-agent-invoke.d.ts +11 -0
- package/dist/miner/coding-agent-invoke.js +80 -0
- package/dist/miner/coding-agent-mode.d.ts +25 -0
- package/dist/miner/coding-agent-mode.js +43 -0
- package/dist/miner/driver-factory.d.ts +82 -0
- package/dist/miner/driver-factory.js +169 -0
- package/dist/miner/harness-submission-trigger.d.ts +37 -0
- package/dist/miner/harness-submission-trigger.js +46 -0
- package/dist/miner/iterate-loop.d.ts +97 -0
- package/dist/miner/iterate-loop.js +278 -0
- package/dist/miner/iterate-policy.d.ts +98 -0
- package/dist/miner/iterate-policy.js +98 -0
- package/dist/miner/lint-guard.d.ts +50 -0
- package/dist/miner/lint-guard.js +68 -0
- package/dist/miner/local-write-tools.d.ts +81 -0
- package/dist/miner/local-write-tools.js +113 -0
- package/dist/miner/loop-reentry-policy.d.ts +36 -0
- package/dist/miner/loop-reentry-policy.js +53 -0
- package/dist/miner/repo-map.d.ts +51 -0
- package/dist/miner/repo-map.js +268 -0
- package/dist/miner/self-review-adapter.d.ts +106 -0
- package/dist/miner/self-review-adapter.js +83 -0
- package/dist/miner/submission-gate.d.ts +40 -0
- package/dist/miner/submission-gate.js +89 -0
- package/dist/miner/worktree-allocator.d.ts +59 -0
- package/dist/miner/worktree-allocator.js +60 -0
- package/dist/miner/worktree-pool.d.ts +55 -0
- package/dist/miner/worktree-pool.js +65 -0
- package/dist/miner-goal-lane-fit.d.ts +18 -0
- package/dist/miner-goal-lane-fit.js +76 -0
- package/dist/miner-goal-spec.d.ts +139 -0
- package/dist/miner-goal-spec.js +281 -0
- package/dist/miner-prediction-metrics.d.ts +16 -0
- package/dist/miner-prediction-metrics.js +61 -0
- package/dist/miner-telemetry.d.ts +32 -0
- package/dist/miner-telemetry.js +87 -0
- package/dist/objective-anchor.d.ts +125 -0
- package/dist/objective-anchor.js +401 -0
- package/dist/opportunity-competition.d.ts +6 -0
- package/dist/opportunity-competition.js +29 -0
- package/dist/opportunity-freshness.d.ts +18 -0
- package/dist/opportunity-freshness.js +55 -0
- package/dist/opportunity-metadata.d.ts +53 -0
- package/dist/opportunity-metadata.js +204 -0
- package/dist/opportunity-ranker.d.ts +46 -0
- package/dist/opportunity-ranker.js +74 -0
- package/dist/pairwise-calibration.d.ts +49 -0
- package/dist/pairwise-calibration.js +106 -0
- package/dist/phase7-calibration-loop.d.ts +136 -0
- package/dist/phase7-calibration-loop.js +437 -0
- package/dist/plan-blocked.d.ts +6 -0
- package/dist/plan-blocked.js +24 -0
- package/dist/plan-completed.d.ts +5 -0
- package/dist/plan-completed.js +6 -0
- package/dist/plan-completion.d.ts +6 -0
- package/dist/plan-completion.js +7 -0
- package/dist/plan-empty.d.ts +5 -0
- package/dist/plan-empty.js +6 -0
- package/dist/plan-export.d.ts +27 -0
- package/dist/plan-export.js +86 -0
- package/dist/plan-failure.d.ts +5 -0
- package/dist/plan-failure.js +6 -0
- package/dist/plan-overall-status.d.ts +6 -0
- package/dist/plan-overall-status.js +25 -0
- package/dist/plan-pending.d.ts +5 -0
- package/dist/plan-pending.js +6 -0
- package/dist/plan-progress-complete.d.ts +7 -0
- package/dist/plan-progress-complete.js +10 -0
- package/dist/plan-ready.d.ts +6 -0
- package/dist/plan-ready.js +12 -0
- package/dist/plan-running.d.ts +5 -0
- package/dist/plan-running.js +6 -0
- package/dist/plan-skipped.d.ts +5 -0
- package/dist/plan-skipped.js +6 -0
- package/dist/plan-step-count.d.ts +5 -0
- package/dist/plan-step-count.js +6 -0
- package/dist/plan-step-stats.d.ts +5 -0
- package/dist/plan-step-stats.js +6 -0
- package/dist/plan-templates.d.ts +25 -0
- package/dist/plan-templates.js +107 -0
- package/dist/plan-terminated.d.ts +6 -0
- package/dist/plan-terminated.js +9 -0
- package/dist/portfolio/non-convergence.d.ts +37 -0
- package/dist/portfolio/non-convergence.js +57 -0
- package/dist/portfolio/queue.d.ts +30 -0
- package/dist/portfolio/queue.js +150 -0
- package/dist/predicted-gate.d.ts +103 -0
- package/dist/predicted-gate.js +218 -0
- package/dist/prompt-packet.d.ts +11 -0
- package/dist/prompt-packet.js +34 -0
- package/dist/ranked-opportunity-best-min-score.d.ts +8 -0
- package/dist/ranked-opportunity-best-min-score.js +9 -0
- package/dist/ranked-opportunity-best-pick.d.ts +8 -0
- package/dist/ranked-opportunity-best-pick.js +9 -0
- package/dist/ranked-opportunity-min-score.d.ts +8 -0
- package/dist/ranked-opportunity-min-score.js +13 -0
- package/dist/ranked-opportunity-top-min-score.d.ts +8 -0
- package/dist/ranked-opportunity-top-min-score.js +13 -0
- package/dist/review/advisory-ai-routing-config.d.ts +8 -0
- package/dist/review/advisory-ai-routing-config.js +40 -0
- package/dist/review/check-names.d.ts +1 -0
- package/dist/review/check-names.js +1 -0
- package/dist/review/cla-check.d.ts +49 -0
- package/dist/review/cla-check.js +71 -0
- package/dist/review/diff-file-priority.d.ts +1 -0
- package/dist/review/diff-file-priority.js +12 -0
- package/dist/review/enrichment-analyzer-names.d.ts +3 -0
- package/dist/review/enrichment-analyzer-names.js +63 -0
- package/dist/review/guardrail-config.d.ts +10 -0
- package/dist/review/guardrail-config.js +62 -0
- package/dist/review/linked-issue-hard-rules-config.d.ts +4 -0
- package/dist/review/linked-issue-hard-rules-config.js +79 -0
- package/dist/review/linked-issue-label-propagation.d.ts +9 -0
- package/dist/review/linked-issue-label-propagation.js +114 -0
- package/dist/review/pre-merge-checks.d.ts +28 -0
- package/dist/review/pre-merge-checks.js +66 -0
- package/dist/review/review-thread-findings.d.ts +1 -0
- package/dist/review/review-thread-findings.js +1 -0
- package/dist/review/safe-url.d.ts +5 -0
- package/dist/review/safe-url.js +137 -0
- package/dist/review/screenshot-table-gate.d.ts +87 -0
- package/dist/review/screenshot-table-gate.js +340 -0
- package/dist/review/unlinked-issue-guardrail-config.d.ts +9 -0
- package/dist/review/unlinked-issue-guardrail-config.js +44 -0
- package/dist/reviewer-consensus-calibration.d.ts +113 -0
- package/dist/reviewer-consensus-calibration.js +493 -0
- package/dist/reward-risk.d.ts +223 -0
- package/dist/reward-risk.js +618 -0
- package/dist/scoring/label-match.d.ts +4 -0
- package/dist/scoring/label-match.js +145 -0
- package/dist/scoring/model.d.ts +31 -0
- package/dist/scoring/model.js +157 -0
- package/dist/scoring/pending-pr-scenarios.d.ts +44 -0
- package/dist/scoring/pending-pr-scenarios.js +145 -0
- package/dist/scoring/preview.d.ts +246 -0
- package/dist/scoring/preview.js +1108 -0
- package/dist/scoring/types.d.ts +131 -0
- package/dist/scoring/types.js +7 -0
- package/dist/settings/auto-close-exempt.d.ts +10 -0
- package/dist/settings/auto-close-exempt.js +58 -0
- package/dist/settings/autonomy.d.ts +33 -0
- package/dist/settings/autonomy.js +80 -0
- package/dist/settings/command-authorization.d.ts +49 -0
- package/dist/settings/command-authorization.js +222 -0
- package/dist/settings/contributor-blacklist.d.ts +17 -0
- package/dist/settings/contributor-blacklist.js +86 -0
- package/dist/settings/moderation-rules.d.ts +51 -0
- package/dist/settings/moderation-rules.js +104 -0
- package/dist/settings/pr-type-label.d.ts +70 -0
- package/dist/settings/pr-type-label.js +149 -0
- package/dist/signals/change-guardrail.d.ts +49 -0
- package/dist/signals/change-guardrail.js +164 -0
- package/dist/signals/check-summary.d.ts +9 -0
- package/dist/signals/check-summary.js +12 -0
- package/dist/signals/contributor-calibration.d.ts +23 -0
- package/dist/signals/contributor-calibration.js +47 -0
- package/dist/signals/duplicate-winner.d.ts +9 -0
- package/dist/signals/duplicate-winner.js +9 -0
- package/dist/signals/path-matchers.d.ts +36 -0
- package/dist/signals/path-matchers.js +371 -0
- package/dist/signals/predicted-gate-engine.d.ts +62 -0
- package/dist/signals/predicted-gate-engine.js +895 -0
- package/dist/signals/preflight-limits.d.ts +14 -0
- package/dist/signals/preflight-limits.js +14 -0
- package/dist/signals/slop.d.ts +56 -0
- package/dist/signals/slop.js +324 -0
- package/dist/signals/test-evidence.d.ts +25 -0
- package/dist/signals/test-evidence.js +108 -0
- package/dist/subprocess-env.d.ts +23 -0
- package/dist/subprocess-env.js +74 -0
- package/dist/track-record-summary.d.ts +92 -0
- package/dist/track-record-summary.js +301 -0
- package/dist/types/manifest-deps-types.d.ts +514 -0
- package/dist/types/manifest-deps-types.js +4 -0
- package/dist/types/predicted-gate-types.d.ts +338 -0
- package/dist/types/predicted-gate-types.js +3 -0
- package/dist/types/reward-risk-types.d.ts +319 -0
- package/dist/types/reward-risk-types.js +11 -0
- package/dist/utils/json.d.ts +1 -0
- package/dist/utils/json.js +3 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/package.json +80 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Driver-level structured attempt log — pure event shapes (#4294). Mirrors `governor-ledger.ts`: fixed vocabulary,
|
|
2
|
+
// fail-closed normalization, JSON-round-trip-verified payloads. SQLite persistence + JSONL export live in
|
|
3
|
+
// `packages/gittensory-miner/lib/attempt-log.js`, which imports this normalizer from the engine package.
|
|
4
|
+
export const ATTEMPT_LOG_EVENT_TYPES = Object.freeze([
|
|
5
|
+
"attempt_started",
|
|
6
|
+
"attempt_tool_edit",
|
|
7
|
+
"attempt_shadow",
|
|
8
|
+
"attempt_succeeded",
|
|
9
|
+
"attempt_failed",
|
|
10
|
+
"attempt_aborted",
|
|
11
|
+
]);
|
|
12
|
+
const attemptEventTypeSet = new Set(ATTEMPT_LOG_EVENT_TYPES);
|
|
13
|
+
const codingAgentModes = new Set(["paused", "dry_run", "live"]);
|
|
14
|
+
/* v8 ignore start -- Normalization helpers are covered through normalizeAttemptLogEvent export tests. */
|
|
15
|
+
function normalizeRequiredString(value, code) {
|
|
16
|
+
if (typeof value !== "string")
|
|
17
|
+
throw new Error(code);
|
|
18
|
+
const trimmed = value.trim();
|
|
19
|
+
if (!trimmed)
|
|
20
|
+
throw new Error(code);
|
|
21
|
+
return trimmed;
|
|
22
|
+
}
|
|
23
|
+
function jsonRoundTripEqual(a, b) {
|
|
24
|
+
if (a === b)
|
|
25
|
+
return true;
|
|
26
|
+
if (typeof a !== typeof b || a === null || b === null)
|
|
27
|
+
return false;
|
|
28
|
+
if (typeof a !== "object")
|
|
29
|
+
return false;
|
|
30
|
+
const aIsArray = Array.isArray(a);
|
|
31
|
+
if (aIsArray !== Array.isArray(b))
|
|
32
|
+
return false;
|
|
33
|
+
if (aIsArray) {
|
|
34
|
+
const bArr = b;
|
|
35
|
+
const aArr = a;
|
|
36
|
+
return aArr.length === bArr.length && aArr.every((value, index) => jsonRoundTripEqual(value, bArr[index]));
|
|
37
|
+
}
|
|
38
|
+
const aKeys = Object.keys(a);
|
|
39
|
+
const bRecord = b;
|
|
40
|
+
return aKeys.length === Object.keys(bRecord).length && aKeys.every((key) => Object.hasOwn(bRecord, key) && jsonRoundTripEqual(a[key], bRecord[key]));
|
|
41
|
+
}
|
|
42
|
+
function serializePayload(payload) {
|
|
43
|
+
if (payload === undefined)
|
|
44
|
+
return "{}";
|
|
45
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
46
|
+
throw new Error("invalid_payload");
|
|
47
|
+
}
|
|
48
|
+
let json;
|
|
49
|
+
try {
|
|
50
|
+
json = JSON.stringify(payload);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new Error("invalid_payload");
|
|
54
|
+
}
|
|
55
|
+
if (!jsonRoundTripEqual(JSON.parse(json), payload)) {
|
|
56
|
+
throw new Error("invalid_payload");
|
|
57
|
+
}
|
|
58
|
+
return json;
|
|
59
|
+
}
|
|
60
|
+
/* v8 ignore stop */
|
|
61
|
+
function normalizeMode(value) {
|
|
62
|
+
const mode = normalizeRequiredString(value, "invalid_mode");
|
|
63
|
+
if (!codingAgentModes.has(mode))
|
|
64
|
+
throw new Error("invalid_mode");
|
|
65
|
+
return mode;
|
|
66
|
+
}
|
|
67
|
+
/** Validate and normalize an attempt-log row before append. Fail-closed on unknown types/modes. */
|
|
68
|
+
export function normalizeAttemptLogEvent(input) {
|
|
69
|
+
if (!input || typeof input !== "object")
|
|
70
|
+
throw new Error("invalid_event");
|
|
71
|
+
const event = input;
|
|
72
|
+
const eventType = normalizeRequiredString(event.eventType, "invalid_event_type");
|
|
73
|
+
if (!attemptEventTypeSet.has(eventType))
|
|
74
|
+
throw new Error("invalid_event_type");
|
|
75
|
+
return {
|
|
76
|
+
eventType: eventType,
|
|
77
|
+
attemptId: normalizeRequiredString(event.attemptId, "invalid_attempt_id"),
|
|
78
|
+
actionClass: normalizeRequiredString(event.actionClass, "invalid_action_class"),
|
|
79
|
+
mode: normalizeMode(event.mode),
|
|
80
|
+
reason: normalizeRequiredString(event.reason, "invalid_reason"),
|
|
81
|
+
payloadJson: serializePayload(event.payload),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Serialize normalized events as JSONL (one attempt's trace). Pure. */
|
|
85
|
+
export function formatAttemptLogJsonl(events) {
|
|
86
|
+
return events.map((event) => JSON.stringify(event)).join("\n");
|
|
87
|
+
}
|
|
88
|
+
/** In-memory appender for tests and local tooling — production persistence uses `gittensory-miner/lib/attempt-log.js`. */
|
|
89
|
+
export function createAttemptLogBuffer() {
|
|
90
|
+
const rows = [];
|
|
91
|
+
return {
|
|
92
|
+
append(event) {
|
|
93
|
+
const normalized = normalizeAttemptLogEvent(event);
|
|
94
|
+
rows.push(normalized);
|
|
95
|
+
return normalized;
|
|
96
|
+
},
|
|
97
|
+
events: () => rows,
|
|
98
|
+
jsonl: () => formatAttemptLogJsonl(rows),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** One usage increment normalized to the metered unit. `costUsd` is 0 when a driver can't report spend. */
|
|
2
|
+
export type AttemptUsage = {
|
|
3
|
+
/** Model tokens consumed (prompt + completion) in this increment. */
|
|
4
|
+
tokens: number;
|
|
5
|
+
/** Agent turns (one full agent iteration) in this increment. */
|
|
6
|
+
turns: number;
|
|
7
|
+
/** Wall-clock milliseconds elapsed in this increment. */
|
|
8
|
+
wallClockMs: number;
|
|
9
|
+
/** Monetary cost (USD) of this increment; 0 when the driver does not report spend. */
|
|
10
|
+
costUsd: number;
|
|
11
|
+
};
|
|
12
|
+
/** Accumulated attempt totals — same shape as a single increment. */
|
|
13
|
+
export type AttemptMeterTotals = AttemptUsage;
|
|
14
|
+
/** Per-axis ceilings. An omitted axis means "no limit on that axis". */
|
|
15
|
+
export type AttemptBudget = {
|
|
16
|
+
maxTokens?: number;
|
|
17
|
+
maxTurns?: number;
|
|
18
|
+
maxWallClockMs?: number;
|
|
19
|
+
maxCostUsd?: number;
|
|
20
|
+
};
|
|
21
|
+
/** Which metered axes have reached or exceeded their ceiling. */
|
|
22
|
+
export type AttemptBudgetAxis = "tokens" | "turns" | "wallClockMs" | "costUsd";
|
|
23
|
+
export type AttemptMeterVerdict = {
|
|
24
|
+
totals: AttemptMeterTotals;
|
|
25
|
+
/** True when no axis has reached its ceiling. */
|
|
26
|
+
withinBudget: boolean;
|
|
27
|
+
/** The axes at/over ceiling (empty when within budget). */
|
|
28
|
+
breaches: AttemptBudgetAxis[];
|
|
29
|
+
};
|
|
30
|
+
/** Fold one usage increment into a running total. Pure — returns a new total, mutates nothing. */
|
|
31
|
+
export declare function accumulateAttemptUsage(total: AttemptMeterTotals, next: AttemptUsage): AttemptMeterTotals;
|
|
32
|
+
/** Sum a sequence of usage increments from zero. Pure. */
|
|
33
|
+
export declare function meterAttemptUsage(increments: readonly AttemptUsage[]): AttemptMeterTotals;
|
|
34
|
+
/**
|
|
35
|
+
* Evaluate accumulated totals against a budget. An axis is breached when its total is **at or above** its
|
|
36
|
+
* ceiling (`>=`), so a total exactly equal to the ceiling counts as a breach — the boundary the caller must
|
|
37
|
+
* stop on. An omitted ceiling never breaches. Pure and deterministic.
|
|
38
|
+
*/
|
|
39
|
+
export declare function evaluateAttemptBudget(totals: AttemptMeterTotals, budget: AttemptBudget): AttemptMeterVerdict;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Per-attempt cost/turn metering (#4311): pure accumulation of a coding-agent attempt's usage
|
|
2
|
+
// (tokens / turns / wall-clock / cost) plus a pure evaluation of the running totals against a configured
|
|
3
|
+
// budget. Numbers only — no IO, no Date.now(), no randomness, no enforcement. This module reports whether a
|
|
4
|
+
// ceiling has been reached; it never stops, kills, or gates a driver. That enforcement wiring (graceful-stop
|
|
5
|
+
// vs. hard SIGKILL) and the attempt-log persistence (#4294) are separate, maintainer-owned concerns.
|
|
6
|
+
//
|
|
7
|
+
// Mirrors the governor/rate-limit.ts discipline ("computes numbers only... does NOT store state, schedule,
|
|
8
|
+
// or gate any write action"). Drivers report usage in different native shapes (CLI-subprocess vs. Agent-SDK);
|
|
9
|
+
// the caller normalizes each increment to the {tokens, turns, wallClockMs, costUsd} unit defined here.
|
|
10
|
+
const ZERO_USAGE = { tokens: 0, turns: 0, wallClockMs: 0, costUsd: 0 };
|
|
11
|
+
function assertNonNegativeFiniteNumber(name, value) {
|
|
12
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
13
|
+
throw new RangeError(`${name} must be a finite, non-negative number`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function assertAttemptUsage(name, usage) {
|
|
17
|
+
assertNonNegativeFiniteNumber(`${name}.tokens`, usage.tokens);
|
|
18
|
+
assertNonNegativeFiniteNumber(`${name}.turns`, usage.turns);
|
|
19
|
+
assertNonNegativeFiniteNumber(`${name}.wallClockMs`, usage.wallClockMs);
|
|
20
|
+
assertNonNegativeFiniteNumber(`${name}.costUsd`, usage.costUsd);
|
|
21
|
+
}
|
|
22
|
+
function assertAttemptBudget(budget) {
|
|
23
|
+
if (budget.maxTokens !== undefined)
|
|
24
|
+
assertNonNegativeFiniteNumber("budget.maxTokens", budget.maxTokens);
|
|
25
|
+
if (budget.maxTurns !== undefined)
|
|
26
|
+
assertNonNegativeFiniteNumber("budget.maxTurns", budget.maxTurns);
|
|
27
|
+
if (budget.maxWallClockMs !== undefined) {
|
|
28
|
+
assertNonNegativeFiniteNumber("budget.maxWallClockMs", budget.maxWallClockMs);
|
|
29
|
+
}
|
|
30
|
+
if (budget.maxCostUsd !== undefined)
|
|
31
|
+
assertNonNegativeFiniteNumber("budget.maxCostUsd", budget.maxCostUsd);
|
|
32
|
+
}
|
|
33
|
+
/** Fold one usage increment into a running total. Pure — returns a new total, mutates nothing. */
|
|
34
|
+
export function accumulateAttemptUsage(total, next) {
|
|
35
|
+
assertAttemptUsage("total", total);
|
|
36
|
+
assertAttemptUsage("next", next);
|
|
37
|
+
return {
|
|
38
|
+
tokens: total.tokens + next.tokens,
|
|
39
|
+
turns: total.turns + next.turns,
|
|
40
|
+
wallClockMs: total.wallClockMs + next.wallClockMs,
|
|
41
|
+
costUsd: total.costUsd + next.costUsd,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Sum a sequence of usage increments from zero. Pure. */
|
|
45
|
+
export function meterAttemptUsage(increments) {
|
|
46
|
+
return increments.reduce(accumulateAttemptUsage, { ...ZERO_USAGE });
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Evaluate accumulated totals against a budget. An axis is breached when its total is **at or above** its
|
|
50
|
+
* ceiling (`>=`), so a total exactly equal to the ceiling counts as a breach — the boundary the caller must
|
|
51
|
+
* stop on. An omitted ceiling never breaches. Pure and deterministic.
|
|
52
|
+
*/
|
|
53
|
+
export function evaluateAttemptBudget(totals, budget) {
|
|
54
|
+
assertAttemptUsage("totals", totals);
|
|
55
|
+
assertAttemptBudget(budget);
|
|
56
|
+
const breaches = [];
|
|
57
|
+
if (budget.maxTokens !== undefined && totals.tokens >= budget.maxTokens)
|
|
58
|
+
breaches.push("tokens");
|
|
59
|
+
if (budget.maxTurns !== undefined && totals.turns >= budget.maxTurns)
|
|
60
|
+
breaches.push("turns");
|
|
61
|
+
if (budget.maxWallClockMs !== undefined && totals.wallClockMs >= budget.maxWallClockMs) {
|
|
62
|
+
breaches.push("wallClockMs");
|
|
63
|
+
}
|
|
64
|
+
if (budget.maxCostUsd !== undefined && totals.costUsd >= budget.maxCostUsd)
|
|
65
|
+
breaches.push("costUsd");
|
|
66
|
+
return { totals, withinBudget: breaches.length === 0, breaches };
|
|
67
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { CodingAgentDriver, CodingAgentDriverTask } from "./coding-agent-driver.js";
|
|
2
|
+
/** The spawn primitive a CLI driver depends on — the generalized shape of src/selfhost/ai.ts's `SpawnFn`. Injected
|
|
3
|
+
* so the driver never hardcodes `child_process`; a fake resolves this in tests. */
|
|
4
|
+
export type CliSubprocessSpawnFn = (cmd: string, args: readonly string[], opts: {
|
|
5
|
+
cwd: string;
|
|
6
|
+
env: Record<string, string | undefined>;
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
firstOutputTimeoutMs?: number;
|
|
9
|
+
}) => Promise<{
|
|
10
|
+
stdout: string;
|
|
11
|
+
code: number | null;
|
|
12
|
+
stderr?: string;
|
|
13
|
+
timedOut?: boolean;
|
|
14
|
+
stalledNoOutput?: boolean;
|
|
15
|
+
}>;
|
|
16
|
+
export type CliSubprocessDriverOptions = {
|
|
17
|
+
/** The coding-agent CLI to spawn (e.g. "claude" or "codex"). */
|
|
18
|
+
command: string;
|
|
19
|
+
/** Injected spawn — a real `child_process` spawn in prod, a fake in tests. */
|
|
20
|
+
spawn: CliSubprocessSpawnFn;
|
|
21
|
+
/** Per-run wall-clock budget handed to the spawn. Default: 120000ms. */
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
/** Optional fast-fail deadline (#4994/#5053): killed early and reported distinctly ("stalled", not a generic
|
|
24
|
+
* timeout) if the subprocess produces zero stdout before this elapses. Mirrors src/selfhost/ai.ts's
|
|
25
|
+
* `firstOutputTimeoutMs`/`resolveClaudeFirstOutputTimeoutMs` pattern, built after a naive single-timeout design
|
|
26
|
+
* caused a real production outage against these same claude/codex binaries. Opt-in and backward compatible:
|
|
27
|
+
* omitting it leaves behavior exactly as it was before this option existed. */
|
|
28
|
+
firstOutputTimeoutMs?: number;
|
|
29
|
+
/** Parent env to allowlist from. Default: `{}` (a real caller passes `process.env`; the default stays pure). */
|
|
30
|
+
parentEnv?: Record<string, string | undefined>;
|
|
31
|
+
/** Extra env overlaid on the allowlisted parent (e.g. an auth value the CLI reads). */
|
|
32
|
+
env?: Record<string, string | undefined>;
|
|
33
|
+
/** Known secret values (e.g. an injected auth token) to strip from any surfaced output, on top of the well-known
|
|
34
|
+
* token-shape patterns. */
|
|
35
|
+
knownSecrets?: readonly string[];
|
|
36
|
+
/** Build the CLI argv from a task. Defaults to `defaultClaudeCliArgs`/`defaultCodexCliArgs` based on
|
|
37
|
+
* `command` -- a caller overrides this to point at a different real CLI's own flags. */
|
|
38
|
+
buildArgs?: (task: CodingAgentDriverTask) => readonly string[];
|
|
39
|
+
};
|
|
40
|
+
/** Real, verified `claude -p` non-interactive argv (confirmed against `claude --help` and a live invocation,
|
|
41
|
+
* #5135 follow-up -- the previous default argv here used `--max-turns`/`--acceptance-criteria`, neither of
|
|
42
|
+
* which is a real `claude` CLI flag, and never passed `-p`/`--print` at all, meaning a spawned `claude`
|
|
43
|
+
* process would start an INTERACTIVE session against a subprocess whose stdin is closed).
|
|
44
|
+
* - `-p`/`--print` is REQUIRED for non-interactive use; without it `claude` starts an interactive session.
|
|
45
|
+
* - `--output-format json` produces a single parseable JSON result on stdout, matching `claudeErrorStatus`'s
|
|
46
|
+
* own `JSON.parse(stdout.trim())` assumption (which was already written for this shape, just never
|
|
47
|
+
* actually triggered because this flag was never passed).
|
|
48
|
+
* - `--permission-mode acceptEdits` is the same edit-permission scope the Agent-SDK driver already uses
|
|
49
|
+
* (#4267) -- file edits run unattended inside the scoped worktree, nothing broader.
|
|
50
|
+
* There is no turn-budget flag on the real CLI (verified via `claude --help`) and no acceptance-criteria
|
|
51
|
+
* flag either -- the coding agent discovers `task.acceptanceCriteriaPath` itself via its own Read tool
|
|
52
|
+
* inside the scoped working directory, exactly like the Agent-SDK driver already does (agent-sdk-driver.ts
|
|
53
|
+
* never passes it as a distinct option either; only `task.instructions` is forwarded as the prompt there
|
|
54
|
+
* too). The wall-clock `timeoutMs` (already implemented) is this provider's only real turn/cost ceiling. */
|
|
55
|
+
export declare function defaultClaudeCliArgs(task: CodingAgentDriverTask): string[];
|
|
56
|
+
/** Real, verified `codex exec` non-interactive argv (confirmed against `codex exec --help`, #5135
|
|
57
|
+
* follow-up). `exec` is a REQUIRED subcommand -- without it `codex` starts an interactive session, the same
|
|
58
|
+
* class of bug the missing `-p` had for claude. `--json` emits the JSONL event stream
|
|
59
|
+
* `codexErrorFromStdout` already parses line-by-line. `--sandbox workspace-write` is codex's own equivalent
|
|
60
|
+
* of claude's `acceptEdits` -- the model may write within the workspace, nothing broader. Same "no
|
|
61
|
+
* turn-budget flag" and "no acceptance-criteria flag" gaps as claude apply here too. */
|
|
62
|
+
export declare function defaultCodexCliArgs(task: CodingAgentDriverTask): string[];
|
|
63
|
+
/**
|
|
64
|
+
* Create a {@link CodingAgentDriver} that runs the coding agent as a CLI subprocess. A non-zero or absent exit
|
|
65
|
+
* code, or a timeout, yields `ok: false` with a redacted error; exit `0` yields `ok: true`. Any subprocess output
|
|
66
|
+
* kept as a transcript or folded into an error is redacted first.
|
|
67
|
+
*/
|
|
68
|
+
export declare function createCliSubprocessCodingAgentDriver(options: CliSubprocessDriverOptions): CodingAgentDriver;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { buildAllowlistedEnv, redactSecrets } from "../subprocess-env.js";
|
|
2
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
3
|
+
const MAX_TRANSCRIPT_CHARS = 8000;
|
|
4
|
+
const MAX_ERROR_DETAIL_CHARS = 500;
|
|
5
|
+
const CODING_AGENT_ENV_ALLOWLIST = [
|
|
6
|
+
"HTTPS_PROXY",
|
|
7
|
+
"HTTP_PROXY",
|
|
8
|
+
"LANG",
|
|
9
|
+
"LC_ALL",
|
|
10
|
+
"NODE_EXTRA_CA_CERTS",
|
|
11
|
+
"NO_PROXY",
|
|
12
|
+
"PATH",
|
|
13
|
+
"SSL_CERT_DIR",
|
|
14
|
+
"SSL_CERT_FILE",
|
|
15
|
+
"TERM",
|
|
16
|
+
"https_proxy",
|
|
17
|
+
"http_proxy",
|
|
18
|
+
"no_proxy",
|
|
19
|
+
];
|
|
20
|
+
/** Real, verified `claude -p` non-interactive argv (confirmed against `claude --help` and a live invocation,
|
|
21
|
+
* #5135 follow-up -- the previous default argv here used `--max-turns`/`--acceptance-criteria`, neither of
|
|
22
|
+
* which is a real `claude` CLI flag, and never passed `-p`/`--print` at all, meaning a spawned `claude`
|
|
23
|
+
* process would start an INTERACTIVE session against a subprocess whose stdin is closed).
|
|
24
|
+
* - `-p`/`--print` is REQUIRED for non-interactive use; without it `claude` starts an interactive session.
|
|
25
|
+
* - `--output-format json` produces a single parseable JSON result on stdout, matching `claudeErrorStatus`'s
|
|
26
|
+
* own `JSON.parse(stdout.trim())` assumption (which was already written for this shape, just never
|
|
27
|
+
* actually triggered because this flag was never passed).
|
|
28
|
+
* - `--permission-mode acceptEdits` is the same edit-permission scope the Agent-SDK driver already uses
|
|
29
|
+
* (#4267) -- file edits run unattended inside the scoped worktree, nothing broader.
|
|
30
|
+
* There is no turn-budget flag on the real CLI (verified via `claude --help`) and no acceptance-criteria
|
|
31
|
+
* flag either -- the coding agent discovers `task.acceptanceCriteriaPath` itself via its own Read tool
|
|
32
|
+
* inside the scoped working directory, exactly like the Agent-SDK driver already does (agent-sdk-driver.ts
|
|
33
|
+
* never passes it as a distinct option either; only `task.instructions` is forwarded as the prompt there
|
|
34
|
+
* too). The wall-clock `timeoutMs` (already implemented) is this provider's only real turn/cost ceiling. */
|
|
35
|
+
export function defaultClaudeCliArgs(task) {
|
|
36
|
+
return ["--print", "--output-format", "json", "--permission-mode", "acceptEdits", task.instructions];
|
|
37
|
+
}
|
|
38
|
+
/** Real, verified `codex exec` non-interactive argv (confirmed against `codex exec --help`, #5135
|
|
39
|
+
* follow-up). `exec` is a REQUIRED subcommand -- without it `codex` starts an interactive session, the same
|
|
40
|
+
* class of bug the missing `-p` had for claude. `--json` emits the JSONL event stream
|
|
41
|
+
* `codexErrorFromStdout` already parses line-by-line. `--sandbox workspace-write` is codex's own equivalent
|
|
42
|
+
* of claude's `acceptEdits` -- the model may write within the workspace, nothing broader. Same "no
|
|
43
|
+
* turn-budget flag" and "no acceptance-criteria flag" gaps as claude apply here too. */
|
|
44
|
+
export function defaultCodexCliArgs(task) {
|
|
45
|
+
return ["exec", "--json", "--sandbox", "workspace-write", task.instructions];
|
|
46
|
+
}
|
|
47
|
+
/** Resolve the default argv builder for a known production command; throws for anything else so an
|
|
48
|
+
* unrecognized `command` fails at driver-construction time (never silently invokes a binary with no known
|
|
49
|
+
* real argv shape) unless the caller supplies an explicit `options.buildArgs` override. */
|
|
50
|
+
function resolveDefaultBuildArgs(command) {
|
|
51
|
+
if (command === "claude")
|
|
52
|
+
return defaultClaudeCliArgs;
|
|
53
|
+
if (command === "codex")
|
|
54
|
+
return defaultCodexCliArgs;
|
|
55
|
+
throw new Error(`unsupported_cli_subprocess_command:${command}`);
|
|
56
|
+
}
|
|
57
|
+
/** Best-effort real dollar-cost extraction from a CLI's own stdout. Mirrors src/selfhost/ai.ts's
|
|
58
|
+
* `extractCliUsage`/`COST_KEYS` (redeclared here, not imported, per this file's own no-src-import
|
|
59
|
+
* convention) but narrowed to just the cost field this driver's `CodingAgentDriverResult` surfaces -- tokens
|
|
60
|
+
* and model aren't part of that shape. Tries the whole trimmed stdout as one JSON object first (claude's
|
|
61
|
+
* `--output-format json` shape, empirically confirmed to carry `total_cost_usd`, the exact same field name
|
|
62
|
+
* the Agent-SDK's own result message carries), then scans line by line (codex's `--json` JSONL stream,
|
|
63
|
+
* "still evolving" per src/selfhost/ai.ts's own comment, so multiple real key spellings are tolerated). A
|
|
64
|
+
* missing/malformed field means "no cost signal", never an error -- never fabricated. */
|
|
65
|
+
const COST_KEYS = ["total_cost_usd", "totalCostUsd", "cost_usd", "costUsd"];
|
|
66
|
+
function finiteNonNegativeNumber(value) {
|
|
67
|
+
const n = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN;
|
|
68
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
69
|
+
}
|
|
70
|
+
function costUsdFromRecord(record) {
|
|
71
|
+
let best;
|
|
72
|
+
for (const key of COST_KEYS) {
|
|
73
|
+
const n = finiteNonNegativeNumber(record[key]);
|
|
74
|
+
if (n !== undefined)
|
|
75
|
+
best = Math.max(best ?? 0, n);
|
|
76
|
+
}
|
|
77
|
+
return best;
|
|
78
|
+
}
|
|
79
|
+
function extractCostUsd(stdout) {
|
|
80
|
+
const trimmed = stdout.trim();
|
|
81
|
+
if (!trimmed)
|
|
82
|
+
return undefined;
|
|
83
|
+
let best;
|
|
84
|
+
const tryLine = (text) => {
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(text);
|
|
87
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
88
|
+
const n = costUsdFromRecord(parsed);
|
|
89
|
+
if (n !== undefined)
|
|
90
|
+
best = Math.max(best ?? 0, n);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
/* not JSON -- best-effort only */
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
tryLine(trimmed);
|
|
98
|
+
for (const line of trimmed.split(/\r?\n/)) {
|
|
99
|
+
if (line.trim())
|
|
100
|
+
tryLine(line);
|
|
101
|
+
}
|
|
102
|
+
return best;
|
|
103
|
+
}
|
|
104
|
+
/** Claude Code's `--output-format json` sometimes exits non-zero while still emitting a structured
|
|
105
|
+
* `{is_error, api_error_status}` envelope on stdout (e.g. an auth/model error). Ported from
|
|
106
|
+
* src/selfhost/ai.ts's `claudeErrorStatus` -- redeclared here rather than imported, per this file's own
|
|
107
|
+
* no-src-import convention (see header comment). Returns null on absent/malformed stdout, in which case the
|
|
108
|
+
* caller falls back to the generic exit-code error unchanged (#5168). */
|
|
109
|
+
function claudeErrorStatus(stdout) {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(stdout.trim());
|
|
112
|
+
if (parsed.is_error === true)
|
|
113
|
+
return String(parsed.api_error_status ?? parsed.subtype ?? "unknown");
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
/* not a single JSON object -- handled by the caller's generic exit-code fallback */
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
/** Codex's stderr is typically just an uninformative "Reading prompt from stdin..." startup banner; the real
|
|
121
|
+
* error (auth failure, unknown model, API error) lands in its JSONL stdout instead. Scans lines in reverse
|
|
122
|
+
* (the error object is usually last) and returns the first human-readable detail found, or null. Ported from
|
|
123
|
+
* src/selfhost/ai.ts's `codexErrorFromStdout` -- redeclared here (not imported) per this file's own
|
|
124
|
+
* no-src-import convention, and returns the RAW detail unredacted; the caller applies this driver's own
|
|
125
|
+
* knownSecrets-aware `redactSecrets` at the call site (#5169). */
|
|
126
|
+
function codexErrorFromStdout(stdout) {
|
|
127
|
+
const lines = stdout.trim().split(/\r?\n/);
|
|
128
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
129
|
+
const line = lines[i];
|
|
130
|
+
if (!line?.trim())
|
|
131
|
+
continue;
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(line);
|
|
134
|
+
const errorObj = parsed.error;
|
|
135
|
+
const detail = (typeof parsed.error === "string" && parsed.error) ||
|
|
136
|
+
(typeof parsed.message === "string" && parsed.message) ||
|
|
137
|
+
(typeof parsed.msg === "string" && parsed.msg) ||
|
|
138
|
+
(errorObj && typeof errorObj.message === "string" ? errorObj.message : null) ||
|
|
139
|
+
null;
|
|
140
|
+
if (detail)
|
|
141
|
+
return detail;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* not JSON -- skip */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Create a {@link CodingAgentDriver} that runs the coding agent as a CLI subprocess. A non-zero or absent exit
|
|
151
|
+
* code, or a timeout, yields `ok: false` with a redacted error; exit `0` yields `ok: true`. Any subprocess output
|
|
152
|
+
* kept as a transcript or folded into an error is redacted first.
|
|
153
|
+
*/
|
|
154
|
+
export function createCliSubprocessCodingAgentDriver(options) {
|
|
155
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
156
|
+
const buildArgs = options.buildArgs ?? resolveDefaultBuildArgs(options.command);
|
|
157
|
+
const knownSecrets = options.knownSecrets ?? [];
|
|
158
|
+
return {
|
|
159
|
+
async run(task) {
|
|
160
|
+
const env = buildAllowlistedEnv(options.parentEnv ?? {}, CODING_AGENT_ENV_ALLOWLIST, options.env ?? {});
|
|
161
|
+
const spawned = await options.spawn(options.command, buildArgs(task), {
|
|
162
|
+
cwd: task.workingDirectory,
|
|
163
|
+
env,
|
|
164
|
+
timeoutMs,
|
|
165
|
+
...(options.firstOutputTimeoutMs !== undefined
|
|
166
|
+
? { firstOutputTimeoutMs: options.firstOutputTimeoutMs }
|
|
167
|
+
: {}),
|
|
168
|
+
});
|
|
169
|
+
const transcript = redactSecrets(spawned.stdout, knownSecrets).slice(0, MAX_TRANSCRIPT_CHARS);
|
|
170
|
+
if (spawned.timedOut && spawned.stalledNoOutput) {
|
|
171
|
+
// Fast-fail path (#4994/#5053): killed at firstOutputTimeoutMs, well before the full timeoutMs, because
|
|
172
|
+
// stdout produced no bytes at all. A distinct error (never reusing `${command}_timeout_...`) so this
|
|
173
|
+
// stall is separately countable in logs/Sentry from a genuine full timeout where the process was at
|
|
174
|
+
// least emitting output before it was killed.
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
changedFiles: [],
|
|
178
|
+
summary: `${options.command} stalled with no stdout within ${options.firstOutputTimeoutMs}ms`,
|
|
179
|
+
transcript,
|
|
180
|
+
error: `${options.command}_stalled_no_output`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (spawned.timedOut) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
changedFiles: [],
|
|
187
|
+
summary: `${options.command} timed out after ${timeoutMs}ms`,
|
|
188
|
+
transcript,
|
|
189
|
+
error: `${options.command}_timeout_${timeoutMs}ms`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (spawned.code !== 0) {
|
|
193
|
+
if (options.command === "claude") {
|
|
194
|
+
const errStatus = claudeErrorStatus(spawned.stdout);
|
|
195
|
+
if (errStatus) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
changedFiles: [],
|
|
199
|
+
summary: `${options.command} exited non-zero`,
|
|
200
|
+
transcript,
|
|
201
|
+
error: redactSecrets(`claude_code_error_${errStatus}`, knownSecrets),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (options.command === "codex") {
|
|
206
|
+
const stderrTrimmed = (spawned.stderr ?? "").trim();
|
|
207
|
+
const jsonlDetail = codexErrorFromStdout(spawned.stdout);
|
|
208
|
+
if (!jsonlDetail && stderrTrimmed === "Reading prompt from stdin...") {
|
|
209
|
+
// codex's JSONL stream carried no structured detail and stderr is ONLY the stdin-reading banner (no
|
|
210
|
+
// API/auth error appended) -- auth.json was present at boot-time but is now expired or was deleted.
|
|
211
|
+
// A distinct, actionable remediation instead of the generic exit-code string (#5169).
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
changedFiles: [],
|
|
215
|
+
summary: `${options.command} exited non-zero`,
|
|
216
|
+
transcript,
|
|
217
|
+
error: redactSecrets("codex_no_auth: auth.json missing or expired -- run `codex auth` to authenticate", knownSecrets),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (jsonlDetail) {
|
|
221
|
+
// Prefer the structured error from codex's JSONL stdout over the uninformative stderr startup
|
|
222
|
+
// message -- codex reports auth/model/API failures in its JSON stream, not stderr.
|
|
223
|
+
const detail = redactSecrets(jsonlDetail, knownSecrets).slice(0, MAX_ERROR_DETAIL_CHARS);
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
changedFiles: [],
|
|
227
|
+
summary: `${options.command} exited non-zero`,
|
|
228
|
+
transcript,
|
|
229
|
+
error: `${options.command}_exit_${spawned.code}: ${detail}`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const stderr = (spawned.stderr ?? "").trim();
|
|
234
|
+
const detail = redactSecrets(stderr || `exit ${spawned.code}`, knownSecrets).slice(0, MAX_ERROR_DETAIL_CHARS);
|
|
235
|
+
return {
|
|
236
|
+
ok: false,
|
|
237
|
+
changedFiles: [],
|
|
238
|
+
summary: `${options.command} exited non-zero`,
|
|
239
|
+
transcript,
|
|
240
|
+
error: `${options.command}_exit_${spawned.code}: ${detail}`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// Claude Code's own documented behavior: `--output-format json` "sometimes exits non-zero", implying
|
|
244
|
+
// it can ALSO exit 0 while still reporting `is_error: true` in its structured JSON envelope (confirmed
|
|
245
|
+
// empirically: a live `claude -p --output-format json` invocation returns a real `is_error`/`subtype`
|
|
246
|
+
// field on every result, success or not). claudeErrorStatus was already written to detect exactly this
|
|
247
|
+
// shape but, before this fix, was only ever checked on the `code !== 0` branch above -- a code-0 error
|
|
248
|
+
// envelope silently read as `ok: true`. Checked here, not folded into that branch, since it's a
|
|
249
|
+
// genuinely different condition (exit code 0).
|
|
250
|
+
if (options.command === "claude") {
|
|
251
|
+
const errStatus = claudeErrorStatus(spawned.stdout);
|
|
252
|
+
if (errStatus) {
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
changedFiles: [],
|
|
256
|
+
summary: `${options.command} exited 0 but reported an error envelope`,
|
|
257
|
+
transcript,
|
|
258
|
+
error: redactSecrets(`claude_code_error_${errStatus}`, knownSecrets),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const costUsd = extractCostUsd(spawned.stdout);
|
|
263
|
+
return {
|
|
264
|
+
ok: true,
|
|
265
|
+
changedFiles: [],
|
|
266
|
+
summary: `${options.command} completed for ${task.attemptId}`,
|
|
267
|
+
transcript,
|
|
268
|
+
...(costUsd !== undefined ? { costUsd } : {}),
|
|
269
|
+
};
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Scoped local task handed to every driver implementation — no GitHub writes, no autonomous continue/stop. */
|
|
2
|
+
export type CodingAgentDriverTask = {
|
|
3
|
+
attemptId: string;
|
|
4
|
+
workingDirectory: string;
|
|
5
|
+
acceptanceCriteriaPath: string;
|
|
6
|
+
instructions: string;
|
|
7
|
+
maxTurns: number;
|
|
8
|
+
};
|
|
9
|
+
/** Provider-agnostic result — nothing here assumes a subprocess CLI vs. an Agent SDK `query()` loop. */
|
|
10
|
+
export type CodingAgentDriverResult = {
|
|
11
|
+
ok: boolean;
|
|
12
|
+
changedFiles: readonly string[];
|
|
13
|
+
summary: string;
|
|
14
|
+
/** Opaque provider transcript for operator inspection; absent when the driver did not run. */
|
|
15
|
+
transcript?: string | undefined;
|
|
16
|
+
turnsUsed?: number | undefined;
|
|
17
|
+
/** Real dollar cost of this driver run, when the provider reports one. Absent (not zero) when the provider
|
|
18
|
+
* never got far enough to have a cost, or reports no cost signal at all -- never fabricated. */
|
|
19
|
+
costUsd?: number | undefined;
|
|
20
|
+
error?: string | undefined;
|
|
21
|
+
};
|
|
22
|
+
export interface CodingAgentDriver {
|
|
23
|
+
run(task: CodingAgentDriverTask): Promise<CodingAgentDriverResult>;
|
|
24
|
+
}
|
|
25
|
+
/** Minimal in-memory fake for contract/parity tests — records the last task without IO. */
|
|
26
|
+
export declare function createFakeCodingAgentDriver(impl?: Partial<{
|
|
27
|
+
run: CodingAgentDriver["run"];
|
|
28
|
+
lastTask: CodingAgentDriverTask | null;
|
|
29
|
+
}>): CodingAgentDriver & {
|
|
30
|
+
lastTask: CodingAgentDriverTask | null;
|
|
31
|
+
};
|
|
32
|
+
/** Default-OFF stub driver for factory resolution tests — never touches the filesystem. */
|
|
33
|
+
export declare function createNoopCodingAgentDriver(): CodingAgentDriver;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// `CodingAgentDriver` interface seam (#4262). Mirrors `SelfHostAi` (`src/selfhost/ai.ts:60-63`): a single `run()`
|
|
2
|
+
// method, provider-agnostic task/result types, and injectable deps on concrete implementations (spawn fn, clock,
|
|
3
|
+
// filesystem) rather than hardcoded globals. Implementations MAY perform real IO; this file defines only the
|
|
4
|
+
// contract — orchestration (mode gating, attempt logging, factory selection) lives in sibling miner modules.
|
|
5
|
+
/** Minimal in-memory fake for contract/parity tests — records the last task without IO. */
|
|
6
|
+
export function createFakeCodingAgentDriver(impl = {}) {
|
|
7
|
+
const state = { lastTask: impl.lastTask ?? null };
|
|
8
|
+
return {
|
|
9
|
+
get lastTask() {
|
|
10
|
+
return state.lastTask;
|
|
11
|
+
},
|
|
12
|
+
run: impl.run ??
|
|
13
|
+
(async (task) => {
|
|
14
|
+
state.lastTask = task;
|
|
15
|
+
return {
|
|
16
|
+
ok: true,
|
|
17
|
+
changedFiles: [],
|
|
18
|
+
summary: `fake driver ran ${task.attemptId}`,
|
|
19
|
+
turnsUsed: 0,
|
|
20
|
+
};
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** Default-OFF stub driver for factory resolution tests — never touches the filesystem. */
|
|
25
|
+
export function createNoopCodingAgentDriver() {
|
|
26
|
+
return {
|
|
27
|
+
async run(task) {
|
|
28
|
+
return {
|
|
29
|
+
ok: true,
|
|
30
|
+
changedFiles: [],
|
|
31
|
+
summary: `noop driver acknowledged ${task.attemptId}`,
|
|
32
|
+
turnsUsed: 0,
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CodingAgentDriver, CodingAgentDriverResult, CodingAgentDriverTask } from "./coding-agent-driver.js";
|
|
2
|
+
import { type CodingAgentExecutionMode } from "./coding-agent-mode.js";
|
|
3
|
+
import type { AttemptLogEvent } from "./attempt-log.js";
|
|
4
|
+
export type AttemptLogSink = {
|
|
5
|
+
append(event: AttemptLogEvent): void;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Invoke a driver under the resolved execution mode. `paused` and `dry_run` never call `driver.run()` — see
|
|
9
|
+
* `coding-agent-mode.ts` for the dry-run tradeoff documentation.
|
|
10
|
+
*/
|
|
11
|
+
export declare function invokeCodingAgentDriver(driver: CodingAgentDriver, mode: CodingAgentExecutionMode, task: CodingAgentDriverTask, log?: AttemptLogSink | undefined): Promise<CodingAgentDriverResult>;
|