@bridge_gpt/mcp-server 0.2.66 → 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 +421 -83
- package/build/agent-capabilities/probes.js +15 -5
- package/build/agent-launchers/claude-executor-adapter.js +70 -16
- package/build/agent-launchers/executor-adapter.js +7 -4
- 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/bounded-wait.js +67 -10
- 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 +12 -12
- package/build/conductor/bridge-api-client.js +857 -1
- package/build/conductor/bring-up-facts.js +1 -1
- package/build/conductor/command-drift-production.js +92 -0
- package/build/conductor/command-drift.js +89 -0
- package/build/conductor/doctor.js +72 -29
- package/build/conductor/epic-branch-ci-inspection.js +1375 -0
- package/build/conductor/epic-implementer-cli.js +98 -28
- package/build/conductor/errors.js +4 -3
- package/build/conductor/gate-discovery.js +890 -0
- package/build/conductor/guided-repair.js +6 -2
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +15 -3
- package/build/conductor/json-output.js +63 -0
- package/build/conductor/merge-execution-contract.js +27 -8
- package/build/conductor/merge-execution-eligibility.js +3 -2
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +113 -12
- package/build/conductor/readiness-sections.js +410 -9
- package/build/conductor/readiness.js +388 -33
- package/build/conductor/recovery-cli.js +310 -12
- package/build/conductor/recovery-operations.js +167 -3
- package/build/conductor/repair-contract.js +54 -3
- package/build/conductor/repository-workflow-remedy.js +125 -0
- package/build/conductor/tools.js +2 -2
- package/build/conductor-bundle-artifacts.js +8 -0
- 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 +62 -3
- package/build/doctor.js +92 -1
- package/build/drive-epic-key.js +6 -5
- package/build/drive-epic.js +129 -61
- package/build/epic-coherence-input.js +2 -1
- package/build/epic-coherence-input.test-fixtures.js +4 -3
- package/build/epic-implementer-bin.js +1379 -28
- package/build/epic-implementer-bundle-cli.js +2 -2
- package/build/epic-workarounds-path.js +39 -0
- package/build/executor/cli.js +33 -2
- package/build/executor/completion-contract.js +66 -10
- package/build/executor/completion-supervision.js +4 -0
- package/build/executor/deps.js +4 -1
- package/build/executor/env.js +19 -0
- package/build/executor/gate-qualification-job.js +499 -0
- package/build/executor/guarded-fast-forward-push.js +129 -0
- package/build/executor/heartbeat.js +1 -1
- package/build/executor/http-client.js +20 -0
- package/build/executor/integration-job.js +122 -8
- package/build/executor/job-errors.js +33 -1
- package/build/executor/job-runner.js +813 -87
- package/build/executor/likely-tests.js +290 -0
- package/build/executor/live-worker-registry.js +1 -1
- package/build/executor/observation.js +37 -3
- 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 +112 -26
- 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 +111 -18
- package/build/executor/resume-pre-spawn.js +2 -0
- package/build/executor/retirement.js +69 -0
- package/build/executor/runner.js +122 -13
- package/build/executor/service-unit.js +7 -1
- 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 +113 -34
- package/build/executor/worker-guard-hook.js +316 -58
- package/build/executor/worker-log.js +4 -3
- package/build/executor/worker-verification-guidance.js +37 -0
- package/build/executor/worktree.js +166 -8
- 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 +1224 -1054
- package/build/init.js +54 -177
- package/build/install-bridge-conductor-gate.js +1372 -0
- package/build/install-bridge-conductor.js +296 -41
- package/build/install-bridge.js +547 -40
- package/build/mcp-install-state.js +50 -4
- package/build/merge-workflow-install.js +3 -4
- package/build/phase-context-git-probe.js +68 -0
- package/build/phase-result-artifacts.js +173 -5
- package/build/pipeline-utils.js +6 -2
- package/build/pipelines.generated.js +34 -160
- package/build/plan-execution-ledger.js +2 -32
- package/build/plane/alembic-head.js +12 -3
- package/build/plane/base-worktree-guard.js +147 -0
- package/build/plane/cli.js +217 -17
- package/build/plane/code-snapshot.js +468 -0
- package/build/plane/defaults.js +48 -3
- package/build/plane/endpoint.js +41 -0
- package/build/plane/manifest.js +136 -2
- package/build/plane/member-roster.js +94 -19
- package/build/plane/preflight.js +83 -2
- package/build/plane/process-cwd.js +67 -0
- package/build/plane/restart.js +542 -0
- package/build/plane/shutdown.js +31 -0
- package/build/plane/status.js +143 -8
- package/build/plane/supervisor.js +586 -179
- package/build/plane/types.js +54 -2
- package/build/printed-remedy-commands.js +52 -0
- package/build/readiness-check.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/repair-assets.js +93 -0
- package/build/review-generation.js +22 -0
- package/build/run-unit-tests-launcher.js +130 -49
- package/build/secret-redaction.js +33 -0
- package/build/setup-epic.js +152 -22
- package/build/sfcc/config.js +9 -0
- package/build/sfcc/credentials.js +100 -38
- package/build/sfcc/hostname.js +67 -0
- package/build/sfcc/permissions.js +17 -13
- package/build/sfcc/register.js +1 -1
- package/build/sfcc/tool-wrapper.js +39 -43
- package/build/sfcc/write-grants.js +17 -11
- package/build/sfcc/write-result.js +4 -3
- package/build/sfcc/writes-site-preference.js +20 -8
- package/build/start-tickets-prereqs.js +70 -16
- package/build/start-tickets.js +208 -36
- package/build/tool-error-envelope.js +61 -0
- package/build/tool-surface-gating.js +36 -0
- package/build/upgrade-cli.js +55 -1
- package/build/version.generated.js +3 -3
- package/build/visual-diff.js +22 -1
- package/build/worker-guard-hook-bin.js +4 -6
- package/build/worker-guard-hook-main.js +47 -0
- package/build/worktree-core.js +145 -6
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +867 -0
- package/docs/EPIC-IMPLEMENTER.md +728 -0
- package/docs/install/mcp-tool-integrations.md +24 -15
- package/docs/install/sfcc-integration.md +12 -4
- package/package.json +3 -3
- package/pipelines/greenfield-setup.json +4 -128
- package/pipelines/implement-ticket.json +3 -2
- package/pipelines/plan-epic.json +0 -5
- package/pipelines/review-ticket.json +1 -1
- package/docs/CONDUCTOR.md +0 -605
- package/pipelines/idea-to-pr.json +0 -49
|
@@ -11,17 +11,19 @@ export const DEFAULT_TERM_GRACE_MS = 10_000;
|
|
|
11
11
|
*/
|
|
12
12
|
export function createProcessTerminationController(proc, deps, termGraceMs = DEFAULT_TERM_GRACE_MS) {
|
|
13
13
|
let requested = false;
|
|
14
|
+
let cause = null;
|
|
14
15
|
let escalated = false;
|
|
15
16
|
let disposed = false;
|
|
16
17
|
let graceTimer;
|
|
17
18
|
const killListeners = [];
|
|
18
19
|
return {
|
|
19
|
-
requestTermination() {
|
|
20
|
+
requestTermination(endCause = "executor_abort") {
|
|
20
21
|
// A disposed controller belongs to a settled child: signalling it would
|
|
21
22
|
// either hit nothing or, worse, a recycled pid.
|
|
22
23
|
if (requested || disposed)
|
|
23
24
|
return;
|
|
24
25
|
requested = true;
|
|
26
|
+
cause = endCause;
|
|
25
27
|
proc.kill("SIGTERM");
|
|
26
28
|
graceTimer = deps.setTimer(() => {
|
|
27
29
|
graceTimer = undefined;
|
|
@@ -42,6 +44,9 @@ export function createProcessTerminationController(proc, deps, termGraceMs = DEF
|
|
|
42
44
|
isTerminationRequested() {
|
|
43
45
|
return requested;
|
|
44
46
|
},
|
|
47
|
+
endCause() {
|
|
48
|
+
return cause;
|
|
49
|
+
},
|
|
45
50
|
dispose() {
|
|
46
51
|
if (disposed)
|
|
47
52
|
return;
|
|
@@ -125,7 +130,7 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
|
|
|
125
130
|
// shutdown, timeout, and MCP containment converge on a single `SIGTERM` and a
|
|
126
131
|
// single grace timer rather than racing three of each.
|
|
127
132
|
const controller = options.terminationController ?? createProcessTerminationController(proc, deps, termGraceMs);
|
|
128
|
-
const terminate = () => controller.requestTermination();
|
|
133
|
+
const terminate = (cause) => controller.requestTermination(cause);
|
|
129
134
|
// BAPI-1301 (D4a): the authoritative consumer, when supplied. `drainClosed`
|
|
130
135
|
// flips once the bounded post-exit drain gives up, so a chunk arriving after
|
|
131
136
|
// the verdict was taken can never mutate the ledger behind the caller's back.
|
|
@@ -160,18 +165,16 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
|
|
|
160
165
|
stdoutExcerpt = appendBounded(stdoutExcerpt, chunk, limit);
|
|
161
166
|
if (options.onStdoutTerminationCheck?.(chunk) === true && !terminationRequested) {
|
|
162
167
|
terminationRequested = true;
|
|
163
|
-
terminate();
|
|
168
|
+
terminate("executor_abort");
|
|
164
169
|
}
|
|
165
170
|
});
|
|
166
171
|
const pumpStderr = pump(proc.stderr, (chunk) => {
|
|
167
172
|
stderrExcerpt = appendBounded(stderrExcerpt, chunk, limit);
|
|
168
173
|
});
|
|
169
|
-
let timedOut = false;
|
|
170
174
|
const timeoutTimer = deps.setTimer(() => {
|
|
171
|
-
timedOut = true;
|
|
172
175
|
// Same escalation the assertion uses; the guard inside makes a timeout that
|
|
173
176
|
// races an already-requested termination a no-op rather than a double signal.
|
|
174
|
-
terminate();
|
|
177
|
+
terminate("job_timeout");
|
|
175
178
|
}, timeoutSeconds * 1000);
|
|
176
179
|
// BAPI-1301 (D4a): bounded waits, only when a drain deadline was supplied.
|
|
177
180
|
// Every timer created here is cleared before returning.
|
|
@@ -226,9 +229,13 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
|
|
|
226
229
|
deps.clearTimer(handle);
|
|
227
230
|
}
|
|
228
231
|
let classification;
|
|
229
|
-
|
|
232
|
+
const endCause = controller.endCause?.() ?? null;
|
|
233
|
+
if (endCause === "job_timeout") {
|
|
230
234
|
classification = "timeout";
|
|
231
235
|
}
|
|
236
|
+
else if (endCause !== null) {
|
|
237
|
+
classification = "killed";
|
|
238
|
+
}
|
|
232
239
|
else if (exitCode === 0) {
|
|
233
240
|
classification = "clean_exit";
|
|
234
241
|
}
|
|
@@ -241,6 +248,7 @@ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options
|
|
|
241
248
|
signal,
|
|
242
249
|
stdoutExcerpt,
|
|
243
250
|
stderrExcerpt,
|
|
251
|
+
endCause: endCause ?? "worker_exit",
|
|
244
252
|
...(terminationRequested ? { terminationRequested: true } : {}),
|
|
245
253
|
// BAPI-1301 fields appear only for callers that opted in, so every legacy
|
|
246
254
|
// result keeps its exact shape.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { renderWorkerVerificationGuidance } from "./worker-verification-guidance.js";
|
|
1
2
|
/**
|
|
2
3
|
* Executor-side structured prompt-spec renderer (BAPI-550, TDD §6/§7).
|
|
3
4
|
*
|
|
@@ -18,6 +19,35 @@
|
|
|
18
19
|
* or reasons.
|
|
19
20
|
*/
|
|
20
21
|
import { CiFixContext, ExecutorNamedError } from "./job-errors.js";
|
|
22
|
+
import { EPIC_WORKAROUNDS_DIR, epicWorkaroundRecordPath } from "../epic-workarounds-path.js";
|
|
23
|
+
/**
|
|
24
|
+
* BAPI-1399 — the workaround contract for a PR-producing Epic Implementer worker.
|
|
25
|
+
*
|
|
26
|
+
* A requirement the worker cannot meet unattended (a missing credential, an
|
|
27
|
+
* external setting, or access) is either MINOR — the ticket's essential
|
|
28
|
+
* behavior still works without it — or CORE. A minor gap gets a guarded
|
|
29
|
+
* workaround and a committed record at `.bridge/epic-workarounds/<TICKET>.md`;
|
|
30
|
+
* while that folder exists on the epic head the integration pull request stays
|
|
31
|
+
* in draft. A core gap is never worked around: the worker leaves the required
|
|
32
|
+
* work unfinished, which parks the ticket as `required_work_incomplete` through
|
|
33
|
+
* the unchanged completion contract.
|
|
34
|
+
*
|
|
35
|
+
* The worker decides from the ticket and the code alone; no supervisor scan
|
|
36
|
+
* result is injected here. Kept SINGLE-LINE and free of `;`, quotes, and
|
|
37
|
+
* apostrophes, like `buildPrBaseContractLaunchInstruction`, because it travels
|
|
38
|
+
* in the same launch prompt.
|
|
39
|
+
*/
|
|
40
|
+
export function buildEpicWorkaroundWorkerInstruction(ticketKey) {
|
|
41
|
+
const record = (ticketKey ? epicWorkaroundRecordPath(ticketKey) : null) ?? `${EPIC_WORKAROUNDS_DIR}<TICKET>.md`;
|
|
42
|
+
return ("Workaround contract: if a requirement of this ticket cannot be met unattended, such as a missing " +
|
|
43
|
+
"credential, external setting, or access, decide whether it is minor or core. It is minor only when " +
|
|
44
|
+
"the essential behavior of the ticket still works without it. For a minor gap, deliver a guarded " +
|
|
45
|
+
"workaround: guard exactly the deferred behavior (for example, check for a missing setting and skip " +
|
|
46
|
+
"only that feature), keep the core path working, and write " + record + " naming the requirement, " +
|
|
47
|
+
"the guarded substitute you delivered, and the step a human must still take. Commit that record on " +
|
|
48
|
+
"this ticket branch with git add -f " + record + ". For a core gap, never write a workaround or a " +
|
|
49
|
+
"record: leave the required work unfinished so the run parks this ticket for a human.");
|
|
50
|
+
}
|
|
21
51
|
/** Stable named-error kind used for every prompt-spec contract failure. */
|
|
22
52
|
const PROMPT_CONTRACT_ERROR = "ContractError.Prompt";
|
|
23
53
|
/** The prompt-spec version this renderer understands. */
|
|
@@ -647,5 +677,7 @@ export async function renderPromptSpecPrompt(job, context) {
|
|
|
647
677
|
const values = await buildPromptSpecPlaceholderValues(job, spec, context);
|
|
648
678
|
const systemPrompt = buildPromptSpecSystemPrompt(spec, values);
|
|
649
679
|
const userPrompt = buildPromptSpecUserPrompt(spec, values);
|
|
650
|
-
return `${systemPrompt}\n\n${userPrompt}
|
|
680
|
+
return `${systemPrompt}\n\n${userPrompt}` +
|
|
681
|
+
(!context.deferComparisonGuidance && ["implement", "resume", "ci_fix"].includes(job.job_type)
|
|
682
|
+
? "\n\n" + renderWorkerVerificationGuidance(context.comparisonSha) : "");
|
|
651
683
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Atomic resolver refresh. Caller retains the exclusive worktree lock. */
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { parseRecentlyFailedTests, RECENTLY_FAILED_TESTS_PATH } from "./likely-tests.js";
|
|
7
|
+
export function validatedRecentFailures(value) {
|
|
8
|
+
if (value === undefined)
|
|
9
|
+
return { version: 1, tests: [], truncated: false };
|
|
10
|
+
const parsed = parseRecentlyFailedTests(value);
|
|
11
|
+
if (!parsed.available || parsed.skipped || JSON.stringify(value).length > 16 * 1024 ||
|
|
12
|
+
value.tests.length > 20)
|
|
13
|
+
throw new Error("recent_failure_schema_invalid");
|
|
14
|
+
return parsed.value;
|
|
15
|
+
}
|
|
16
|
+
export async function writeRecentlyFailedTestsFile(worktree, value) {
|
|
17
|
+
const selection = validatedRecentFailures(value);
|
|
18
|
+
const root = await realpath(worktree);
|
|
19
|
+
const directory = path.join(root, ".conductor");
|
|
20
|
+
try {
|
|
21
|
+
await mkdir(directory);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error.code !== "EEXIST")
|
|
25
|
+
throw new Error("recent_failure_directory_unavailable");
|
|
26
|
+
}
|
|
27
|
+
const directoryStat = await lstat(directory);
|
|
28
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || await realpath(directory) !== directory)
|
|
29
|
+
throw new Error("recent_failure_directory_unsafe");
|
|
30
|
+
const destination = path.join(root, RECENTLY_FAILED_TESTS_PATH);
|
|
31
|
+
const safeDestination = async () => {
|
|
32
|
+
if (await realpath(directory) !== directory || !(await lstat(directory)).isDirectory() ||
|
|
33
|
+
(await lstat(directory)).isSymbolicLink())
|
|
34
|
+
throw new Error("recent_failure_directory_changed");
|
|
35
|
+
try {
|
|
36
|
+
const stat = await lstat(destination);
|
|
37
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
38
|
+
throw new Error("recent_failure_destination_unsafe");
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code !== "ENOENT")
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
await safeDestination();
|
|
46
|
+
const temporary = path.join(directory, `.recent-failures-${randomUUID()}.tmp`);
|
|
47
|
+
let created = false;
|
|
48
|
+
try {
|
|
49
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
50
|
+
created = true;
|
|
51
|
+
try {
|
|
52
|
+
await handle.writeFile(JSON.stringify(selection) + "\n", "utf8");
|
|
53
|
+
await handle.sync();
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await handle.close();
|
|
57
|
+
}
|
|
58
|
+
if (await realpath(temporary) !== temporary || !(await lstat(temporary)).isFile())
|
|
59
|
+
throw new Error("recent_failure_temporary_unsafe");
|
|
60
|
+
await safeDestination();
|
|
61
|
+
const current = await lstat(directory);
|
|
62
|
+
if (current.ino !== directoryStat.ino || current.dev !== directoryStat.dev)
|
|
63
|
+
throw new Error("recent_failure_directory_changed");
|
|
64
|
+
await rename(temporary, destination);
|
|
65
|
+
created = false;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
if (created && await realpath(directory).catch(() => "") === directory)
|
|
69
|
+
await unlink(temporary).catch(() => { });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { guardedFastForwardPush, PUSH_AUTH_FAILURE_RE } from "./guarded-fast-forward-push.js";
|
|
1
2
|
/**
|
|
2
3
|
* The `reconcile_overlap` executor job (BAPI-1152).
|
|
3
4
|
*
|
|
@@ -71,7 +72,6 @@ const CONFLICT_REPORT_DIR = ".conductor/";
|
|
|
71
72
|
const CONDUCTOR_ARTIFACT_DIR = ".conductor/";
|
|
72
73
|
/** Command bounds. */
|
|
73
74
|
const GIT_TIMEOUT_MS = 120_000;
|
|
74
|
-
const PUSH_TIMEOUT_MS = 180_000;
|
|
75
75
|
const COMMAND_OUTPUT_TAIL_CHARS = 4_000;
|
|
76
76
|
/** Left in the job budget when the wrapper waits for provider checks. */
|
|
77
77
|
const CI_WAIT_MARGIN_MS = 90_000;
|
|
@@ -91,8 +91,6 @@ const WRAPPER_STAGE_EXCLUDES = [
|
|
|
91
91
|
":(exclude).claude/settings.local.json",
|
|
92
92
|
];
|
|
93
93
|
const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
94
|
-
const PUSH_AUTH_FAILURE_RE = /authentication failed|could not read username|permission denied|terminal prompts disabled|invalid username or password|returned error: 403|requested url returned error: 401/i;
|
|
95
|
-
const PUSH_LEASE_FAILURE_RE = /stale info|\[rejected\]|non-fast-forward|fetch first/i;
|
|
96
94
|
class PayloadError extends Error {
|
|
97
95
|
}
|
|
98
96
|
function asRecord(value, field) {
|
|
@@ -339,9 +337,13 @@ export function createReconcileOverlapDriver(job, payload, seams) {
|
|
|
339
337
|
const git = async (ctx, args, timeoutMs = GIT_TIMEOUT_MS, env) => {
|
|
340
338
|
if (ctx.signal?.aborted)
|
|
341
339
|
throw new ReconcileOverlapAborted("reconciliation phase aborted");
|
|
340
|
+
const remaining = "remainingMs" in ctx ? ctx.remainingMs() : timeoutMs;
|
|
341
|
+
if (remaining <= 0)
|
|
342
|
+
throw new ReconcileOverlapAborted("reconciliation deadline expired");
|
|
342
343
|
return seams.runCommand("git", args, {
|
|
343
344
|
cwd: ctx.worktreePath,
|
|
344
|
-
timeoutMs,
|
|
345
|
+
timeoutMs: Math.min(timeoutMs, remaining),
|
|
346
|
+
signal: ctx.signal,
|
|
345
347
|
...(env ? { env } : {}),
|
|
346
348
|
});
|
|
347
349
|
};
|
|
@@ -374,8 +376,12 @@ export function createReconcileOverlapDriver(job, payload, seams) {
|
|
|
374
376
|
result: envelope(extra),
|
|
375
377
|
...(lastCommitSha ? { lastCommitSha } : {}),
|
|
376
378
|
});
|
|
379
|
+
let preserveHead = false;
|
|
380
|
+
let cleanupHead = payload.childHead;
|
|
377
381
|
/** Reset to the recorded child head. Never throws. */
|
|
378
382
|
const resetToChildHead = async (ctx) => {
|
|
383
|
+
if (preserveHead)
|
|
384
|
+
return;
|
|
379
385
|
try {
|
|
380
386
|
const mergeHead = await seams.runCommand("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], {
|
|
381
387
|
cwd: ctx.worktreePath,
|
|
@@ -384,7 +390,7 @@ export function createReconcileOverlapDriver(job, payload, seams) {
|
|
|
384
390
|
if (mergeHead.exitCode === 0) {
|
|
385
391
|
await seams.runCommand("git", ["merge", "--abort"], { cwd: ctx.worktreePath, timeoutMs: GIT_TIMEOUT_MS });
|
|
386
392
|
}
|
|
387
|
-
await seams.runCommand("git", ["reset", "--hard",
|
|
393
|
+
await seams.runCommand("git", ["reset", "--hard", cleanupHead], {
|
|
388
394
|
cwd: ctx.worktreePath,
|
|
389
395
|
timeoutMs: GIT_TIMEOUT_MS,
|
|
390
396
|
});
|
|
@@ -595,35 +601,33 @@ export function createReconcileOverlapDriver(job, payload, seams) {
|
|
|
595
601
|
}
|
|
596
602
|
return complete({ observed_epic_head: heads.epic });
|
|
597
603
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
"origin",
|
|
607
|
-
`HEAD:${destination}`,
|
|
608
|
-
], PUSH_TIMEOUT_MS, { ...seams.env, GIT_TERMINAL_PROMPT: "0" });
|
|
609
|
-
if (push.exitCode !== 0) {
|
|
610
|
-
const text = `${push.stdout}\n${push.stderr}`;
|
|
611
|
-
if (PUSH_AUTH_FAILURE_RE.test(text)) {
|
|
604
|
+
const push = await guardedFastForwardPush({
|
|
605
|
+
branch: payload.expectedBranch, expectedOriginSha: payload.childHead,
|
|
606
|
+
candidateSha: candidate, trustedExcludedShas: [payload.requiredEpicSha],
|
|
607
|
+
worktreePath: ctx.worktreePath, runCommand: seams.runCommand, env: seams.env,
|
|
608
|
+
remainingMs: ctx.remainingMs, signal: ctx.signal, mayMutate: () => !ctx.signal.aborted,
|
|
609
|
+
});
|
|
610
|
+
if (push.status !== "published") {
|
|
611
|
+
if (push.reason === "auth_failed") {
|
|
612
612
|
await resetToChildHead(ctx);
|
|
613
613
|
return fail(ReconcileOverlapPushUnauthorized, "pushed", "the executor's git credential helper refused the push");
|
|
614
614
|
}
|
|
615
|
-
if (
|
|
615
|
+
if (push.reason === "lease_rejected") {
|
|
616
616
|
const reread = await readRemoteHeads(ctx);
|
|
617
617
|
await resetToChildHead(ctx);
|
|
618
|
-
if (reread.ok && reread.child !== null && reread.child !== payload.childHead)
|
|
618
|
+
if (reread.ok && reread.child !== null && reread.child !== payload.childHead)
|
|
619
619
|
return complete({ observed_child_head: reread.child });
|
|
620
|
-
}
|
|
621
620
|
}
|
|
622
|
-
else {
|
|
621
|
+
else if (push.reason !== "timeout" && !ctx.signal.aborted) {
|
|
623
622
|
await resetToChildHead(ctx);
|
|
624
623
|
}
|
|
624
|
+
else {
|
|
625
|
+
preserveHead = true;
|
|
626
|
+
}
|
|
625
627
|
return fail(ReconcileOverlapPushRejected, "pushed", "the remote refused the guarded push");
|
|
626
628
|
}
|
|
629
|
+
preserveHead = true;
|
|
630
|
+
ctx.recordPublication?.(push);
|
|
627
631
|
// (3) Finalization runs ONCE, after the wrapper's push, against the pushed
|
|
628
632
|
// head: origin must carry it and the PR must be attached at it.
|
|
629
633
|
const finalize = seams.finalize ?? validateWorkerFinalization;
|
|
@@ -633,6 +637,7 @@ export function createReconcileOverlapDriver(job, payload, seams) {
|
|
|
633
637
|
worktreePath: ctx.worktreePath,
|
|
634
638
|
result: {},
|
|
635
639
|
runCommand: seams.runCommand,
|
|
640
|
+
callTimeoutMs: () => Math.min(GIT_TIMEOUT_MS, ctx.remainingMs()),
|
|
636
641
|
headSha: candidate,
|
|
637
642
|
expectedBaseBranch: ctx.effectiveBaseBranch,
|
|
638
643
|
originResolveAttempts: FINALIZATION_ATTEMPTS,
|
|
@@ -658,6 +663,16 @@ export function createReconcileOverlapDriver(job, payload, seams) {
|
|
|
658
663
|
if (ctx.branch !== payload.expectedBranch) {
|
|
659
664
|
return finish(fail(ReconcileOverlapBranchMismatch, "validated", "the prepared worktree is not on the job's expected branch"));
|
|
660
665
|
}
|
|
666
|
+
if (ctx.preparationPublication) {
|
|
667
|
+
preserveHead = true;
|
|
668
|
+
const heads = await readRemoteHeads(ctx);
|
|
669
|
+
if (!heads.ok)
|
|
670
|
+
return finish(heads.outcome);
|
|
671
|
+
if (heads.child === null)
|
|
672
|
+
return finish(fail(ReconcileOverlapPushRejected, "validated", "the child branch no longer exists on origin"));
|
|
673
|
+
cleanupHead = heads.child;
|
|
674
|
+
return finish(complete({ observed_child_head: heads.child }));
|
|
675
|
+
}
|
|
661
676
|
// Fetch the exact server-supplied inputs. The job never infers a head.
|
|
662
677
|
await git(ctx, ["fetch", "--no-tags", "origin", payload.epicBranch], GIT_TIMEOUT_MS, {
|
|
663
678
|
...seams.env,
|
|
@@ -831,6 +846,9 @@ export function createReconcileOverlapDriver(job, payload, seams) {
|
|
|
831
846
|
return pushFlow(ctx);
|
|
832
847
|
},
|
|
833
848
|
async abandon(ctx) {
|
|
849
|
+
// Wrapper-created merges are not retained worker progress until their
|
|
850
|
+
// verification/publication gate succeeds. Published or ambiguous pushes,
|
|
851
|
+
// including preparation publication, already set preserveHead above.
|
|
834
852
|
await resetToChildHead(ctx);
|
|
835
853
|
},
|
|
836
854
|
};
|
|
@@ -236,7 +236,7 @@ export function createSupervisedCommandLauncher(input) {
|
|
|
236
236
|
let cancelled = false;
|
|
237
237
|
const onAbort = () => {
|
|
238
238
|
cancelled = true;
|
|
239
|
-
controller.requestTermination();
|
|
239
|
+
controller.requestTermination("executor_abort");
|
|
240
240
|
};
|
|
241
241
|
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
242
242
|
try {
|
|
@@ -875,8 +875,38 @@ 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
|
};
|
|
906
|
+
// One predicate for "a required task is still pending", shared by the envelope
|
|
907
|
+
// and the observe-only query so the two cannot drift apart.
|
|
908
|
+
const hasPendingRequiredWork = () => effectiveTasks().some(({ o, state }) => o.required && state === "pending");
|
|
909
|
+
const observeLiveRequiredWork = () => hasPendingRequiredWork() ? "live_required_work" : null;
|
|
880
910
|
const buildEnvelope = (facts) => {
|
|
881
911
|
const reasons = new Set(insufficient);
|
|
882
912
|
if (!lifecycleSupported || !runtimeMatchesSupported(facts.runtime))
|
|
@@ -897,7 +927,7 @@ export function createRequiredWorkLedger(init) {
|
|
|
897
927
|
reasons.add("forced_kill_no_cli_teardown");
|
|
898
928
|
const effective = effectiveTasks();
|
|
899
929
|
// R6: a required task with no terminal after the stream ended is missing evidence.
|
|
900
|
-
if (
|
|
930
|
+
if (hasPendingRequiredWork())
|
|
901
931
|
reasons.add("live_required_work");
|
|
902
932
|
// R6b: a stale snapshot, or a snapshot-only task nothing ever explained.
|
|
903
933
|
const snap = liveSnapshotIds();
|
|
@@ -993,11 +1023,19 @@ export function createRequiredWorkLedger(init) {
|
|
|
993
1023
|
recordDeclarationRefused: () => {
|
|
994
1024
|
insufficient.add("declaration_refused");
|
|
995
1025
|
},
|
|
1026
|
+
observeLiveRequiredWork,
|
|
996
1027
|
buildEnvelope,
|
|
997
1028
|
};
|
|
998
1029
|
}
|
|
999
1030
|
/** The measured settle window after the closure rule first holds (BAPI-1292 C6an-hold2). */
|
|
1000
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;
|
|
1001
1039
|
/**
|
|
1002
1040
|
* Hold stdin open until the closure rule holds for a full debounce window, then
|
|
1003
1041
|
* close it exactly once.
|
|
@@ -1012,6 +1050,13 @@ export const CLOSURE_DEBOUNCE_MS = 1500;
|
|
|
1012
1050
|
* optional watchers are live. It is never sent while required work is live and
|
|
1013
1051
|
* never asks for a broad stop — cancelling required work is not the executor's
|
|
1014
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.
|
|
1015
1060
|
*/
|
|
1016
1061
|
export function createClosureSupervisor(opts) {
|
|
1017
1062
|
const { ledger, deps } = opts;
|
|
@@ -1024,6 +1069,14 @@ export function createClosureSupervisor(opts) {
|
|
|
1024
1069
|
let debounceTimer = null;
|
|
1025
1070
|
let armedSeq = -1;
|
|
1026
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
|
+
};
|
|
1027
1080
|
const clearDebounce = () => {
|
|
1028
1081
|
if (debounceTimer !== null)
|
|
1029
1082
|
deps.clearTimer(debounceTimer);
|
|
@@ -1036,6 +1089,7 @@ export function createClosureSupervisor(opts) {
|
|
|
1036
1089
|
closed = true;
|
|
1037
1090
|
closedReason = reason;
|
|
1038
1091
|
clearDebounce();
|
|
1092
|
+
clearWindow();
|
|
1039
1093
|
if (deadlineTimer !== null)
|
|
1040
1094
|
deps.clearTimer(deadlineTimer);
|
|
1041
1095
|
deadlineTimer = null;
|
|
@@ -1049,6 +1103,48 @@ export function createClosureSupervisor(opts) {
|
|
|
1049
1103
|
// then reports `stdout_not_read_to_eof`, never success.
|
|
1050
1104
|
}
|
|
1051
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
|
+
}
|
|
1052
1148
|
};
|
|
1053
1149
|
let deadlineTimer = deps.setTimer(() => {
|
|
1054
1150
|
deadlineTimer = null;
|
|
@@ -1060,30 +1156,26 @@ export function createClosureSupervisor(opts) {
|
|
|
1060
1156
|
const s = ledger.closureState();
|
|
1061
1157
|
if (armedSeq !== s.activitySeq)
|
|
1062
1158
|
clearDebounce();
|
|
1159
|
+
if (s.turnOpen && windowOpened) {
|
|
1160
|
+
clearWindow();
|
|
1161
|
+
windowOpened = false;
|
|
1162
|
+
}
|
|
1063
1163
|
if (!s.latestIsResult) {
|
|
1064
1164
|
// The nudge was answered (or other activity began): the next result decides again.
|
|
1065
1165
|
nudgeAwaitingReply = false;
|
|
1066
1166
|
return;
|
|
1067
1167
|
}
|
|
1168
|
+
if (!windowOpened && s.finalResultNoOpenTurn && s.liveRequiredBashBg.length > 0) {
|
|
1169
|
+
openWindow(s.liveRequiredBashBg);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1068
1172
|
if (!s.readyToClose)
|
|
1069
1173
|
return;
|
|
1070
1174
|
if (!nudged && s.liveOptional.length > 0) {
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
let ok = false;
|
|
1076
|
-
try {
|
|
1077
|
-
ok = (await opts.writeMessage(line)).ok;
|
|
1078
|
-
}
|
|
1079
|
-
catch {
|
|
1080
|
-
ok = false;
|
|
1081
|
-
}
|
|
1082
|
-
// An undelivered nudge will never be answered: fall back to the rule.
|
|
1083
|
-
if (!ok && !closed && !disposed) {
|
|
1084
|
-
nudgeAwaitingReply = false;
|
|
1085
|
-
onActivity();
|
|
1086
|
-
}
|
|
1175
|
+
// An undelivered nudge will never be answered: fall back to the rule.
|
|
1176
|
+
sendNudge(opts.buildNudge([...s.liveOptional]), () => {
|
|
1177
|
+
nudgeAwaitingReply = false;
|
|
1178
|
+
onActivity();
|
|
1087
1179
|
});
|
|
1088
1180
|
return;
|
|
1089
1181
|
}
|
|
@@ -1112,6 +1204,7 @@ export function createClosureSupervisor(opts) {
|
|
|
1112
1204
|
settled: () => pending,
|
|
1113
1205
|
dispose: () => {
|
|
1114
1206
|
clearDebounce();
|
|
1207
|
+
clearWindow();
|
|
1115
1208
|
if (deadlineTimer !== null)
|
|
1116
1209
|
deps.clearTimer(deadlineTimer);
|
|
1117
1210
|
deadlineTimer = null;
|
|
@@ -149,6 +149,8 @@ export function fillResumePromptPlaceholders(template, gitLog, gitDiff) {
|
|
|
149
149
|
// ---------------------------------------------------------------------------
|
|
150
150
|
/** The deterministic WIP checkpoint message for a continuation. */
|
|
151
151
|
export const CONTINUATION_WIP_COMMIT_MESSAGE = "wip: auto-checkpoint before continuation";
|
|
152
|
+
/** BAPI-1367: the WIP checkpoint message when a retry resumes its prior attempt's work. */
|
|
153
|
+
export const PRIOR_ATTEMPT_WIP_COMMIT_MESSAGE = "wip: auto-checkpoint before retry resume";
|
|
152
154
|
const FULL_SHA = /^[0-9a-f]{40}$/;
|
|
153
155
|
/** Read the continuation binding from a claimed job's payload (pure). */
|
|
154
156
|
export function readContinuationBinding(job) {
|
|
@@ -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
|
+
}
|