@bridge_gpt/mcp-server 0.2.14 → 0.2.18
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 +2 -2
- package/build/agents.generated.js +19 -1
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +191 -11
- package/build/conductor/claude-hook.js +22 -4
- package/build/conductor/cli.js +11 -13
- package/build/conductor/done-gate.js +5 -0
- package/build/conductor/epic-reconcile.js +62 -13
- package/build/conductor/epic-runtime.js +447 -35
- package/build/conductor/epic-state.js +517 -63
- package/build/conductor/errors.js +41 -0
- package/build/conductor/event-accessors.js +234 -0
- package/build/conductor/file-scope-guard.js +201 -0
- package/build/conductor/github-mergeability.js +85 -0
- package/build/conductor/local-merge.js +47 -1
- package/build/conductor/merge-identity.js +41 -0
- package/build/conductor/merge-ledger.js +13 -68
- package/build/conductor/plan.js +12 -2
- package/build/conductor/pr-discovery.js +11 -1
- package/build/conductor/supervisor-config.js +4 -39
- package/build/conductor/supervisor-escalation.js +10 -26
- package/build/conductor/supervisor-ledger.js +5 -12
- package/build/conductor/supervisor-message-relay.js +2 -5
- package/build/conductor/supervisor-notification.js +1 -1
- package/build/conductor/supervisor-runtime.js +12 -54
- package/build/conductor/supervisor-state.js +4 -18
- package/build/conductor/supervisor-types.js +2 -2
- package/build/conductor/taxonomy.js +4 -0
- package/build/conductor-bin.js +2333 -666
- package/build/conductor-claude-hook-bin.js +4 -2
- package/build/doctor.js +32 -0
- package/build/index.js +9985 -7579
- package/build/install-bridge.js +25 -8
- package/build/install-doctor.js +387 -0
- package/build/pipelines.generated.js +30 -5
- package/build/readme.generated.js +1 -1
- package/build/regression-check.js +872 -0
- package/build/review-tickets.js +175 -21
- package/build/sfcc/permissions.js +13 -1
- package/build/sfcc/reads-custom-object-def.js +56 -39
- package/build/sfcc/register.js +1 -1
- package/build/sfcc/tool-wrapper.js +5 -1
- package/build/start-tickets-conductor.js +22 -6
- package/build/start-tickets-prereqs.js +73 -0
- package/build/start-tickets.js +122 -22
- package/build/version.generated.js +1 -1
- package/package.json +5 -5
- package/pipelines/review-ticket.json +24 -2
- package/public/css/main.min.css +3272 -1
- package/public/css/main.min.css.map +1 -1
- package/smoke-test/SMOKE-TEST.md +4 -2
|
@@ -16,82 +16,27 @@
|
|
|
16
16
|
* It performs NO privileged action and never handles VCS write credentials.
|
|
17
17
|
*/
|
|
18
18
|
import { createHash } from "node:crypto";
|
|
19
|
-
import {
|
|
19
|
+
import { getMergeIdentity } from "./event-accessors.js";
|
|
20
|
+
import { buildGateIdentity, makeMergeActionKey, } from "./merge-identity.js";
|
|
20
21
|
import { emitConductorEvent, openReadonlyConductorDatabaseIfExists, } from "./store.js";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
export function buildGateIdentity(gateName, configHash) {
|
|
27
|
-
const name = gateName.trim();
|
|
28
|
-
const hash = typeof configHash === "string" ? configHash.trim() : "";
|
|
29
|
-
return hash ? `${name}@${hash.toLowerCase()}` : name;
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* Build the deterministic action key. Normalizes repo / PR / head SHA exactly as
|
|
33
|
-
* the Python side does (lower-cased SHA, trimmed repo, positive integer PR) so the
|
|
34
|
-
* conductor-computed key and the API-recomputed key are byte-identical. Throws on
|
|
35
|
-
* any invalid component. The branch name is never part of the key.
|
|
36
|
-
*/
|
|
37
|
-
export function makeMergeActionKey(repo, prNumber, headSha, gateIdentity) {
|
|
38
|
-
const r = normalizeRepoName(repo);
|
|
39
|
-
const pr = normalizePrNumber(prNumber);
|
|
40
|
-
const sha = normalizeSha(headSha);
|
|
41
|
-
const gate = (gateIdentity ?? "").trim();
|
|
42
|
-
if (r === null || pr === null || sha === null || gate.length === 0) {
|
|
43
|
-
throw new Error("invalid merge action key component");
|
|
44
|
-
}
|
|
45
|
-
return `merge:${r}:${pr}:${sha}:${gate}`;
|
|
46
|
-
}
|
|
22
|
+
// BAPI-493: the merge-identity primitives now live in the dependency-light
|
|
23
|
+
// `merge-identity.ts` module (so the typed event-accessor layer can build a
|
|
24
|
+
// MergeActionIdentity without an import cycle). They are re-exported here so
|
|
25
|
+
// existing importers (`supervisor-merge.ts`, tests) keep a stable API surface.
|
|
26
|
+
export { buildGateIdentity, makeMergeActionKey };
|
|
47
27
|
/**
|
|
48
28
|
* Resolve the immutable merge identity from a `gate.met` event. Returns `null`
|
|
49
29
|
* unless the event is a worker-scoped `gate.met` carrying a complete PR binding
|
|
50
30
|
* (repo, pr_number, head_sha, gate_name). Run-level or incomplete events yield
|
|
51
31
|
* `null`. Branch-name fields, if present, are ignored.
|
|
32
|
+
*
|
|
33
|
+
* BAPI-493: canonical parsing now lives in the typed accessor layer — this is a
|
|
34
|
+
* thin compatibility wrapper over {@link getMergeIdentity} so `supervisor-merge.ts`
|
|
35
|
+
* and other callers keep an unchanged call site while the hand-rolled
|
|
36
|
+
* `data.details` reader is retired.
|
|
52
37
|
*/
|
|
53
38
|
export function extractMergeActionIdentityFromGateEvent(event) {
|
|
54
|
-
|
|
55
|
-
return null;
|
|
56
|
-
// Worker scope is required — a run-level gate.met never binds a specific worker.
|
|
57
|
-
if (typeof event.worker_id !== "string" || event.worker_id.trim().length === 0) {
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
const data = event.data ?? {};
|
|
61
|
-
const details = data.details;
|
|
62
|
-
if (!details || typeof details !== "object")
|
|
63
|
-
return null;
|
|
64
|
-
const d = details;
|
|
65
|
-
const repo = normalizeRepoName(d.repo);
|
|
66
|
-
const prNumber = normalizePrNumber(d.pr_number);
|
|
67
|
-
const headSha = normalizeSha(d.head_sha);
|
|
68
|
-
const gateName = typeof d.gate_name === "string" ? d.gate_name.trim() : "";
|
|
69
|
-
if (repo === null || prNumber === null || headSha === null || gateName.length === 0) {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
const configHash = typeof d.config_hash === "string" && d.config_hash.trim().length > 0
|
|
73
|
-
? d.config_hash.trim()
|
|
74
|
-
: null;
|
|
75
|
-
const requiredChecks = Array.isArray(d.required_checks)
|
|
76
|
-
? d.required_checks.filter((c) => typeof c === "string" && c.trim().length > 0)
|
|
77
|
-
: [];
|
|
78
|
-
const gateIdentity = buildGateIdentity(gateName, configHash);
|
|
79
|
-
const actionKey = makeMergeActionKey(repo, prNumber, headSha, gateIdentity);
|
|
80
|
-
return {
|
|
81
|
-
repo,
|
|
82
|
-
pr_number: prNumber,
|
|
83
|
-
head_sha: headSha,
|
|
84
|
-
gate_name: gateName,
|
|
85
|
-
config_hash: configHash,
|
|
86
|
-
required_checks: requiredChecks,
|
|
87
|
-
gate_identity: gateIdentity,
|
|
88
|
-
action_key: actionKey,
|
|
89
|
-
gate_event: {
|
|
90
|
-
id: typeof event.id === "string" ? event.id : undefined,
|
|
91
|
-
seq: typeof event.seq === "number" ? event.seq : undefined,
|
|
92
|
-
time: typeof event.time === "string" ? event.time : undefined,
|
|
93
|
-
},
|
|
94
|
-
};
|
|
39
|
+
return getMergeIdentity(event);
|
|
95
40
|
}
|
|
96
41
|
/**
|
|
97
42
|
* Derive a deterministic, UUID-shaped event id from the event type plus action
|
package/build/conductor/plan.js
CHANGED
|
@@ -31,12 +31,16 @@ import { stableJsonHash } from "./git-ci-types.js";
|
|
|
31
31
|
*
|
|
32
32
|
* - Trims outer whitespace from every `ticket_key` (nodes and edges).
|
|
33
33
|
* - Sorts each node's `depends_on` array alphabetically.
|
|
34
|
+
* - Sorts each node's `touched_files` array alphabetically, when present.
|
|
34
35
|
* - Sorts `nodes` lexicographically by normalized `ticket_key`.
|
|
35
36
|
* - Sorts `edges` lexicographically by `from` then `to`.
|
|
37
|
+
* - Sorts each edge's `overlap_files` array alphabetically, when present.
|
|
36
38
|
*
|
|
37
39
|
* The canonical form is fed to {@link hashPlan} / `stableJsonHash`, which
|
|
38
40
|
* additionally sorts object keys recursively, so two independently constructed
|
|
39
|
-
* equal plans always produce a byte-identical SHA-256 digest.
|
|
41
|
+
* equal plans always produce a byte-identical SHA-256 digest. Only fields
|
|
42
|
+
* already present on the input are normalized — canonicalization never
|
|
43
|
+
* derives new data from filesystem, git, network, or LLM state.
|
|
40
44
|
*/
|
|
41
45
|
export function canonicalizePlanDAG(plan) {
|
|
42
46
|
const nodes = plan.nodes
|
|
@@ -44,10 +48,16 @@ export function canonicalizePlanDAG(plan) {
|
|
|
44
48
|
...node,
|
|
45
49
|
ticket_key: node.ticket_key.trim(),
|
|
46
50
|
depends_on: [...node.depends_on].map((k) => k.trim()).sort(),
|
|
51
|
+
...(node.touched_files ? { touched_files: [...node.touched_files].sort() } : {}),
|
|
47
52
|
}))
|
|
48
53
|
.sort((a, b) => a.ticket_key.localeCompare(b.ticket_key));
|
|
49
54
|
const edges = [...plan.edges]
|
|
50
|
-
.map((e) => ({
|
|
55
|
+
.map((e) => ({
|
|
56
|
+
from: e.from.trim(),
|
|
57
|
+
to: e.to.trim(),
|
|
58
|
+
...(e.kind ? { kind: e.kind } : {}),
|
|
59
|
+
...(e.overlap_files ? { overlap_files: [...e.overlap_files].sort() } : {}),
|
|
60
|
+
}))
|
|
51
61
|
.sort((a, b) => {
|
|
52
62
|
const cmp = a.from.localeCompare(b.from);
|
|
53
63
|
return cmp !== 0 ? cmp : a.to.localeCompare(b.to);
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { execFileSync } from "node:child_process";
|
|
13
13
|
import { normalizePrNumber, normalizeRepoName, normalizeSha } from "./git-ci-types.js";
|
|
14
|
+
import { parseGhPrMergeabilityFields } from "./github-mergeability.js";
|
|
14
15
|
import { getGitWorktreeContext } from "./git-inspection.js";
|
|
15
16
|
/** Bounded timeout for the one-shot `gh` lookup. */
|
|
16
17
|
export const GH_COMMAND_TIMEOUT_MS = 5_000;
|
|
@@ -30,7 +31,13 @@ export function runGhCommand(args, options = {}) {
|
|
|
30
31
|
return { ok: false, stdout: "" };
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
|
-
const GH_PR_VIEW_ARGS = [
|
|
34
|
+
const GH_PR_VIEW_ARGS = [
|
|
35
|
+
"pr",
|
|
36
|
+
"view",
|
|
37
|
+
"--json",
|
|
38
|
+
// BAPI-494: mergeability fields added to the SAME one-shot call — no new gh process.
|
|
39
|
+
"number,headRefOid,headRefName,url,state,mergeable,mergeStateStatus",
|
|
40
|
+
];
|
|
34
41
|
/**
|
|
35
42
|
* Perform a one-shot `gh pr view` lookup for the current branch's PR. Returns a
|
|
36
43
|
* normalized {@link DiscoveredPr} or `null` when gh is unavailable, there is no
|
|
@@ -55,10 +62,13 @@ export function discoverPrWithGhCli(options = {}, deps = {}) {
|
|
|
55
62
|
const state = typeof record.state === "string" ? record.state : "";
|
|
56
63
|
if (number === null || state.length === 0)
|
|
57
64
|
return null;
|
|
65
|
+
const mergeability = parseGhPrMergeabilityFields(record);
|
|
58
66
|
const discovered = {
|
|
59
67
|
number,
|
|
60
68
|
head_sha: normalizeSha(record.headRefOid),
|
|
61
69
|
state,
|
|
70
|
+
mergeable: mergeability.mergeable,
|
|
71
|
+
mergeStateStatus: mergeability.mergeStateStatus,
|
|
62
72
|
};
|
|
63
73
|
if (typeof record.headRefName === "string" && record.headRefName.trim().length > 0) {
|
|
64
74
|
discovered.head_ref = record.headRefName.trim();
|
|
@@ -2,15 +2,10 @@
|
|
|
2
2
|
* Supervisor configuration resolution (BAPI-396, conductor C4).
|
|
3
3
|
*
|
|
4
4
|
* Centralizes the supervisor's wake cadence, global timeout, per-state stall
|
|
5
|
-
* thresholds, liveness thresholds, escalation cooldown
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* Token discipline: the WAKE cadence (frequent, deterministic event polling) is
|
|
11
|
-
* intentionally separate from the LLM cadence (judgment-triggered only). The
|
|
12
|
-
* deterministic loop runs every `wake_interval_ms`; the LLM is consulted at most
|
|
13
|
-
* `llm_max_calls` times per run and only for ambiguous escalation candidates.
|
|
5
|
+
* thresholds, liveness thresholds, and escalation cooldown. All values are
|
|
6
|
+
* resolved from `process.env` with conservative, bounded defaults following the
|
|
7
|
+
* style of {@link resolveConductorStoreConfig} in `store.ts`: malformed values
|
|
8
|
+
* fall back to the default and excessive values are clamped.
|
|
14
9
|
*/
|
|
15
10
|
const MINUTE_MS = 60_000;
|
|
16
11
|
const HOUR_MS = 60 * MINUTE_MS;
|
|
@@ -36,13 +31,6 @@ const LIVENESS_STALLED_AFTER_MAX_MS = 4 * HOUR_MS;
|
|
|
36
31
|
const DEAD_AFTER_DEFAULT_MS = 2 * HOUR_MS;
|
|
37
32
|
const DEAD_AFTER_MIN_MS = 10 * MINUTE_MS;
|
|
38
33
|
const DEAD_AFTER_MAX_MS = 24 * HOUR_MS;
|
|
39
|
-
// --- LLM budget. ----------------------------------------------------------
|
|
40
|
-
const LLM_MAX_CALLS_DEFAULT = 10;
|
|
41
|
-
const LLM_MAX_CALLS_MIN = 0;
|
|
42
|
-
const LLM_MAX_CALLS_MAX = 1000;
|
|
43
|
-
const LLM_TIMEOUT_DEFAULT_MS = 30_000;
|
|
44
|
-
const LLM_TIMEOUT_MIN_MS = 1_000;
|
|
45
|
-
const LLM_TIMEOUT_MAX_MS = 120_000;
|
|
46
34
|
/**
|
|
47
35
|
* Parse a bounded integer from an env-style string. Mirrors `parseBoundedInt`
|
|
48
36
|
* in `store.ts`: blank/undefined/non-finite/float-with-fraction/non-numeric
|
|
@@ -68,19 +56,6 @@ export function parseBoundedSupervisorInt(raw, fallback, min, max) {
|
|
|
68
56
|
const POLL_LIMIT_DEFAULT = 200;
|
|
69
57
|
const POLL_LIMIT_MIN = 1;
|
|
70
58
|
const POLL_LIMIT_MAX = 1000;
|
|
71
|
-
/** Truthy parse for boolean-ish env strings (`0`/`false`/`off`/`no` => false). */
|
|
72
|
-
function parseBoolEnv(raw, fallback) {
|
|
73
|
-
if (raw === undefined)
|
|
74
|
-
return fallback;
|
|
75
|
-
const v = raw.trim().toLowerCase();
|
|
76
|
-
if (v.length === 0)
|
|
77
|
-
return fallback;
|
|
78
|
-
if (v === "0" || v === "false" || v === "off" || v === "no")
|
|
79
|
-
return false;
|
|
80
|
-
if (v === "1" || v === "true" || v === "on" || v === "yes")
|
|
81
|
-
return true;
|
|
82
|
-
return fallback;
|
|
83
|
-
}
|
|
84
59
|
/**
|
|
85
60
|
* Resolve the supervisor configuration. Precedence per field: a direct override
|
|
86
61
|
* (when provided and valid) wins over the environment, which wins over the
|
|
@@ -96,13 +71,6 @@ export function resolveSupervisorConfig(overrides = {}, env = process.env) {
|
|
|
96
71
|
const quiet_after_ms = parseBoundedSupervisorInt(env.BAPI_CONDUCTOR_QUIET_AFTER_MS, QUIET_AFTER_DEFAULT_MS, QUIET_AFTER_MIN_MS, QUIET_AFTER_MAX_MS);
|
|
97
72
|
const liveness_stalled_after_ms = parseBoundedSupervisorInt(env.BAPI_CONDUCTOR_LIVENESS_STALLED_AFTER_MS, LIVENESS_STALLED_AFTER_DEFAULT_MS, LIVENESS_STALLED_AFTER_MIN_MS, LIVENESS_STALLED_AFTER_MAX_MS);
|
|
98
73
|
const dead_after_ms = parseBoundedSupervisorInt(env.BAPI_CONDUCTOR_DEAD_AFTER_MS, DEAD_AFTER_DEFAULT_MS, DEAD_AFTER_MIN_MS, DEAD_AFTER_MAX_MS);
|
|
99
|
-
// LLM is enabled by default but degrades cleanly. A direct `llm_enabled:false`
|
|
100
|
-
// override (e.g. CLI `--no-llm`) forces deterministic-only mode.
|
|
101
|
-
const llm_enabled = overrides.llm_enabled !== undefined
|
|
102
|
-
? overrides.llm_enabled
|
|
103
|
-
: parseBoolEnv(env.BAPI_CONDUCTOR_LLM_ENABLED, true);
|
|
104
|
-
const llm_max_calls = clampOverride(overrides.llm_max_calls, parseBoundedSupervisorInt(env.BAPI_CONDUCTOR_LLM_MAX_CALLS, LLM_MAX_CALLS_DEFAULT, LLM_MAX_CALLS_MIN, LLM_MAX_CALLS_MAX), LLM_MAX_CALLS_MIN, LLM_MAX_CALLS_MAX);
|
|
105
|
-
const llm_timeout_ms = clampOverride(overrides.llm_timeout_ms, parseBoundedSupervisorInt(env.BAPI_CONDUCTOR_LLM_TIMEOUT_MS, LLM_TIMEOUT_DEFAULT_MS, LLM_TIMEOUT_MIN_MS, LLM_TIMEOUT_MAX_MS), LLM_TIMEOUT_MIN_MS, LLM_TIMEOUT_MAX_MS);
|
|
106
74
|
const poll_limit = parseBoundedSupervisorInt(env.BAPI_CONDUCTOR_SUPERVISOR_POLL_LIMIT, POLL_LIMIT_DEFAULT, POLL_LIMIT_MIN, POLL_LIMIT_MAX);
|
|
107
75
|
return {
|
|
108
76
|
wake_interval_ms,
|
|
@@ -114,9 +82,6 @@ export function resolveSupervisorConfig(overrides = {}, env = process.env) {
|
|
|
114
82
|
dead_after_ms,
|
|
115
83
|
},
|
|
116
84
|
escalation_cooldown_ms,
|
|
117
|
-
llm_enabled,
|
|
118
|
-
llm_max_calls,
|
|
119
|
-
llm_timeout_ms,
|
|
120
85
|
poll_limit,
|
|
121
86
|
};
|
|
122
87
|
}
|
|
@@ -34,8 +34,7 @@ function elapsedSinceSignal(worker, now) {
|
|
|
34
34
|
* Inspect run state and return deterministic escalation candidates. Reasons:
|
|
35
35
|
* `worker_not_started`, `worker_stalled`, `worker_blocked`, `worker_dead`,
|
|
36
36
|
* `candidate_done_stuck`, `verification_stalled`, and `global_timeout`. Terminal
|
|
37
|
-
* workers never produce candidates.
|
|
38
|
-
* from an LLM judgment (long-running stalls) vs. unambiguous ones.
|
|
37
|
+
* workers never produce candidates.
|
|
39
38
|
*/
|
|
40
39
|
export function findSupervisorEscalationCandidates(state, config, now) {
|
|
41
40
|
const candidates = [];
|
|
@@ -59,7 +58,6 @@ export function findSupervisorEscalationCandidates(state, config, now) {
|
|
|
59
58
|
state: worker.state,
|
|
60
59
|
liveness: worker.liveness,
|
|
61
60
|
elapsed_ms: elapsed,
|
|
62
|
-
ambiguous: false,
|
|
63
61
|
context: baseContext,
|
|
64
62
|
});
|
|
65
63
|
continue;
|
|
@@ -82,7 +80,6 @@ export function findSupervisorEscalationCandidates(state, config, now) {
|
|
|
82
80
|
state: worker.state,
|
|
83
81
|
liveness: worker.liveness,
|
|
84
82
|
elapsed_ms: elapsed,
|
|
85
|
-
ambiguous: false,
|
|
86
83
|
context: baseContext,
|
|
87
84
|
});
|
|
88
85
|
}
|
|
@@ -95,7 +92,6 @@ export function findSupervisorEscalationCandidates(state, config, now) {
|
|
|
95
92
|
state: worker.state,
|
|
96
93
|
liveness: worker.liveness,
|
|
97
94
|
elapsed_ms: elapsed,
|
|
98
|
-
ambiguous: false,
|
|
99
95
|
context: { ...baseContext, blocked_reason: worker.blocked_reason },
|
|
100
96
|
});
|
|
101
97
|
break;
|
|
@@ -107,7 +103,6 @@ export function findSupervisorEscalationCandidates(state, config, now) {
|
|
|
107
103
|
state: worker.state,
|
|
108
104
|
liveness: worker.liveness,
|
|
109
105
|
elapsed_ms: elapsed,
|
|
110
|
-
ambiguous: true,
|
|
111
106
|
context: baseContext,
|
|
112
107
|
});
|
|
113
108
|
break;
|
|
@@ -124,7 +119,6 @@ export function findSupervisorEscalationCandidates(state, config, now) {
|
|
|
124
119
|
state: worker.state,
|
|
125
120
|
liveness: worker.liveness,
|
|
126
121
|
elapsed_ms: elapsed,
|
|
127
|
-
ambiguous: true,
|
|
128
122
|
context: baseContext,
|
|
129
123
|
});
|
|
130
124
|
}
|
|
@@ -141,7 +135,6 @@ export function findSupervisorEscalationCandidates(state, config, now) {
|
|
|
141
135
|
state: worker.state,
|
|
142
136
|
liveness: worker.liveness,
|
|
143
137
|
elapsed_ms: elapsed,
|
|
144
|
-
ambiguous: true,
|
|
145
138
|
context: baseContext,
|
|
146
139
|
});
|
|
147
140
|
}
|
|
@@ -160,7 +153,6 @@ export function findSupervisorEscalationCandidates(state, config, now) {
|
|
|
160
153
|
state: null,
|
|
161
154
|
liveness: null,
|
|
162
155
|
elapsed_ms: Math.max(0, now - deadlineMs),
|
|
163
|
-
ambiguous: false,
|
|
164
156
|
context: {
|
|
165
157
|
run_id: state.run_id,
|
|
166
158
|
deadline_at: state.global_deadline_at,
|
|
@@ -176,23 +168,20 @@ function cooldownWindowFor(now, cooldownMs) {
|
|
|
176
168
|
return String(Math.floor(now / width));
|
|
177
169
|
}
|
|
178
170
|
/**
|
|
179
|
-
* Decide whether a candidate should be emitted now. A candidate is
|
|
171
|
+
* Decide whether a candidate should be emitted now. A candidate is a DUPLICATE
|
|
180
172
|
* when an escalation for the same worker + reason was already emitted (or hit a
|
|
181
173
|
* duplicate) within the SAME cooldown window. Returns the cooldown window
|
|
182
174
|
* identifier the caller must pass to `makeSupervisorIdempotencyKey` so the
|
|
183
|
-
* idempotency key and the
|
|
175
|
+
* idempotency key and the duplicate check agree.
|
|
184
176
|
*/
|
|
185
177
|
export function shouldEmitEscalation(state, candidate, config, now) {
|
|
186
178
|
const cooldownWindow = cooldownWindowFor(now, config.escalation_cooldown_ms);
|
|
187
179
|
const alreadyDecided = state.escalations.some((record) => record.reason === candidate.reason &&
|
|
188
180
|
(record.worker_id ?? null) === (candidate.worker_id ?? null) &&
|
|
189
181
|
record.cooldown_window === cooldownWindow &&
|
|
190
|
-
// A prior
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
(record.outcome === "emitted" ||
|
|
194
|
-
record.outcome === "duplicate" ||
|
|
195
|
-
record.outcome === "suppressed"));
|
|
182
|
+
// A prior emitted/duplicate decision in this window is binding — do not
|
|
183
|
+
// re-decide until the window rolls over.
|
|
184
|
+
(record.outcome === "emitted" || record.outcome === "duplicate"));
|
|
196
185
|
return { emit: !alreadyDecided, cooldown_window: cooldownWindow };
|
|
197
186
|
}
|
|
198
187
|
/**
|
|
@@ -227,18 +216,13 @@ function formatElapsed(ms) {
|
|
|
227
216
|
}
|
|
228
217
|
/**
|
|
229
218
|
* Render a concise, SECRET-FREE terminal escalation line. Includes run id,
|
|
230
|
-
* worker id, reason, state, liveness, elapsed time
|
|
231
|
-
*
|
|
219
|
+
* worker id, reason, state, liveness, and elapsed time. Never includes raw
|
|
220
|
+
* payloads or full JSON dumps.
|
|
232
221
|
*/
|
|
233
|
-
export function formatEscalationForTerminal(runId, candidate
|
|
222
|
+
export function formatEscalationForTerminal(runId, candidate) {
|
|
234
223
|
const worker = candidate.worker_id ? ` worker=${candidate.worker_id}` : "";
|
|
235
224
|
const stateBit = candidate.state ? ` state=${candidate.state}` : "";
|
|
236
225
|
const liveBit = candidate.liveness ? ` liveness=${candidate.liveness}` : "";
|
|
237
226
|
const elapsed = ` elapsed=${formatElapsed(candidate.elapsed_ms)}`;
|
|
238
|
-
|
|
239
|
-
if (draftText && draftText.trim().length > 0) {
|
|
240
|
-
// Single-line the drafted text so the terminal stays scannable.
|
|
241
|
-
line += ` :: ${draftText.replace(/\s+/g, " ").trim()}`;
|
|
242
|
-
}
|
|
243
|
-
return line;
|
|
227
|
+
return `[supervisor] run=${runId}${worker} reason=${candidate.reason}${stateBit}${liveBit}${elapsed}`;
|
|
244
228
|
}
|
|
@@ -6,9 +6,8 @@
|
|
|
6
6
|
* concurrent retries, each event carries a deterministic id derived from a
|
|
7
7
|
* STABLE idempotency key. The key is built only from normalized dimensions
|
|
8
8
|
* (run id, optional worker id, reason, kind, cooldown window) — it deliberately
|
|
9
|
-
* EXCLUDES raw event payloads
|
|
10
|
-
*
|
|
11
|
-
* constraint instead of double-emitting.
|
|
9
|
+
* EXCLUDES raw event payloads, so two assessments for the same decision
|
|
10
|
+
* collide on the `events.id` UNIQUE constraint instead of double-emitting.
|
|
12
11
|
*
|
|
13
12
|
* This module performs NO privileged action: it only writes an audit event to
|
|
14
13
|
* the local ledger.
|
|
@@ -22,7 +21,7 @@ function normalizeDimension(value) {
|
|
|
22
21
|
/**
|
|
23
22
|
* Build a stable idempotency key from normalized dimensions. Whitespace and
|
|
24
23
|
* case are normalized; empty/undefined `worker_id` collapses to a fixed
|
|
25
|
-
* run-level token. NO raw payload
|
|
24
|
+
* run-level token. NO raw payload ever enters the key.
|
|
26
25
|
*/
|
|
27
26
|
export function makeSupervisorIdempotencyKey(meta) {
|
|
28
27
|
const parts = [
|
|
@@ -75,8 +74,7 @@ export async function emitSupervisorAssessmentIfNew(input, deps = {}) {
|
|
|
75
74
|
const emitEvent = deps.emitEvent ?? emitConductorEvent;
|
|
76
75
|
const idempotencyKey = makeSupervisorIdempotencyKey(input.idempotency);
|
|
77
76
|
const eventId = makeSupervisorAssessmentEventId(idempotencyKey);
|
|
78
|
-
// Build secret-free assessment details
|
|
79
|
-
// classification/decision and optional short drafted text — never raw payload.
|
|
77
|
+
// Build secret-free deterministic assessment details — never raw payload.
|
|
80
78
|
const details = {
|
|
81
79
|
...(input.details ?? {}),
|
|
82
80
|
idempotency_key: idempotencyKey,
|
|
@@ -85,12 +83,7 @@ export async function emitSupervisorAssessmentIfNew(input, deps = {}) {
|
|
|
85
83
|
cooldown_window: input.idempotency.cooldown_window,
|
|
86
84
|
classification: input.assessment.classification,
|
|
87
85
|
confidence: input.assessment.confidence,
|
|
88
|
-
should_escalate: input.assessment.should_escalate,
|
|
89
|
-
assessment_source: input.assessment.source,
|
|
90
86
|
};
|
|
91
|
-
if (input.assessment.draft_escalation_text) {
|
|
92
|
-
details.draft_escalation_text = input.assessment.draft_escalation_text;
|
|
93
|
-
}
|
|
94
87
|
const event = {
|
|
95
88
|
id: eventId,
|
|
96
89
|
source: "conductor-supervisor",
|
|
@@ -101,7 +94,7 @@ export async function emitSupervisorAssessmentIfNew(input, deps = {}) {
|
|
|
101
94
|
observed_via: "supervisor",
|
|
102
95
|
data: {
|
|
103
96
|
summary: `supervisor assessment: ${input.idempotency.reason}`,
|
|
104
|
-
status:
|
|
97
|
+
status: "escalated",
|
|
105
98
|
reason: input.idempotency.reason,
|
|
106
99
|
details,
|
|
107
100
|
},
|
|
@@ -28,11 +28,8 @@ export function buildSupervisorEscalationWorkerMessage(candidate, assessment, st
|
|
|
28
28
|
state: candidate.state,
|
|
29
29
|
liveness: candidate.liveness,
|
|
30
30
|
elapsed_ms: candidate.elapsed_ms,
|
|
31
|
-
assessment_source:
|
|
31
|
+
assessment_source: "deterministic",
|
|
32
32
|
};
|
|
33
|
-
if (assessment.draft_escalation_text) {
|
|
34
|
-
details.draft_escalation_text = assessment.draft_escalation_text;
|
|
35
|
-
}
|
|
36
33
|
return {
|
|
37
34
|
run_id: state.run_id,
|
|
38
35
|
worker_id: candidate.worker_id,
|
|
@@ -40,7 +37,7 @@ export function buildSupervisorEscalationWorkerMessage(candidate, assessment, st
|
|
|
40
37
|
cause_seq: state.last_seq,
|
|
41
38
|
payload: {
|
|
42
39
|
summary: `supervisor escalation: ${candidate.reason}`,
|
|
43
|
-
status:
|
|
40
|
+
status: "escalated",
|
|
44
41
|
details,
|
|
45
42
|
},
|
|
46
43
|
source: "conductor-supervisor",
|
|
@@ -23,7 +23,7 @@ export async function dispatchSupervisorNotification(epicRunId, candidate, asses
|
|
|
23
23
|
worker_id: candidate.worker_id ?? null,
|
|
24
24
|
elapsed_ms: candidate.elapsed_ms,
|
|
25
25
|
ticket_key: (candidate.context?.ticket_key ?? null),
|
|
26
|
-
|
|
26
|
+
classification: assessment.classification,
|
|
27
27
|
},
|
|
28
28
|
};
|
|
29
29
|
const headers = {
|
|
@@ -12,13 +12,10 @@
|
|
|
12
12
|
* 2. supervisor_projection — the resumable operational mirror (this loop owns
|
|
13
13
|
* writes; crash/restart re-hydrates from it).
|
|
14
14
|
* 3. supervisor.assessment events — AUDIT ONLY; never override raw truth.
|
|
15
|
-
* 4. LLM judgment context — DISPOSABLE; advisory text only, never persisted as
|
|
16
|
-
* truth and never able to execute an action.
|
|
17
15
|
*
|
|
18
|
-
* The deterministic loop owns correctness
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* fails.
|
|
16
|
+
* The deterministic loop owns correctness and always escalates a detected
|
|
17
|
+
* candidate (BAPI-496): there is no LLM judgment boundary and no suppression
|
|
18
|
+
* path.
|
|
22
19
|
*/
|
|
23
20
|
import { waitForConductorEvent, getSupervisorSnapshot, upsertSupervisorProjection, sendWorkerMessage, } from "./store.js";
|
|
24
21
|
import { emitSupervisorAssessmentIfNew } from "./supervisor-ledger.js";
|
|
@@ -26,36 +23,18 @@ import { sendSupervisorEscalationWorkerMessageIfNew } from "./supervisor-message
|
|
|
26
23
|
import { resolveSupervisorConfig } from "./supervisor-config.js";
|
|
27
24
|
import { hydrateSupervisorRunStateFromSnapshot, applyConductorEventToSupervisorState, applySupervisorHousekeeping, isSupervisorRunTerminal, hasSupervisorGlobalTimeoutElapsed, toSupervisorProjectionInput, } from "./supervisor-state.js";
|
|
28
25
|
import { findSupervisorEscalationCandidates, shouldEmitEscalation, recordEscalationResult, formatEscalationForTerminal, } from "./supervisor-escalation.js";
|
|
29
|
-
import { assessSupervisorCandidate } from "./supervisor-judgment.js";
|
|
30
26
|
import { makeSupervisorIdempotencyKey } from "./supervisor-ledger.js";
|
|
31
|
-
import { createDefaultSupervisorJudgmentClient } from "./supervisor-judgment-python.js";
|
|
32
27
|
import { resolveConductorBridgeApiAccess, } from "./bridge-api-client.js";
|
|
33
28
|
import { extractMergeActionIdentityFromGateEvent } from "./merge-ledger.js";
|
|
34
29
|
import { processGateMetMerge } from "./supervisor-merge.js";
|
|
35
30
|
import { dispatchSupervisorNotification } from "./supervisor-notification.js";
|
|
36
31
|
import { ConductorValidationError } from "./errors.js";
|
|
37
|
-
/**
|
|
38
|
-
function compactWorkerForJudgment(worker) {
|
|
39
|
-
if (!worker)
|
|
40
|
-
return null;
|
|
41
|
-
return {
|
|
42
|
-
worker_id: worker.worker_id,
|
|
43
|
-
ticket_key: worker.ticket_key,
|
|
44
|
-
state: worker.state,
|
|
45
|
-
liveness: worker.liveness,
|
|
46
|
-
last_event_time: worker.last_event_time,
|
|
47
|
-
last_progress_time: worker.last_progress_time,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
/** Deterministic (degraded) assessment for an unambiguous candidate. */
|
|
32
|
+
/** Deterministic assessment for an escalation candidate. */
|
|
51
33
|
function deterministicAssessment(candidate) {
|
|
52
34
|
return {
|
|
53
35
|
classification: "stuck",
|
|
54
36
|
confidence: 1,
|
|
55
|
-
should_escalate: true,
|
|
56
37
|
reason: candidate.reason,
|
|
57
|
-
draft_escalation_text: null,
|
|
58
|
-
source: "degraded",
|
|
59
38
|
};
|
|
60
39
|
}
|
|
61
40
|
/** Resolve the terminal run status from worker outcomes. */
|
|
@@ -64,20 +43,20 @@ function terminalStatus(state) {
|
|
|
64
43
|
return anyFailed ? "failed" : "complete";
|
|
65
44
|
}
|
|
66
45
|
/**
|
|
67
|
-
* Process the escalation pipeline for the current state: find candidates,
|
|
68
|
-
*
|
|
46
|
+
* Process the escalation pipeline for the current state: find candidates,
|
|
47
|
+
* deterministically assess them, emit idempotent assessment events, print
|
|
69
48
|
* terminal lines for newly-emitted escalations, and record outcomes. Returns
|
|
70
49
|
* nothing; it mutates `state`.
|
|
71
50
|
*/
|
|
72
|
-
async function processEscalations(state, config,
|
|
51
|
+
async function processEscalations(state, config, deps) {
|
|
73
52
|
const now = deps.now();
|
|
74
53
|
const candidates = findSupervisorEscalationCandidates(state, config, now);
|
|
75
54
|
for (const candidate of candidates) {
|
|
76
55
|
const decision = shouldEmitEscalation(state, candidate, config, now);
|
|
77
56
|
if (!decision.emit)
|
|
78
57
|
continue;
|
|
79
|
-
// Compute the idempotency key up front so
|
|
80
|
-
//
|
|
58
|
+
// Compute the idempotency key up front so the decision in this window can
|
|
59
|
+
// be recorded under it.
|
|
81
60
|
const idempotency = {
|
|
82
61
|
run_id: state.run_id,
|
|
83
62
|
worker_id: candidate.worker_id,
|
|
@@ -86,28 +65,8 @@ async function processEscalations(state, config, client, deps) {
|
|
|
86
65
|
cooldown_window: decision.cooldown_window,
|
|
87
66
|
};
|
|
88
67
|
const idempotencyKey = makeSupervisorIdempotencyKey(idempotency);
|
|
89
|
-
|
|
90
|
-
if (candidate.ambiguous) {
|
|
91
|
-
assessment = await assessSupervisorCandidate({
|
|
92
|
-
run_id: state.run_id,
|
|
93
|
-
candidate,
|
|
94
|
-
worker: candidate.worker_id ? compactWorkerForJudgment(state.workers[candidate.worker_id]) : null,
|
|
95
|
-
}, config, state.llm_budget, client);
|
|
96
|
-
}
|
|
97
|
-
else {
|
|
98
|
-
assessment = deterministicAssessment(candidate);
|
|
99
|
-
}
|
|
68
|
+
const assessment = deterministicAssessment(candidate);
|
|
100
69
|
state.latest_assessment = assessment;
|
|
101
|
-
if (!assessment.should_escalate) {
|
|
102
|
-
// The judgment declined to escalate. Record a `suppressed` decision for
|
|
103
|
-
// this cooldown window so the candidate is NOT re-judged every wake
|
|
104
|
-
// interval — without this, a persistently-ambiguous worker would re-query
|
|
105
|
-
// the LLM each tick, exhaust the per-run budget, and then escalate anyway
|
|
106
|
-
// once the degraded fallback kicks in. The decision stands until the
|
|
107
|
-
// window rolls over.
|
|
108
|
-
recordEscalationResult(state, candidate, decision.cooldown_window, idempotencyKey, "suppressed", now);
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
70
|
let outcome = "skipped";
|
|
112
71
|
try {
|
|
113
72
|
const result = await deps.emitAssessment({
|
|
@@ -145,7 +104,7 @@ async function processEscalations(state, config, client, deps) {
|
|
|
145
104
|
// Only print on a genuinely NEW escalation — never re-spam a duplicate after
|
|
146
105
|
// a crash/restart within the same cooldown window.
|
|
147
106
|
if (outcome === "emitted") {
|
|
148
|
-
deps.log(formatEscalationForTerminal(state.run_id, candidate
|
|
107
|
+
deps.log(formatEscalationForTerminal(state.run_id, candidate));
|
|
149
108
|
// S7: dispatch an out-of-band human notification for newly emitted escalations.
|
|
150
109
|
// Best-effort: a failure must never stop supervising.
|
|
151
110
|
if (deps.dispatchNotification) {
|
|
@@ -179,7 +138,6 @@ export async function runSupervisor(options, deps = {}) {
|
|
|
179
138
|
const upsertProjection = deps.upsertProjection ?? upsertSupervisorProjection;
|
|
180
139
|
const emitAssessment = deps.emitAssessment ?? emitSupervisorAssessmentIfNew;
|
|
181
140
|
const sendMessage = deps.sendWorkerMessage ?? sendWorkerMessage;
|
|
182
|
-
const judgmentClient = deps.judgmentClient ?? createDefaultSupervisorJudgmentClient(config);
|
|
183
141
|
const resolveBridgeAccess = deps.resolveBridgeAccess ?? (() => resolveConductorBridgeApiAccess());
|
|
184
142
|
const processMerge = deps.processMerge ?? processGateMetMerge;
|
|
185
143
|
const dispatchNotification = deps.dispatchNotification ?? dispatchSupervisorNotification;
|
|
@@ -304,7 +262,7 @@ export async function runSupervisor(options, deps = {}) {
|
|
|
304
262
|
// Housekeeping (liveness + state-specific stalls) runs every iteration so a
|
|
305
263
|
// timeout with no events still advances liveness/stall state.
|
|
306
264
|
applySupervisorHousekeeping(state, config, now());
|
|
307
|
-
await processEscalations(state, config,
|
|
265
|
+
await processEscalations(state, config, {
|
|
308
266
|
emitAssessment,
|
|
309
267
|
sendWorkerMessage: sendMessage,
|
|
310
268
|
log,
|
|
@@ -48,11 +48,6 @@ export function createEmptySupervisorRunState(runId, config, now) {
|
|
|
48
48
|
gates: {},
|
|
49
49
|
latest_assessment: null,
|
|
50
50
|
escalations: [],
|
|
51
|
-
llm_budget: {
|
|
52
|
-
enabled: config.llm_enabled,
|
|
53
|
-
max_calls: config.llm_max_calls,
|
|
54
|
-
used_calls: 0,
|
|
55
|
-
},
|
|
56
51
|
started_at: startedIso,
|
|
57
52
|
updated_at: startedIso,
|
|
58
53
|
global_deadline_at: msToIso(now + config.global_timeout_ms),
|
|
@@ -93,15 +88,8 @@ export function hydrateSupervisorRunStateFromSnapshot(snapshot, runId, config, n
|
|
|
93
88
|
escalations: Array.isArray(summary.escalations)
|
|
94
89
|
? summary.escalations
|
|
95
90
|
: [],
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
enabled: config.llm_enabled,
|
|
99
|
-
max_calls: config.llm_max_calls,
|
|
100
|
-
used_calls: typeof summary.llm_budget.used_calls === "number"
|
|
101
|
-
? summary.llm_budget.used_calls
|
|
102
|
-
: 0,
|
|
103
|
-
}
|
|
104
|
-
: empty.llm_budget,
|
|
91
|
+
// A legacy pre-BAPI-496 LLM-budget summary key is intentionally ignored
|
|
92
|
+
// rather than treated as an error — old projections still carry it.
|
|
105
93
|
started_at: typeof summary.started_at === "string" ? summary.started_at : empty.started_at,
|
|
106
94
|
global_deadline_at: typeof summary.global_deadline_at === "string" ? summary.global_deadline_at : empty.global_deadline_at,
|
|
107
95
|
roster_discovered: summary.roster_discovered === true,
|
|
@@ -536,11 +524,10 @@ function compactWorker(worker) {
|
|
|
536
524
|
* holds compact worker summaries (never raw event data); `gates` holds gate
|
|
537
525
|
* metadata; `assessment` holds the latest assessment or null; and `summary`
|
|
538
526
|
* holds the FULL resumable supervisor state (worker watchdog states, liveness,
|
|
539
|
-
* last seq, escalation history,
|
|
527
|
+
* last seq, escalation history, run start/deadline metadata).
|
|
540
528
|
*
|
|
541
529
|
* The serialized summary is secret-free by construction: it is built only from
|
|
542
|
-
* the compact state fields above — never from raw payloads
|
|
543
|
-
* prompts.
|
|
530
|
+
* the compact state fields above — never from raw payloads or secrets.
|
|
544
531
|
*/
|
|
545
532
|
export function toSupervisorProjectionInput(state) {
|
|
546
533
|
const summary = {
|
|
@@ -553,7 +540,6 @@ export function toSupervisorProjectionInput(state) {
|
|
|
553
540
|
gates: state.gates,
|
|
554
541
|
latest_assessment: state.latest_assessment,
|
|
555
542
|
escalations: state.escalations,
|
|
556
|
-
llm_budget: state.llm_budget,
|
|
557
543
|
started_at: state.started_at,
|
|
558
544
|
updated_at: state.updated_at,
|
|
559
545
|
global_deadline_at: state.global_deadline_at,
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* (BAPI-396, conductor C4).
|
|
4
4
|
*
|
|
5
5
|
* These are pure TypeScript type declarations with NO runtime behavior so every
|
|
6
|
-
* supervisor module (config, reducer, escalation,
|
|
7
|
-
*
|
|
6
|
+
* supervisor module (config, reducer, escalation, runtime) can depend on them
|
|
7
|
+
* freely without import cycles or side effects.
|
|
8
8
|
*
|
|
9
9
|
* Vocabulary split — the supervisor tracks two ORTHOGONAL axes per worker:
|
|
10
10
|
* - {@link WatchdogState}: the worker's progress through the run lifecycle
|