@bridge_gpt/mcp-server 0.2.67 → 0.2.68
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 +178 -15
- package/build/agent-launchers/claude-executor-adapter.js +27 -0
- package/build/agents.generated.js +22 -2
- package/build/bitbucket-workspace-remote.js +136 -0
- package/build/bootstrap-project-identity.js +24 -0
- package/build/bridge-permissions.js +829 -0
- package/build/claude-settings.js +18 -0
- package/build/cli-help.js +21 -0
- package/build/command-assets-doctor.js +24 -70
- package/build/command-provisioning.js +29 -5
- package/build/commands.generated.js +8 -8
- package/build/conductor/bridge-api-client.js +714 -0
- package/build/conductor/command-drift-production.js +92 -0
- package/build/conductor/command-drift.js +89 -0
- package/build/conductor/doctor.js +69 -26
- package/build/conductor/epic-branch-ci-inspection.js +1375 -0
- package/build/conductor/epic-implementer-cli.js +76 -22
- package/build/conductor/gate-discovery.js +890 -0
- package/build/conductor/install-doctor.js +15 -3
- package/build/conductor/json-output.js +63 -0
- package/build/conductor/readiness-cli.js +95 -10
- package/build/conductor/readiness-sections.js +369 -3
- package/build/conductor/readiness.js +211 -8
- package/build/conductor/recovery-cli.js +110 -4
- package/build/conductor/recovery-operations.js +99 -1
- package/build/connect-bitbucket-api.js +41 -6
- package/build/connect-bitbucket.js +106 -33
- package/build/credential-store.js +207 -47
- package/build/docs.generated.js +57 -3
- package/build/doctor.js +92 -1
- package/build/drive-epic.js +45 -8
- package/build/epic-coherence-input.test-fixtures.js +4 -3
- package/build/epic-implementer-bin.js +1374 -25
- package/build/epic-workarounds-path.js +39 -0
- package/build/executor/cli.js +30 -0
- package/build/executor/completion-contract.js +18 -5
- package/build/executor/deps.js +4 -1
- package/build/executor/gate-qualification-job.js +499 -0
- package/build/executor/guarded-fast-forward-push.js +129 -0
- package/build/executor/integration-job.js +122 -8
- package/build/executor/job-errors.js +8 -1
- package/build/executor/job-runner.js +589 -47
- package/build/executor/likely-tests.js +290 -0
- package/build/executor/live-worker-registry.js +1 -1
- package/build/executor/observation.js +31 -2
- package/build/executor/packaged-recipe.js +106 -0
- package/build/executor/permissions.js +115 -28
- package/build/executor/platform.js +1 -1
- package/build/executor/preflight.js +61 -12
- package/build/executor/process-containment.js +36 -12
- package/build/executor/process.js +15 -7
- package/build/executor/prompt-spec.js +33 -1
- package/build/executor/recently-failed-tests-file.js +71 -0
- package/build/executor/reconcile-overlap-job.js +41 -23
- package/build/executor/required-verification.js +1 -1
- package/build/executor/required-work-ledger.js +105 -17
- package/build/executor/retirement.js +69 -0
- package/build/executor/runner.js +114 -11
- package/build/executor/test-clock.js +15 -0
- package/build/executor/types.js +32 -0
- package/build/executor/viewer-tabs.js +58 -8
- package/build/executor/worker-command.js +23 -7
- package/build/executor/worker-diagnostics.js +37 -0
- package/build/executor/worker-finalization.js +31 -5
- package/build/executor/worker-guard-hook.js +179 -25
- package/build/executor/worker-log.js +4 -3
- package/build/executor/worker-verification-guidance.js +37 -0
- package/build/executor/worktree.js +88 -11
- package/build/first-build-advisory.js +97 -0
- package/build/generated-assets-advisory.js +14 -0
- package/build/generated-assets-catalog.js +66 -0
- package/build/generated-assets-context.js +46 -0
- package/build/generated-assets-dependencies.js +26 -0
- package/build/generated-assets-doctor.js +50 -0
- package/build/generated-assets-health.js +65 -0
- package/build/generated-assets-host.js +17 -0
- package/build/generated-assets-inventory.js +17 -0
- package/build/generated-assets-paths.js +60 -0
- package/build/generated-assets-publisher.js +133 -0
- package/build/generated-assets-remedy.js +25 -0
- package/build/generated-assets-repair.js +72 -0
- package/build/generated-assets-session.js +71 -0
- package/build/generated-assets-test-support.js +35 -0
- package/build/index.js +964 -768
- package/build/init.js +54 -177
- package/build/install-bridge-conductor-gate.js +1372 -0
- package/build/install-bridge-conductor.js +289 -36
- package/build/install-bridge.js +544 -37
- package/build/mcp-install-state.js +50 -4
- package/build/merge-workflow-install.js +3 -4
- package/build/pipeline-utils.js +6 -2
- package/build/pipelines.generated.js +22 -142
- package/build/plan-execution-ledger.js +2 -32
- package/build/plane/manifest.js +16 -1
- package/build/plane/preflight.js +54 -1
- package/build/printed-remedy-commands.js +16 -1
- package/build/readme.generated.js +1 -1
- package/build/repair-assets.js +93 -0
- package/build/run-unit-tests-launcher.js +130 -49
- package/build/secret-redaction.js +33 -0
- package/build/setup-epic.js +108 -3
- package/build/start-tickets-prereqs.js +70 -16
- package/build/start-tickets.js +29 -5
- package/build/tool-surface-gating.js +36 -0
- package/build/upgrade-cli.js +55 -1
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +3 -2
- package/build/worker-guard-hook-main.js +1 -1
- package/build/worktree-core.js +15 -2
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +30 -12
- package/docs/EPIC-IMPLEMENTER.md +68 -2
- package/package.json +3 -3
- package/pipelines/greenfield-setup.json +4 -128
- package/pipelines/implement-ticket.json +3 -2
|
@@ -875,7 +875,33 @@ export function createRequiredWorkLedger(init) {
|
|
|
875
875
|
if (!liveOptional.includes(tid))
|
|
876
876
|
liveOptional.push(tid);
|
|
877
877
|
const readyToClose = resultsSeen > 0 && latestIsResult && !turnOpen && liveRequired.length === 0 && snap.required.length === 0;
|
|
878
|
-
|
|
878
|
+
const bashBgIds = [];
|
|
879
|
+
let onlyBashBg = true;
|
|
880
|
+
for (const o of obligations.values()) {
|
|
881
|
+
if (!isLive(o) || !o.required)
|
|
882
|
+
continue;
|
|
883
|
+
if (o.kind === "bash_bg")
|
|
884
|
+
bashBgIds.push(o.taskId ?? o.toolUseId);
|
|
885
|
+
else
|
|
886
|
+
onlyBashBg = false;
|
|
887
|
+
}
|
|
888
|
+
for (const tid of snap.required) {
|
|
889
|
+
const o = findByTaskId(tid);
|
|
890
|
+
if (!o || o.kind !== "bash_bg")
|
|
891
|
+
onlyBashBg = false;
|
|
892
|
+
else if (!bashBgIds.includes(o.taskId ?? o.toolUseId))
|
|
893
|
+
bashBgIds.push(o.taskId ?? o.toolUseId);
|
|
894
|
+
}
|
|
895
|
+
return {
|
|
896
|
+
readyToClose,
|
|
897
|
+
liveRequired,
|
|
898
|
+
liveOptional,
|
|
899
|
+
activitySeq,
|
|
900
|
+
latestIsResult,
|
|
901
|
+
turnOpen,
|
|
902
|
+
finalResultNoOpenTurn: resultsSeen > 0 && latestIsResult && !turnOpen,
|
|
903
|
+
liveRequiredBashBg: onlyBashBg ? bashBgIds : [],
|
|
904
|
+
};
|
|
879
905
|
};
|
|
880
906
|
// One predicate for "a required task is still pending", shared by the envelope
|
|
881
907
|
// and the observe-only query so the two cannot drift apart.
|
|
@@ -1003,6 +1029,13 @@ export function createRequiredWorkLedger(init) {
|
|
|
1003
1029
|
}
|
|
1004
1030
|
/** The measured settle window after the closure rule first holds (BAPI-1292 C6an-hold2). */
|
|
1005
1031
|
export const CLOSURE_DEBOUNCE_MS = 1500;
|
|
1032
|
+
/**
|
|
1033
|
+
* The absolute post-result window (BAPI-1395). Once a final result is in, no
|
|
1034
|
+
* turn is open, and the only live required work is the worker's own background
|
|
1035
|
+
* Bash, stdin is closed at most this long after the window opens — whatever the
|
|
1036
|
+
* worker does meanwhile. Normal early closure is unchanged.
|
|
1037
|
+
*/
|
|
1038
|
+
export const COMPLETION_WINDOW_MS = 30 * 60 * 1000;
|
|
1006
1039
|
/**
|
|
1007
1040
|
* Hold stdin open until the closure rule holds for a full debounce window, then
|
|
1008
1041
|
* close it exactly once.
|
|
@@ -1017,6 +1050,13 @@ export const CLOSURE_DEBOUNCE_MS = 1500;
|
|
|
1017
1050
|
* optional watchers are live. It is never sent while required work is live and
|
|
1018
1051
|
* never asks for a broad stop — cancelling required work is not the executor's
|
|
1019
1052
|
* call.
|
|
1053
|
+
*
|
|
1054
|
+
* The completion window (BAPI-1395): when the final result is in and only
|
|
1055
|
+
* required `bash_bg` work is live, one fixed end `min(opened + window, deadline)`
|
|
1056
|
+
* is set. A new worker turn cancels it; background activity does not. The one
|
|
1057
|
+
* nudge (shared with the optional-watcher nudge, so a job gets at most one)
|
|
1058
|
+
* asks the worker to stop only its own tasks that are merely waiting. Closing
|
|
1059
|
+
* at the end resolves nothing: the live tasks stay unresolved in the ledger.
|
|
1020
1060
|
*/
|
|
1021
1061
|
export function createClosureSupervisor(opts) {
|
|
1022
1062
|
const { ledger, deps } = opts;
|
|
@@ -1029,6 +1069,14 @@ export function createClosureSupervisor(opts) {
|
|
|
1029
1069
|
let debounceTimer = null;
|
|
1030
1070
|
let armedSeq = -1;
|
|
1031
1071
|
let pending = Promise.resolve();
|
|
1072
|
+
const windowMs = opts.windowMs ?? COMPLETION_WINDOW_MS;
|
|
1073
|
+
let windowOpened = false;
|
|
1074
|
+
let windowTimer = null;
|
|
1075
|
+
const clearWindow = () => {
|
|
1076
|
+
if (windowTimer !== null)
|
|
1077
|
+
deps.clearTimer(windowTimer);
|
|
1078
|
+
windowTimer = null;
|
|
1079
|
+
};
|
|
1032
1080
|
const clearDebounce = () => {
|
|
1033
1081
|
if (debounceTimer !== null)
|
|
1034
1082
|
deps.clearTimer(debounceTimer);
|
|
@@ -1041,6 +1089,7 @@ export function createClosureSupervisor(opts) {
|
|
|
1041
1089
|
closed = true;
|
|
1042
1090
|
closedReason = reason;
|
|
1043
1091
|
clearDebounce();
|
|
1092
|
+
clearWindow();
|
|
1044
1093
|
if (deadlineTimer !== null)
|
|
1045
1094
|
deps.clearTimer(deadlineTimer);
|
|
1046
1095
|
deadlineTimer = null;
|
|
@@ -1054,6 +1103,48 @@ export function createClosureSupervisor(opts) {
|
|
|
1054
1103
|
// then reports `stdout_not_read_to_eof`, never success.
|
|
1055
1104
|
}
|
|
1056
1105
|
});
|
|
1106
|
+
if (reason === "window") {
|
|
1107
|
+
try {
|
|
1108
|
+
opts.onWindowExpiry?.();
|
|
1109
|
+
}
|
|
1110
|
+
catch {
|
|
1111
|
+
/* the caller's termination scheduling must never break closure */
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
const sendNudge = (line, onUndelivered) => {
|
|
1116
|
+
nudged = true;
|
|
1117
|
+
nudgeAwaitingReply = true;
|
|
1118
|
+
pending = pending.then(async () => {
|
|
1119
|
+
let ok = false;
|
|
1120
|
+
try {
|
|
1121
|
+
ok = (await opts.writeMessage(line)).ok;
|
|
1122
|
+
}
|
|
1123
|
+
catch {
|
|
1124
|
+
ok = false;
|
|
1125
|
+
}
|
|
1126
|
+
if (!ok && !closed && !disposed)
|
|
1127
|
+
onUndelivered();
|
|
1128
|
+
});
|
|
1129
|
+
};
|
|
1130
|
+
const openWindow = (bashBgIds) => {
|
|
1131
|
+
windowOpened = true;
|
|
1132
|
+
const now = deps.now();
|
|
1133
|
+
const windowEnd = Math.min(now + windowMs, opts.deadlineAtMs);
|
|
1134
|
+
// The deadline timer already closes at `deadlineAtMs`; a second timer there
|
|
1135
|
+
// would only race it, so the window schedules its own close only when it
|
|
1136
|
+
// ends strictly earlier.
|
|
1137
|
+
if (windowEnd < opts.deadlineAtMs) {
|
|
1138
|
+
windowTimer = deps.setTimer(() => {
|
|
1139
|
+
windowTimer = null;
|
|
1140
|
+
close("window");
|
|
1141
|
+
}, Math.max(0, windowEnd - now));
|
|
1142
|
+
}
|
|
1143
|
+
if (!nudged && opts.buildWindowNudge) {
|
|
1144
|
+
sendNudge(opts.buildWindowNudge([...bashBgIds]), () => {
|
|
1145
|
+
nudgeAwaitingReply = false;
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1057
1148
|
};
|
|
1058
1149
|
let deadlineTimer = deps.setTimer(() => {
|
|
1059
1150
|
deadlineTimer = null;
|
|
@@ -1065,30 +1156,26 @@ export function createClosureSupervisor(opts) {
|
|
|
1065
1156
|
const s = ledger.closureState();
|
|
1066
1157
|
if (armedSeq !== s.activitySeq)
|
|
1067
1158
|
clearDebounce();
|
|
1159
|
+
if (s.turnOpen && windowOpened) {
|
|
1160
|
+
clearWindow();
|
|
1161
|
+
windowOpened = false;
|
|
1162
|
+
}
|
|
1068
1163
|
if (!s.latestIsResult) {
|
|
1069
1164
|
// The nudge was answered (or other activity began): the next result decides again.
|
|
1070
1165
|
nudgeAwaitingReply = false;
|
|
1071
1166
|
return;
|
|
1072
1167
|
}
|
|
1168
|
+
if (!windowOpened && s.finalResultNoOpenTurn && s.liveRequiredBashBg.length > 0) {
|
|
1169
|
+
openWindow(s.liveRequiredBashBg);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1073
1172
|
if (!s.readyToClose)
|
|
1074
1173
|
return;
|
|
1075
1174
|
if (!nudged && s.liveOptional.length > 0) {
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
let ok = false;
|
|
1081
|
-
try {
|
|
1082
|
-
ok = (await opts.writeMessage(line)).ok;
|
|
1083
|
-
}
|
|
1084
|
-
catch {
|
|
1085
|
-
ok = false;
|
|
1086
|
-
}
|
|
1087
|
-
// An undelivered nudge will never be answered: fall back to the rule.
|
|
1088
|
-
if (!ok && !closed && !disposed) {
|
|
1089
|
-
nudgeAwaitingReply = false;
|
|
1090
|
-
onActivity();
|
|
1091
|
-
}
|
|
1175
|
+
// An undelivered nudge will never be answered: fall back to the rule.
|
|
1176
|
+
sendNudge(opts.buildNudge([...s.liveOptional]), () => {
|
|
1177
|
+
nudgeAwaitingReply = false;
|
|
1178
|
+
onActivity();
|
|
1092
1179
|
});
|
|
1093
1180
|
return;
|
|
1094
1181
|
}
|
|
@@ -1117,6 +1204,7 @@ export function createClosureSupervisor(opts) {
|
|
|
1117
1204
|
settled: () => pending,
|
|
1118
1205
|
dispose: () => {
|
|
1119
1206
|
clearDebounce();
|
|
1207
|
+
clearWindow();
|
|
1120
1208
|
if (deadlineTimer !== null)
|
|
1121
1209
|
deps.clearTimer(deadlineTimer);
|
|
1122
1210
|
deadlineTimer = null;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Process-local, pure retirement bookkeeping for a single-run executor. */
|
|
2
|
+
export const SUSPEND_GAP_SLACK_MS = 60_000;
|
|
3
|
+
export function initialRetirementState() {
|
|
4
|
+
return { unreachableMs: 0, idleMs: 0, definitiveReason: null, definitiveReads: 0, definitiveMs: 0, failedCallEvidence: false };
|
|
5
|
+
}
|
|
6
|
+
/** Bounds prevent accidental years-long values and unsafe number conversions. */
|
|
7
|
+
export function parseRetirementSetting(raw, defaultValue, maximum) {
|
|
8
|
+
if (raw === undefined)
|
|
9
|
+
return { value: defaultValue, warning: false };
|
|
10
|
+
if (!/^(0|[1-9][0-9]*)$/.test(raw))
|
|
11
|
+
return { value: defaultValue, warning: true };
|
|
12
|
+
const value = Number(raw);
|
|
13
|
+
return Number.isSafeInteger(value) && value <= maximum
|
|
14
|
+
? { value, warning: false }
|
|
15
|
+
: { value: defaultValue, warning: true };
|
|
16
|
+
}
|
|
17
|
+
export function transitionRetirement(prior, cycle, config) {
|
|
18
|
+
const state = { ...prior };
|
|
19
|
+
const elapsed = cycle.sleep.completed && cycle.sleep.elapsedMs >= 0 &&
|
|
20
|
+
cycle.sleep.elapsedMs <= 3 * config.pollIntervalMs + SUSPEND_GAP_SLACK_MS
|
|
21
|
+
? cycle.sleep.elapsedMs : 0;
|
|
22
|
+
const success = cycle.successfulServerCall || cycle.claimedJob || cycle.claimedAction || cycle.scopedRead === "ok";
|
|
23
|
+
const failure = cycle.failedServerCall || ["network", "timeout", "server", "malformed", "unexpected"].includes(cycle.scopedRead);
|
|
24
|
+
if (success) {
|
|
25
|
+
state.unreachableMs = 0;
|
|
26
|
+
state.failedCallEvidence = false;
|
|
27
|
+
}
|
|
28
|
+
else if (failure) {
|
|
29
|
+
state.failedCallEvidence = true;
|
|
30
|
+
}
|
|
31
|
+
if (cycle.claimedJob || cycle.claimedAction || cycle.activeCount > 0)
|
|
32
|
+
state.idleMs = 0;
|
|
33
|
+
else if ((cycle.claimOutcome === "none" || cycle.claimOutcome === "preflight-refused") && elapsed > 0)
|
|
34
|
+
state.idleMs += elapsed;
|
|
35
|
+
if (cycle.activeCount === 0 && !success && state.failedCallEvidence)
|
|
36
|
+
state.unreachableMs += elapsed;
|
|
37
|
+
if (cycle.scopedRead === "not-found" || cycle.scopedRead === "unauthorized") {
|
|
38
|
+
if (state.definitiveReason === cycle.scopedRead) {
|
|
39
|
+
state.definitiveReads += 1;
|
|
40
|
+
state.definitiveMs += elapsed;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
state.definitiveReason = cycle.scopedRead;
|
|
44
|
+
state.definitiveReads = 1;
|
|
45
|
+
state.definitiveMs = 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (cycle.scopedRead === "ok" || cycle.scopedRead === "unavailable" || cycle.scopedRead === "network" || cycle.scopedRead === "timeout" || cycle.scopedRead === "server" || cycle.scopedRead === "malformed" || cycle.scopedRead === "unexpected") {
|
|
49
|
+
state.definitiveReason = null;
|
|
50
|
+
state.definitiveReads = 0;
|
|
51
|
+
state.definitiveMs = 0;
|
|
52
|
+
}
|
|
53
|
+
else if (state.definitiveReason !== null) {
|
|
54
|
+
state.definitiveMs += elapsed;
|
|
55
|
+
}
|
|
56
|
+
let decision = { kind: "continue" };
|
|
57
|
+
if (cycle.activeCount === 0) {
|
|
58
|
+
if (state.definitiveReason !== null && state.definitiveReads >= 3 && state.definitiveMs >= 5 * 60_000) {
|
|
59
|
+
decision = { kind: "retire", reason: state.definitiveReason === "not-found" ? "run_not_found" : "run_unauthorized", awakeMinutes: Math.floor(state.definitiveMs / 60_000) };
|
|
60
|
+
}
|
|
61
|
+
else if (config.unreachableMinutes > 0 && state.unreachableMs >= config.unreachableMinutes * 60_000) {
|
|
62
|
+
decision = { kind: "retire", reason: "server_unreachable", awakeMinutes: Math.floor(state.unreachableMs / 60_000) };
|
|
63
|
+
}
|
|
64
|
+
else if (config.idleHours > 0 && state.idleMs >= config.idleHours * 3_600_000) {
|
|
65
|
+
decision = { kind: "retire", reason: "idle", awakeMinutes: Math.floor(state.idleMs / 60_000) };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { state, decision };
|
|
69
|
+
}
|
package/build/executor/runner.js
CHANGED
|
@@ -18,6 +18,7 @@ import { INTEGRATION_ACTION_PROTOCOL_VERSION, } from "./types.js";
|
|
|
18
18
|
import { buildIntegrationCorrectionJob, createIntegrationCorrectionLeaseAdapter, runIntegrationAction, } from "./integration-job.js";
|
|
19
19
|
import { validateBranchName } from "../base-ref.js";
|
|
20
20
|
import { isTerminalEpicRunStatus } from "../epic-run-status.js";
|
|
21
|
+
import { initialRetirementState, parseRetirementSetting, transitionRetirement } from "./retirement.js";
|
|
21
22
|
import { ensureEpicIntegrationPullRequest, createProductionEpicIntegrationGhRunner, formatEpicIntegrationPullRequestOutcome, EPIC_BRANCH_PREFIX, } from "../epic-integration-pr.js";
|
|
22
23
|
/**
|
|
23
24
|
* Signals that request a graceful executor shutdown (BAPI-828).
|
|
@@ -109,7 +110,10 @@ export function formatProcessHeartbeatRecoveredDiagnostic() {
|
|
|
109
110
|
*/
|
|
110
111
|
export function formatRunTerminalReadFailureDiagnostic(reason) {
|
|
111
112
|
return (`executor: could not read the scoped run's state (reason=${reason}); ` +
|
|
112
|
-
"
|
|
113
|
+
"continuing to poll during the bounded retirement window.");
|
|
114
|
+
}
|
|
115
|
+
export function formatExecutorRetirement(reason, awakeMinutes) {
|
|
116
|
+
return `executor: retiring (reason=${reason}, awake_minutes=${awakeMinutes}); restart with drive-epic <EPIC> or the printed manual command.`;
|
|
113
117
|
}
|
|
114
118
|
/** Seconds a correction worker may run: what the action deadline leaves, less a margin. */
|
|
115
119
|
function correctionTimeoutSeconds(deadlineAt, nowMs) {
|
|
@@ -227,6 +231,20 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
227
231
|
// Suppresses the missing-read-capability advisory to one line per
|
|
228
232
|
// invocation, mirroring the dispatcher-liveness suppression above.
|
|
229
233
|
let missingReadCapabilityWarned = false;
|
|
234
|
+
let cycleSuccessfulCall = false;
|
|
235
|
+
let cycleFailedCall = false;
|
|
236
|
+
let cycleClaimedJob = false;
|
|
237
|
+
let cycleClaimedAction = false;
|
|
238
|
+
let cycleClaimOutcome = "not-attempted";
|
|
239
|
+
let heartbeatDelivered = false;
|
|
240
|
+
let pendingSleep = { completed: false, elapsedMs: 0 };
|
|
241
|
+
const unreachableSetting = parseRetirementSetting(deps.env.BAPI_EXECUTOR_UNREACHABLE_RETIRE_MINUTES, 60, 10_080);
|
|
242
|
+
const idleSetting = parseRetirementSetting(deps.env.BAPI_EXECUTOR_IDLE_RETIRE_HOURS, 24, 8_760);
|
|
243
|
+
if (unreachableSetting.warning)
|
|
244
|
+
deps.errorLog("executor: invalid BAPI_EXECUTOR_UNREACHABLE_RETIRE_MINUTES; using 60");
|
|
245
|
+
if (idleSetting.warning)
|
|
246
|
+
deps.errorLog("executor: invalid BAPI_EXECUTOR_IDLE_RETIRE_HOURS; using 24");
|
|
247
|
+
let retirementState = initialRetirementState();
|
|
230
248
|
/**
|
|
231
249
|
* Announce a dispatcher availability CHANGE, and only a change.
|
|
232
250
|
*
|
|
@@ -323,9 +341,16 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
323
341
|
try {
|
|
324
342
|
const result = await readState.call(httpClient, observedEpicRunId, options.repoName);
|
|
325
343
|
if (!result.ok) {
|
|
326
|
-
|
|
344
|
+
cycleFailedCall = true;
|
|
345
|
+
if (shouldReport(result.reason, lastCompletionReadFailure, lastCompletionReadFailureAt)) {
|
|
346
|
+
deps.errorLog(`executor: epic run completion-state read failed (reason=${result.reason})`);
|
|
347
|
+
lastCompletionReadFailureAt = deps.now();
|
|
348
|
+
}
|
|
349
|
+
lastCompletionReadFailure = result.reason;
|
|
327
350
|
return;
|
|
328
351
|
}
|
|
352
|
+
cycleSuccessfulCall = true;
|
|
353
|
+
lastCompletionReadFailure = null;
|
|
329
354
|
if (result.state.status !== "done")
|
|
330
355
|
return;
|
|
331
356
|
const featureBranch = result.state.featureBranch;
|
|
@@ -369,6 +394,12 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
369
394
|
// The last failure reason reported, so a long outage logs once per change
|
|
370
395
|
// rather than once per poll.
|
|
371
396
|
let lastRunStateFailure = null;
|
|
397
|
+
let lastRunStateFailureAt = -Infinity;
|
|
398
|
+
let lastPreflightRefusal = null;
|
|
399
|
+
let lastPreflightRefusalAt = -Infinity;
|
|
400
|
+
let lastCompletionReadFailure = null;
|
|
401
|
+
let lastCompletionReadFailureAt = -Infinity;
|
|
402
|
+
const shouldReport = (value, previous, at) => value !== previous || deps.now() - at >= 15 * 60_000;
|
|
372
403
|
/**
|
|
373
404
|
* Is the scoped run finished? Called only between claim cycles while this
|
|
374
405
|
* process holds NO job. The wind-down observer runs FIRST, so a run that just
|
|
@@ -378,17 +409,19 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
378
409
|
*/
|
|
379
410
|
const scopedRunIsTerminal = async () => {
|
|
380
411
|
if (!exitWhenRunTerminal || observedEpicRunId === null)
|
|
381
|
-
return false;
|
|
412
|
+
return { terminal: false, read: "none" };
|
|
382
413
|
await observeEpicRunCompletion();
|
|
383
414
|
const readState = httpClient.readEpicRunState;
|
|
384
415
|
if (typeof readState !== "function")
|
|
385
|
-
return false;
|
|
416
|
+
return { terminal: false, read: "unavailable" };
|
|
386
417
|
let reason = null;
|
|
387
418
|
let terminal = false;
|
|
388
419
|
try {
|
|
389
420
|
const result = await readState.call(httpClient, observedEpicRunId, options.repoName);
|
|
390
|
-
if (result.ok)
|
|
421
|
+
if (result.ok) {
|
|
422
|
+
cycleSuccessfulCall = true;
|
|
391
423
|
terminal = isTerminalEpicRunStatus(result.state.status);
|
|
424
|
+
}
|
|
392
425
|
else
|
|
393
426
|
reason = result.reason;
|
|
394
427
|
}
|
|
@@ -396,14 +429,17 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
396
429
|
reason = "unexpected";
|
|
397
430
|
}
|
|
398
431
|
if (reason !== null) {
|
|
399
|
-
if (reason
|
|
432
|
+
if (shouldReport(reason, lastRunStateFailure, lastRunStateFailureAt)) {
|
|
400
433
|
deps.errorLog(formatRunTerminalReadFailureDiagnostic(reason));
|
|
434
|
+
lastRunStateFailureAt = deps.now();
|
|
401
435
|
}
|
|
402
436
|
lastRunStateFailure = reason;
|
|
403
|
-
|
|
437
|
+
cycleFailedCall = true;
|
|
438
|
+
const read = reason === "not-found" || reason === "unauthorized" || reason === "network" || reason === "timeout" || reason === "server" || reason === "malformed" ? reason : "unexpected";
|
|
439
|
+
return { terminal: false, read };
|
|
404
440
|
}
|
|
405
441
|
lastRunStateFailure = null;
|
|
406
|
-
return terminal;
|
|
442
|
+
return { terminal, read: "ok" };
|
|
407
443
|
};
|
|
408
444
|
// BAPI-722: ONE deny-probe cache per `runExecutor` invocation, created OUTSIDE
|
|
409
445
|
// the claim loop below — that scope is the whole feature. A cache created inside
|
|
@@ -623,6 +659,8 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
623
659
|
deps.errorLog(formatProcessHeartbeatRecoveredDiagnostic());
|
|
624
660
|
}
|
|
625
661
|
lastFailed = outcome === "failed";
|
|
662
|
+
if (outcome === "delivered")
|
|
663
|
+
heartbeatDelivered = true;
|
|
626
664
|
if (heartbeatStopRequested || shutdownRequested)
|
|
627
665
|
return;
|
|
628
666
|
// Woken by the same waiter list a real shutdown fires, so process exit does
|
|
@@ -674,6 +712,7 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
674
712
|
}
|
|
675
713
|
}
|
|
676
714
|
function dispatch(job, report) {
|
|
715
|
+
cycleClaimedJob = true;
|
|
677
716
|
const promise = (async () => {
|
|
678
717
|
try {
|
|
679
718
|
// `undefined` for the behavior seams (the job runner defaults them), then
|
|
@@ -711,6 +750,7 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
711
750
|
},
|
|
712
751
|
}));
|
|
713
752
|
function dispatchIntegrationAction(action) {
|
|
753
|
+
cycleClaimedAction = true;
|
|
714
754
|
const key = --integrationSeq;
|
|
715
755
|
const promise = (async () => {
|
|
716
756
|
try {
|
|
@@ -747,10 +787,12 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
747
787
|
: { repo_wide: true }),
|
|
748
788
|
});
|
|
749
789
|
if (result.kind === "claimed") {
|
|
790
|
+
cycleSuccessfulCall = true;
|
|
750
791
|
dispatchIntegrationAction(result.action);
|
|
751
792
|
continue;
|
|
752
793
|
}
|
|
753
794
|
if (result.kind === "fatal") {
|
|
795
|
+
cycleFailedCall = true;
|
|
754
796
|
if (result.authHttpStatus === 401 || result.authHttpStatus === 403) {
|
|
755
797
|
control.reportAuthFatal?.({
|
|
756
798
|
repoName,
|
|
@@ -766,6 +808,7 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
766
808
|
return;
|
|
767
809
|
}
|
|
768
810
|
if (result.kind === "retryable") {
|
|
811
|
+
cycleFailedCall = true;
|
|
769
812
|
deps.errorLog(`executor integration action claim retryable: ${result.error}`);
|
|
770
813
|
}
|
|
771
814
|
break;
|
|
@@ -789,11 +832,32 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
789
832
|
}
|
|
790
833
|
}
|
|
791
834
|
}
|
|
835
|
+
const rawParentPid = deps.env.BAPI_EXECUTOR_PARENT_WATCH_PID;
|
|
836
|
+
if (rawParentPid !== undefined) {
|
|
837
|
+
const pid = Number(rawParentPid);
|
|
838
|
+
if (!/^[1-9][0-9]*$/.test(rawParentPid) || !Number.isSafeInteger(pid)) {
|
|
839
|
+
deps.errorLog("executor: invalid BAPI_EXECUTOR_PARENT_WATCH_PID; ignoring parent watch");
|
|
840
|
+
}
|
|
841
|
+
else if (seams.watchParent) {
|
|
842
|
+
unsubscribers.push(seams.watchParent(pid, () => {
|
|
843
|
+
if (shutdownRequested)
|
|
844
|
+
return;
|
|
845
|
+
deps.errorLog(`executor: watched parent process ${pid} exited; shutting down.`);
|
|
846
|
+
beginShutdown();
|
|
847
|
+
}));
|
|
848
|
+
}
|
|
849
|
+
}
|
|
792
850
|
// Started after the FIRST successful preflight and joined before the runner
|
|
793
851
|
// returns. Declared here so the `finally` below can await it.
|
|
794
852
|
let processHeartbeats = null;
|
|
795
853
|
try {
|
|
796
854
|
for (;;) {
|
|
855
|
+
cycleSuccessfulCall = heartbeatDelivered;
|
|
856
|
+
heartbeatDelivered = false;
|
|
857
|
+
cycleFailedCall = false;
|
|
858
|
+
cycleClaimedJob = false;
|
|
859
|
+
cycleClaimedAction = false;
|
|
860
|
+
cycleClaimOutcome = "not-attempted";
|
|
797
861
|
const report = await collectPreflight(options, deps, preflightSeams);
|
|
798
862
|
latestCorrectionQualification = report.correctionIntentQualified === true &&
|
|
799
863
|
report.correctionQualificationEvidence?.hook_installed === true &&
|
|
@@ -826,7 +890,13 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
826
890
|
deps.errorLog(`executor preflight: ${notice}`);
|
|
827
891
|
}
|
|
828
892
|
if (!report.ok) {
|
|
829
|
-
|
|
893
|
+
cycleClaimOutcome = "preflight-refused";
|
|
894
|
+
const finding = report.fatalFindings.join("; ");
|
|
895
|
+
if (shouldReport(finding, lastPreflightRefusal, lastPreflightRefusalAt)) {
|
|
896
|
+
deps.errorLog(`executor preflight refused claiming: ${finding}`);
|
|
897
|
+
lastPreflightRefusalAt = deps.now();
|
|
898
|
+
}
|
|
899
|
+
lastPreflightRefusal = finding;
|
|
830
900
|
}
|
|
831
901
|
else if (shutdownRequested) {
|
|
832
902
|
// Shutdown arrived during preflight. Claiming now would take on work this
|
|
@@ -835,6 +905,7 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
835
905
|
deps.errorLog("executor: shutdown requested; not claiming new work");
|
|
836
906
|
}
|
|
837
907
|
else {
|
|
908
|
+
lastPreflightRefusal = null;
|
|
838
909
|
let claiming = true;
|
|
839
910
|
while (claiming && !shutdownRequested && options.maxConcurrent - active.size > 0) {
|
|
840
911
|
const freeSlots = options.maxConcurrent - active.size;
|
|
@@ -844,10 +915,12 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
844
915
|
// ahead of the branch below. An error result carries no verdict and is
|
|
845
916
|
// not evidence about the dispatcher, so it is deliberately not observed.
|
|
846
917
|
if (result.kind === "claimed" || result.kind === "none") {
|
|
918
|
+
cycleSuccessfulCall = true;
|
|
847
919
|
observeDispatcherLiveness(result.reconcilerLiveness);
|
|
848
920
|
await observeEpicRunCompletion();
|
|
849
921
|
}
|
|
850
922
|
if (result.kind === "claimed") {
|
|
923
|
+
cycleClaimOutcome = "claimed";
|
|
851
924
|
const currentEpicRunId = result.job.epic_run_id ?? null;
|
|
852
925
|
if (lastClaimedEpicRunId !== null &&
|
|
853
926
|
currentEpicRunId !== null &&
|
|
@@ -876,9 +949,13 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
876
949
|
dispatch(result.job, report);
|
|
877
950
|
}
|
|
878
951
|
else if (result.kind === "none") {
|
|
952
|
+
if (cycleClaimOutcome !== "claimed")
|
|
953
|
+
cycleClaimOutcome = "none";
|
|
879
954
|
claiming = false;
|
|
880
955
|
}
|
|
881
956
|
else {
|
|
957
|
+
cycleClaimOutcome = "error";
|
|
958
|
+
cycleFailedCall = true;
|
|
882
959
|
deps.errorLog(`executor claim ${result.kind}: ${result.error}`);
|
|
883
960
|
claiming = false;
|
|
884
961
|
// BAPI-1021 (AC-5): a categorized 401/403 fatal claim halts the
|
|
@@ -919,11 +996,35 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
919
996
|
// keeps refusing still holds a sleep assertion and a record, and must not
|
|
920
997
|
// outlive its run. `break` takes the SAME path a `--once` pass does:
|
|
921
998
|
// drain, stop the heartbeat loop, unsubscribe, exit 0.
|
|
922
|
-
|
|
999
|
+
const scoped = !shutdownRequested && active.size === 0
|
|
1000
|
+
? await scopedRunIsTerminal()
|
|
1001
|
+
: { terminal: false, read: "none" };
|
|
1002
|
+
if (scoped.terminal) {
|
|
923
1003
|
runTerminal = true;
|
|
924
1004
|
deps.errorLog(`executor: run ${observedEpicRunId} is finished and no job is held; exiting.`);
|
|
925
1005
|
break;
|
|
926
1006
|
}
|
|
1007
|
+
if (exitWhenRunTerminal && !shutdownRequested) {
|
|
1008
|
+
const transition = transitionRetirement(retirementState, {
|
|
1009
|
+
sleep: pendingSleep,
|
|
1010
|
+
scopedRead: scoped.read,
|
|
1011
|
+
successfulServerCall: cycleSuccessfulCall,
|
|
1012
|
+
failedServerCall: cycleFailedCall,
|
|
1013
|
+
claimedJob: cycleClaimedJob,
|
|
1014
|
+
claimedAction: cycleClaimedAction,
|
|
1015
|
+
claimOutcome: cycleClaimOutcome,
|
|
1016
|
+
activeCount: active.size,
|
|
1017
|
+
}, {
|
|
1018
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
1019
|
+
unreachableMinutes: unreachableSetting.value,
|
|
1020
|
+
idleHours: idleSetting.value,
|
|
1021
|
+
});
|
|
1022
|
+
retirementState = transition.state;
|
|
1023
|
+
if (transition.decision.kind === "retire") {
|
|
1024
|
+
deps.errorLog(formatExecutorRetirement(transition.decision.reason, transition.decision.awakeMinutes));
|
|
1025
|
+
break;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
927
1028
|
if (options.once || shutdownRequested)
|
|
928
1029
|
break;
|
|
929
1030
|
// Sample the clock immediately around the ACTUAL sleep, so the measured gap
|
|
@@ -932,7 +1033,9 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
932
1033
|
const wake = await sleepUntilPollOrShutdown(options.pollIntervalMs);
|
|
933
1034
|
if (wake === "shutdown")
|
|
934
1035
|
break;
|
|
935
|
-
|
|
1036
|
+
const elapsedMs = deps.now() - sleepStartedAt;
|
|
1037
|
+
pendingSleep = { completed: true, elapsedMs };
|
|
1038
|
+
await reportSuspendGap(elapsedMs);
|
|
936
1039
|
}
|
|
937
1040
|
// Drain every dispatched job — on the `once` path and the shutdown path alike.
|
|
938
1041
|
// Terminated workers still have to finish supervision, flush final telemetry,
|
|
@@ -250,6 +250,21 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
|
|
|
250
250
|
}
|
|
251
251
|
};
|
|
252
252
|
}
|
|
253
|
+
// Explicit safe answers for the new preparation reservation and routing reads.
|
|
254
|
+
// Existing fixtures still own every legacy Git invocation.
|
|
255
|
+
const fixtureRun = merged.runCommand;
|
|
256
|
+
merged.runCommand = async (file, args, options) => {
|
|
257
|
+
if (file === "git" && args.join(" ") === "rev-parse --path-format=absolute --git-common-dir") {
|
|
258
|
+
return { stdout: overrides.cwd ? `${overrides.cwd}/.git` : "/fake-common.git", stderr: "", exitCode: 0 };
|
|
259
|
+
}
|
|
260
|
+
if (file === "git" && args[0] === "config" && args[1] === "--get-all") {
|
|
261
|
+
return args[2] === "remote.origin.url" ? { stdout: "/fake-origin", stderr: "", exitCode: 0 }
|
|
262
|
+
: { stdout: "", stderr: "", exitCode: 1 };
|
|
263
|
+
}
|
|
264
|
+
if (file === "git" && args[0] === "config" && args[1] === "--local")
|
|
265
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
266
|
+
return fixtureRun(file, args, options);
|
|
267
|
+
};
|
|
253
268
|
return merged;
|
|
254
269
|
}
|
|
255
270
|
/** Sensible default executor options for tests. */
|
package/build/executor/types.js
CHANGED
|
@@ -24,6 +24,11 @@ export const EXECUTOR_JOB_TYPE_VALUES = [
|
|
|
24
24
|
// this union does not carry fails as `ContractError.UnsupportedJobType`,
|
|
25
25
|
// which is the single most likely way this ticket could ship half-landed.
|
|
26
26
|
"ci_rerun",
|
|
27
|
+
// BAPI-1391 — the run-less gate qualification: run a repository owner's
|
|
28
|
+
// confirmed local gate commands once on a clean, detached checkout of the
|
|
29
|
+
// default branch. Deterministic like `ci_rerun` (no worker, no worktree), and
|
|
30
|
+
// implemented in `gate-qualification-job.ts`.
|
|
31
|
+
"gate_qualification",
|
|
27
32
|
];
|
|
28
33
|
/**
|
|
29
34
|
* Server-authoritative completion classification (T2 `ExecutorClassification`,
|
|
@@ -42,6 +47,12 @@ export const EXECUTOR_CLASSIFICATION_VALUES = [
|
|
|
42
47
|
"no_progress",
|
|
43
48
|
"lease_expired",
|
|
44
49
|
];
|
|
50
|
+
/**
|
|
51
|
+
* BAPI-1391 — the capability a REPOSITORY-WIDE executor declares when it can run
|
|
52
|
+
* a run-less `gate_qualification` job. Mirrors the server's stamped
|
|
53
|
+
* `required_capabilities`, so only a claim declaring it can take such a job.
|
|
54
|
+
*/
|
|
55
|
+
export const GATE_QUALIFICATION_CAPABILITY = "gate_qualification.v1";
|
|
45
56
|
/**
|
|
46
57
|
* BAPI-1311 — the capability an executor declares when it can run a FOCUSED
|
|
47
58
|
* merge-stage `rebase`: validate `payload.focused_rebase` before any worker
|
|
@@ -73,6 +84,27 @@ export const TERMINAL_CAPACITY_CAUSE_VALUES = [
|
|
|
73
84
|
"quota_exhausted",
|
|
74
85
|
"provider_capacity_unavailable",
|
|
75
86
|
];
|
|
87
|
+
/**
|
|
88
|
+
* BAPI-1392 — the customized command names carried on a claimed job's payload.
|
|
89
|
+
*
|
|
90
|
+
* Returns an empty list for an absent or malformed value, and the usable
|
|
91
|
+
* non-empty string names (first-seen order, de-duplicated) otherwise. Never
|
|
92
|
+
* mutates the payload and never throws. Shape-only: catalog membership is
|
|
93
|
+
* checked by the packaged-recipe resolver against this executor's own bundled
|
|
94
|
+
* catalog.
|
|
95
|
+
*/
|
|
96
|
+
export function readCustomizedCommands(payload) {
|
|
97
|
+
const raw = payload && typeof payload === "object" ? payload.customized_commands : undefined;
|
|
98
|
+
if (!Array.isArray(raw))
|
|
99
|
+
return [];
|
|
100
|
+
const names = [];
|
|
101
|
+
for (const entry of raw) {
|
|
102
|
+
if (typeof entry === "string" && entry.trim().length > 0 && !names.includes(entry)) {
|
|
103
|
+
names.push(entry);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return names;
|
|
107
|
+
}
|
|
76
108
|
/**
|
|
77
109
|
* Outcome of a heartbeat/complete/fail mutation (T2 status mapping):
|
|
78
110
|
* - `updated` 2xx `{ ok: true }`
|