@bridge_gpt/mcp-server 0.2.2 → 0.2.3
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 +97 -15
- package/build/agent-config-credential-migration.js +272 -0
- package/build/agents.generated.js +1 -1
- package/build/chain-orchestrator.js +16 -1
- package/build/commands.generated.js +9 -7
- package/build/conductor/bridge-api-client.js +625 -0
- package/build/conductor/claude-hook.js +251 -0
- package/build/conductor/cli.js +1048 -0
- package/build/conductor/data-normalization.js +114 -0
- package/build/conductor/doctor.js +164 -0
- package/build/conductor/done-gate.js +325 -0
- package/build/conductor/epic-reconcile.js +139 -0
- package/build/conductor/epic-runtime.js +611 -0
- package/build/conductor/epic-state.js +125 -0
- package/build/conductor/errors.js +85 -0
- package/build/conductor/git-ci-types.js +129 -0
- package/build/conductor/git-hooks.js +218 -0
- package/build/conductor/git-inspection.js +185 -0
- package/build/conductor/git-producer.js +137 -0
- package/build/conductor/merge-ledger.js +198 -0
- package/build/conductor/paths.js +224 -0
- package/build/conductor/plan.js +77 -0
- package/build/conductor/pr-ci-producer.js +427 -0
- package/build/conductor/pr-discovery.js +135 -0
- package/build/conductor/producer-ledger.js +125 -0
- package/build/conductor/redaction.js +112 -0
- package/build/conductor/store.js +1156 -0
- package/build/conductor/supervisor-config.js +150 -0
- package/build/conductor/supervisor-escalation.js +244 -0
- package/build/conductor/supervisor-judgment-python.js +141 -0
- package/build/conductor/supervisor-judgment.js +215 -0
- package/build/conductor/supervisor-ledger.js +119 -0
- package/build/conductor/supervisor-merge.js +127 -0
- package/build/conductor/supervisor-message-relay.js +61 -0
- package/build/conductor/supervisor-notification.js +39 -0
- package/build/conductor/supervisor-runtime.js +351 -0
- package/build/conductor/supervisor-state.js +572 -0
- package/build/conductor/supervisor-types.js +16 -0
- package/build/conductor/taxonomy.js +58 -0
- package/build/conductor/tools.js +367 -0
- package/build/conductor/types.js +9 -0
- package/build/conductor-bin.js +21 -0
- package/build/conductor-claude-hook-bin.js +21 -0
- package/build/credential-store.js +175 -4
- package/build/credentials-cli.js +223 -0
- package/build/decision-page-schema.js +60 -0
- package/build/decision-page-template.js +262 -10
- package/build/doctor.js +5 -1
- package/build/index.js +468 -59
- package/build/pipeline-orchestrator.js +5 -1
- package/build/pipeline-utils.js +45 -5
- package/build/pipelines.generated.js +37 -9
- package/build/readme.generated.js +1 -1
- package/build/review-tickets.js +596 -0
- package/build/scheduled-prompt.js +16 -10
- package/build/start-tickets-conductor.js +496 -0
- package/build/start-tickets-prereqs.js +32 -23
- package/build/start-tickets-repo.js +49 -0
- package/build/start-tickets.js +682 -81
- package/build/version.generated.js +1 -1
- package/design-assets/favicon/android-chrome-192x192.png +0 -0
- package/design-assets/favicon/android-chrome-512x512.png +0 -0
- package/design-assets/favicon/apple-touch-icon.png +0 -0
- package/design-assets/favicon/favicon-16x16.png +0 -0
- package/design-assets/favicon/favicon-32x32.png +0 -0
- package/design-assets/favicon/favicon.ico +0 -0
- package/design-assets/favicon/site.webmanifest +1 -0
- package/design-assets/just-logo-rough-draft.png +0 -0
- package/package.json +17 -5
- package/pipelines/idea-to-ticket.json +5 -0
- package/pipelines/plan-epic.json +16 -1
- package/pipelines/review-ticket.json +2 -1
- package/public/css/main.min.css +2 -0
- package/public/css/main.min.css.map +1 -0
- package/public/fonts/OFL.txt +93 -0
- package/public/fonts/SourceSansPro-Black.ttf +0 -0
- package/public/fonts/SourceSansPro-BlackItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Bold.ttf +0 -0
- package/public/fonts/SourceSansPro-BoldItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-ExtraLight.ttf +0 -0
- package/public/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Italic.ttf +0 -0
- package/public/fonts/SourceSansPro-Light.ttf +0 -0
- package/public/fonts/SourceSansPro-LightItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Regular.ttf +0 -0
- package/public/fonts/SourceSansPro-SemiBold.ttf +0 -0
- package/public/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
- package/public/img/bridge-logo-160x51.webp +0 -0
- package/public/img/bridge-logo-300x92.webp +0 -0
- package/public/img/favicon/android-chrome-192x192.png +0 -0
- package/public/img/favicon/android-chrome-512x512.png +0 -0
- package/public/img/favicon/apple-touch-icon.png +0 -0
- package/public/img/favicon/favicon-16x16.png +0 -0
- package/public/img/favicon/favicon-32x32.png +0 -0
- package/public/img/favicon/favicon.ico +0 -0
- package/public/img/favicon/site.webmanifest +1 -0
- package/public/img/installation/bitbucket/app-password-1.png +0 -0
- package/public/img/installation/bitbucket/app-password-2.png +0 -0
- package/public/img/installation/bitbucket/create-token-1.png +0 -0
- package/public/img/installation/bitbucket/create-token-2.png +0 -0
- package/public/img/installation/bitbucket/webhook-1.png +0 -0
- package/public/img/installation/github/github-review-webhook.png +0 -0
- package/public/img/installation/jira/credentials/api-key.png +0 -0
- package/public/img/installation/jira/webhook/create-rule.png +0 -0
- package/public/img/installation/jira/webhook/project-settings.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-1.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-2.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-3.png +0 -0
- package/public/img/installation/pinecone/pinecone-api-key.png +0 -0
- package/public/img/installation/pinecone/pinecone-index.png +0 -0
- package/public/js/main.min.js +2 -0
- package/public/js/main.min.js.map +1 -0
- package/smoke-test/SMOKE-TEST.md +16 -8
package/build/start-tickets.js
CHANGED
|
@@ -58,8 +58,8 @@ import { readFile, writeFile, mkdir, stat } from "fs/promises";
|
|
|
58
58
|
import os from "node:os";
|
|
59
59
|
import path from "path";
|
|
60
60
|
import { VERSION } from "./version.generated.js";
|
|
61
|
-
import { resolveBapiCredentials } from "./credential-store.js";
|
|
62
|
-
import {
|
|
61
|
+
import { resolveBapiCredentials, getPrimaryCredentialStorePath, } from "./credential-store.js";
|
|
62
|
+
import { resolveStartTicketsRepoName as resolveSharedStartTicketsRepoName, resolveRequiredStartTicketsRepoName, } from "./start-tickets-repo.js";
|
|
63
63
|
import { provisionMcpRegistrationsForCreatedWorktrees, } from "./mcp-provisioning.js";
|
|
64
64
|
// Per-OS prerequisite knowledge + low-level command probes live in the shared
|
|
65
65
|
// prereqs module so `runPreflight` (enforce) and the read-only `doctor` (render)
|
|
@@ -67,6 +67,8 @@ import { provisionMcpRegistrationsForCreatedWorktrees, } from "./mcp-provisionin
|
|
|
67
67
|
// module imports only TYPES back, so the runtime graph stays acyclic.
|
|
68
68
|
import { WORKTRUNK_BINARY_OVERRIDE_ENV, WINDOWS_TERMINAL_COMMAND, WINDOWS_POWERSHELL_CANDIDATES, DEFAULT_WINDOWS_WORKTRUNK_BINARY, DEFAULT_POSIX_WORKTRUNK_BINARY, TMUX_COMMAND, GIT_FOR_WINDOWS_BASH_HINT, isSupportedStartTicketsPlatform, unsupportedPlatformMessage, resolveWorktrunkBinary, commandSucceeded, getCommandProbe, isCommandOnPath, resolveFirstCommandOnPath, enforcePreflightPrerequisites, appendDoctorHint, } from "./start-tickets-prereqs.js";
|
|
69
69
|
import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, resolveModelAlias, isValidModelAlias, isModelTier, } from "./agent-registry.js";
|
|
70
|
+
import { createStartTicketsConductorContext, provisionConductorHooksForRows, emitStartTicketsRunStarted, injectConductorEnvIntoShellCommand, buildSupervisorTabCommand, isSupervisorLaunchEnabled, supervisorSpawnKey, } from "./start-tickets-conductor.js";
|
|
71
|
+
import { transitionEpicDispatch, resolveConductorBridgeApiAccess, } from "./conductor/bridge-api-client.js";
|
|
70
72
|
// Re-export the shared prereq surface (constants, platform helpers, command
|
|
71
73
|
// probes) so existing import sites that read them from "./start-tickets.js"
|
|
72
74
|
// keep working unchanged.
|
|
@@ -108,6 +110,16 @@ export function getStartTicketsUsage() {
|
|
|
108
110
|
"Environment:",
|
|
109
111
|
` ${WORKTRUNK_BINARY_OVERRIDE_ENV} Override the Worktrunk executable name/path for nonstandard installs`,
|
|
110
112
|
` ${TMUX_SESSION_OVERRIDE_ENV} Override the tmux session-name prefix on Linux (default: ${DEFAULT_TMUX_SESSION_PREFIX})`,
|
|
113
|
+
" BAPI_CONDUCTOR_GATE_NAME Conductor gate name for this run (default: implement-ticket)",
|
|
114
|
+
" BAPI_CONDUCTOR_SUPERVISOR_MODE Conductor supervisor mode (default: auto when --auto, else interactive)",
|
|
115
|
+
" BAPI_CONDUCTOR_ENABLE_PRE_TOOL_USE Set 1/true to also register a PreToolUse conductor hook",
|
|
116
|
+
"",
|
|
117
|
+
"Conductor observability:",
|
|
118
|
+
" Real Claude Code workers launched by start-tickets receive per-worktree conductor",
|
|
119
|
+
" hook injection (into .claude/settings.local.json) and emit local lifecycle events",
|
|
120
|
+
" into the conductor ledger. Each real run mints one run_id and attributes worker",
|
|
121
|
+
" events by worker_id, ticket key, and worktree path. Inspect the ledger with the",
|
|
122
|
+
" `conductor` CLI.",
|
|
111
123
|
"",
|
|
112
124
|
"Prerequisites:",
|
|
113
125
|
" macOS wt, git, osascript",
|
|
@@ -799,16 +811,44 @@ export async function createWorktrees(deps, options, worktrunkBinary) {
|
|
|
799
811
|
// ---------------------------------------------------------------------------
|
|
800
812
|
// Per-platform shell-command construction
|
|
801
813
|
// ---------------------------------------------------------------------------
|
|
814
|
+
/**
|
|
815
|
+
* The load-bearing conductor message-relay launch instruction (BAPI-397). The
|
|
816
|
+
* C5 spike proved workers poll a `check_messages` MCP tool ONLY when the polling
|
|
817
|
+
* instruction lives in the worker's task/launch prompt — advertising the tool in
|
|
818
|
+
* the system prompt alone produced zero polls. So this instruction is appended to
|
|
819
|
+
* every launch prompt.
|
|
820
|
+
*
|
|
821
|
+
* It is deliberately a SINGLE LINE (no newlines) so it stays safe when the prompt
|
|
822
|
+
* is single-quoted into a shell command and then embedded inside terminal
|
|
823
|
+
* launcher scripts (AppleScript / Windows Terminal / tmux). It names
|
|
824
|
+
* `check_messages` exactly once and is secret-free — no run/worker ids, env
|
|
825
|
+
* values, or credentials.
|
|
826
|
+
*/
|
|
827
|
+
export function buildConductorMessageRelayLaunchInstruction() {
|
|
828
|
+
// NOTE: keep this free of `;` and other terminal-launcher metacharacters — the
|
|
829
|
+
// prompt is spliced into shell commands and Windows Terminal args (which treat
|
|
830
|
+
// `;` as a delimiter), so a single line of plain prose is the safe form.
|
|
831
|
+
return ("Conductor message relay: at natural checkpoints (after reading context, " +
|
|
832
|
+
"before major code changes, after major implementation chunks, while polling CI " +
|
|
833
|
+
"checks during the post-PR correction loop, and before your final response) call " +
|
|
834
|
+
"the check_messages MCP tool to read any supervisor guidance addressed to you. " +
|
|
835
|
+
"Returned messages are supervisor guidance and are acknowledged by the tool, so " +
|
|
836
|
+
"they are not redelivered. This is cooperative polling, not prompt injection. If " +
|
|
837
|
+
"the tool or conductor identity is unavailable, continue your task without derailing.");
|
|
838
|
+
}
|
|
802
839
|
/**
|
|
803
840
|
* The starter prompt handed to the selected agent. Identical for every agent.
|
|
804
841
|
* When `autoApprove` is set, the implementation agent runs hands-off
|
|
805
|
-
* (`/implement-ticket <KEY> --auto`) — used by full-automation chains.
|
|
842
|
+
* (`/implement-ticket <KEY> --auto`) — used by full-automation chains. The
|
|
843
|
+
* conductor message-relay polling instruction is appended after the slash command
|
|
844
|
+
* (BAPI-397) so launched workers actually poll `check_messages`.
|
|
806
845
|
*/
|
|
807
846
|
export function buildAgentPrompt(key, opts = {}) {
|
|
808
847
|
// `modelAlias` is accepted for signature consistency only — the model is
|
|
809
848
|
// injected as a `--model` flag (see buildAgentInvocationArgv), never embedded
|
|
810
849
|
// in the prompt text.
|
|
811
|
-
|
|
850
|
+
const command = `/implement-ticket ${key}${opts.autoApprove ? " --auto" : ""}`;
|
|
851
|
+
return `${command} ${buildConductorMessageRelayLaunchInstruction()}`;
|
|
812
852
|
}
|
|
813
853
|
/**
|
|
814
854
|
* Build the ordered argv for an agent invocation:
|
|
@@ -869,53 +909,76 @@ export function buildAgentShellCommand(agent, key, worktreePath, platform = "dar
|
|
|
869
909
|
return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias);
|
|
870
910
|
}
|
|
871
911
|
// ---------------------------------------------------------------------------
|
|
912
|
+
// Spawned-terminal titling (shared across platforms)
|
|
913
|
+
// ---------------------------------------------------------------------------
|
|
914
|
+
/**
|
|
915
|
+
* Human-facing title applied to each spawned terminal so users can tell which
|
|
916
|
+
* ticket a tab/window/session is implementing — e.g. `BAPI-200 Implementation`.
|
|
917
|
+
* `key` is the full ticket key (already `PROJ-NUM`).
|
|
918
|
+
*/
|
|
919
|
+
export function terminalTitleForTicket(key) {
|
|
920
|
+
return `${key} Implementation`;
|
|
921
|
+
}
|
|
922
|
+
// ---------------------------------------------------------------------------
|
|
872
923
|
// macOS terminal spawning (behind the injected boundary)
|
|
873
924
|
// ---------------------------------------------------------------------------
|
|
874
|
-
/**
|
|
875
|
-
|
|
925
|
+
/**
|
|
926
|
+
* Generate AppleScript that runs `shellCommand` in a Terminal.app tab and sets
|
|
927
|
+
* its custom title. `do script` returns the spawned tab in both the new-window
|
|
928
|
+
* and new-tab paths, so we capture it and set `custom title` on it.
|
|
929
|
+
*/
|
|
930
|
+
export function buildTerminalAppleScript(shellCommand, title) {
|
|
876
931
|
const esc = applescriptDquoteInner(shellCommand);
|
|
932
|
+
const titleEsc = applescriptDquoteInner(title);
|
|
877
933
|
return [
|
|
878
934
|
'tell application "Terminal"',
|
|
879
935
|
" activate",
|
|
880
936
|
" if (count of windows) is 0 then",
|
|
881
|
-
` do script "${esc}"`,
|
|
937
|
+
` set spawnedTab to do script "${esc}"`,
|
|
882
938
|
" else",
|
|
883
939
|
' tell application "System Events" to keystroke "t" using command down',
|
|
884
940
|
" delay 0.2",
|
|
885
|
-
` do script "${esc}" in selected tab of front window`,
|
|
941
|
+
` set spawnedTab to do script "${esc}" in selected tab of front window`,
|
|
886
942
|
" end if",
|
|
943
|
+
` set custom title of spawnedTab to "${titleEsc}"`,
|
|
887
944
|
"end tell",
|
|
888
945
|
].join("\n");
|
|
889
946
|
}
|
|
890
|
-
/**
|
|
891
|
-
|
|
947
|
+
/**
|
|
948
|
+
* Generate AppleScript that runs `shellCommand` in an iTerm2 tab. We set the
|
|
949
|
+
* session `name` (which drives the tab title) so the label sticks rather than
|
|
950
|
+
* being overwritten by the running agent's program title.
|
|
951
|
+
*/
|
|
952
|
+
export function buildITermAppleScript(shellCommand, title) {
|
|
892
953
|
const esc = applescriptDquoteInner(shellCommand);
|
|
954
|
+
const titleEsc = applescriptDquoteInner(title);
|
|
893
955
|
return [
|
|
894
956
|
'tell application "iTerm"',
|
|
895
957
|
" activate",
|
|
896
958
|
" if (count of windows) = 0 then",
|
|
897
|
-
" set
|
|
898
|
-
` tell current session of newWindow to write text "${esc}"`,
|
|
959
|
+
" set spawnedSession to current session of (create window with default profile)",
|
|
899
960
|
" else",
|
|
900
|
-
" tell current window",
|
|
901
|
-
" set newTab to (create tab with default profile)",
|
|
902
|
-
` tell current session of newTab to write text "${esc}"`,
|
|
903
|
-
" end tell",
|
|
961
|
+
" tell current window to set spawnedSession to (current session of (create tab with default profile))",
|
|
904
962
|
" end if",
|
|
963
|
+
" tell spawnedSession",
|
|
964
|
+
` set name to "${titleEsc}"`,
|
|
965
|
+
` write text "${esc}"`,
|
|
966
|
+
" end tell",
|
|
905
967
|
"end tell",
|
|
906
968
|
].join("\n");
|
|
907
969
|
}
|
|
908
970
|
/**
|
|
909
971
|
* Spawn a single macOS terminal tab running `shellCommand`. Selects the
|
|
910
972
|
* AppleScript builder by terminal choice and runs it via `osascript -e`. The
|
|
911
|
-
*
|
|
912
|
-
*
|
|
913
|
-
* (never throws).
|
|
973
|
+
* `context.key` is used to title the tab `<KEY> Implementation`; a missing
|
|
974
|
+
* context falls back to an untitled-but-functional tab. Returns a structured
|
|
975
|
+
* failure for expected spawn errors (never throws).
|
|
914
976
|
*/
|
|
915
|
-
export async function spawnMacOSTerminalTab(deps, terminal, shellCommand,
|
|
977
|
+
export async function spawnMacOSTerminalTab(deps, terminal, shellCommand, context) {
|
|
978
|
+
const title = terminalTitleForTicket(context?.key ?? "");
|
|
916
979
|
const script = terminal === "iterm"
|
|
917
|
-
? buildITermAppleScript(shellCommand)
|
|
918
|
-
: buildTerminalAppleScript(shellCommand);
|
|
980
|
+
? buildITermAppleScript(shellCommand, title)
|
|
981
|
+
: buildTerminalAppleScript(shellCommand, title);
|
|
919
982
|
const result = await deps.runCommand("osascript", ["-e", script]);
|
|
920
983
|
if (commandSucceeded(result))
|
|
921
984
|
return { ok: true };
|
|
@@ -940,9 +1003,23 @@ export async function spawnMacOSTerminalTab(deps, terminal, shellCommand, _conte
|
|
|
940
1003
|
* `Start-Process` fallback below does its own quoting via `-ArgumentList` and is
|
|
941
1004
|
* unaffected.)
|
|
942
1005
|
*/
|
|
943
|
-
export function buildWindowsTerminalArgs(worktreePath, shellCommand) {
|
|
1006
|
+
export function buildWindowsTerminalArgs(worktreePath, shellCommand, title) {
|
|
944
1007
|
const wtEscapedCommand = shellCommand.replace(/;/g, "\\;");
|
|
945
|
-
|
|
1008
|
+
// `--title` labels the tab `<KEY> Implementation`; `--suppressApplicationTitle`
|
|
1009
|
+
// stops the running agent from overwriting that title. Both are passed as
|
|
1010
|
+
// discrete argv items, so the space in the title needs no extra quoting.
|
|
1011
|
+
return [
|
|
1012
|
+
"new-tab",
|
|
1013
|
+
"--title",
|
|
1014
|
+
title,
|
|
1015
|
+
"--suppressApplicationTitle",
|
|
1016
|
+
"-d",
|
|
1017
|
+
worktreePath,
|
|
1018
|
+
"powershell.exe",
|
|
1019
|
+
"-NoExit",
|
|
1020
|
+
"-Command",
|
|
1021
|
+
wtEscapedCommand,
|
|
1022
|
+
];
|
|
946
1023
|
}
|
|
947
1024
|
/**
|
|
948
1025
|
* Build the PowerShell `Start-Process` command used as the no-Windows-Terminal
|
|
@@ -950,8 +1027,12 @@ export function buildWindowsTerminalArgs(worktreePath, shellCommand) {
|
|
|
950
1027
|
* `powershell.exe -Command` invoked through `execFile` cannot). Nested quoting
|
|
951
1028
|
* uses PowerShell single-quote escaping.
|
|
952
1029
|
*/
|
|
953
|
-
export function buildPowerShellFallbackStartProcessCommand(worktreePath, shellCommand) {
|
|
954
|
-
|
|
1030
|
+
export function buildPowerShellFallbackStartProcessCommand(worktreePath, shellCommand, title) {
|
|
1031
|
+
// Set the new window's title (`<KEY> Implementation`) before running the agent
|
|
1032
|
+
// so the user can tell the windows apart; `Start-Process` opens its own window
|
|
1033
|
+
// so we cannot use wt's `--title`.
|
|
1034
|
+
const titledCommand = `$host.UI.RawUI.WindowTitle = ${powershellSquote(title)}; ${shellCommand}`;
|
|
1035
|
+
const argumentList = `@('-NoExit', '-Command', ${powershellSquote(titledCommand)})`;
|
|
955
1036
|
return (`Start-Process -FilePath 'powershell.exe' ` +
|
|
956
1037
|
`-WorkingDirectory ${powershellSquote(worktreePath)} ` +
|
|
957
1038
|
`-ArgumentList ${argumentList}`);
|
|
@@ -970,8 +1051,9 @@ export async function spawnWindowsTerminalTab(deps, _terminal, shellCommand, con
|
|
|
970
1051
|
error: "Windows spawner requires a worktreePath context to open a tab.",
|
|
971
1052
|
};
|
|
972
1053
|
}
|
|
1054
|
+
const title = terminalTitleForTicket(context?.key ?? "");
|
|
973
1055
|
if (await isCommandOnPath(deps, WINDOWS_TERMINAL_COMMAND)) {
|
|
974
|
-
const args = buildWindowsTerminalArgs(worktreePath, shellCommand);
|
|
1056
|
+
const args = buildWindowsTerminalArgs(worktreePath, shellCommand, title);
|
|
975
1057
|
const result = await deps.runCommand(WINDOWS_TERMINAL_COMMAND, args);
|
|
976
1058
|
if (commandSucceeded(result))
|
|
977
1059
|
return { ok: true };
|
|
@@ -988,7 +1070,7 @@ export async function spawnWindowsTerminalTab(deps, _terminal, shellCommand, con
|
|
|
988
1070
|
error: "Windows Terminal (wt.exe) or PowerShell is required to open a tab, but neither was found on PATH.",
|
|
989
1071
|
};
|
|
990
1072
|
}
|
|
991
|
-
const fallback = buildPowerShellFallbackStartProcessCommand(worktreePath, shellCommand);
|
|
1073
|
+
const fallback = buildPowerShellFallbackStartProcessCommand(worktreePath, shellCommand, title);
|
|
992
1074
|
const result = await deps.runCommand(powershell, [
|
|
993
1075
|
"-NoProfile",
|
|
994
1076
|
"-ExecutionPolicy",
|
|
@@ -1015,9 +1097,15 @@ export function sanitizeTmuxName(value) {
|
|
|
1015
1097
|
.replace(/-+$/, "");
|
|
1016
1098
|
return cleaned.length > 0 ? cleaned : "ticket";
|
|
1017
1099
|
}
|
|
1018
|
-
/**
|
|
1100
|
+
/**
|
|
1101
|
+
* tmux WINDOW name shown to the user, e.g. `BAPI-200 Implementation`. The key
|
|
1102
|
+
* portion is sanitized (drops `/`, `:`, etc.) but the human-facing
|
|
1103
|
+
* ` Implementation` suffix keeps its space — tmux window names may contain
|
|
1104
|
+
* spaces. The SESSION name stays space-free (see `tmuxSessionNameForTicket`) so
|
|
1105
|
+
* `tmux attach -t <session>` remains easy to type.
|
|
1106
|
+
*/
|
|
1019
1107
|
export function tmuxWindowNameForTicket(key) {
|
|
1020
|
-
return sanitizeTmuxName(key);
|
|
1108
|
+
return terminalTitleForTicket(sanitizeTmuxName(key));
|
|
1021
1109
|
}
|
|
1022
1110
|
/** Resolve the tmux session-name prefix (env override, else the default). */
|
|
1023
1111
|
export function tmuxSessionPrefix(deps) {
|
|
@@ -1111,7 +1199,11 @@ export async function spawnTabsForCreatedWorktrees(deps, rows, terminal, buildSh
|
|
|
1111
1199
|
out.push(row);
|
|
1112
1200
|
continue;
|
|
1113
1201
|
}
|
|
1114
|
-
const
|
|
1202
|
+
const baseShellCommand = buildShellCommand(row.key, row.path, row.modelAlias ?? null);
|
|
1203
|
+
// Scope the row's conductor identity env to this terminal/tab/session only
|
|
1204
|
+
// (BAPI-394). No-op when the row carries no conductorEnv (dry-run, non-Claude
|
|
1205
|
+
// agents, or conductor disabled). Never mutates process/global env.
|
|
1206
|
+
const shellCommand = injectConductorEnvIntoShellCommand(deps.platform, baseShellCommand, row.conductorEnv);
|
|
1115
1207
|
const result = await deps.spawnTerminalTab(deps, terminal, shellCommand, {
|
|
1116
1208
|
key: row.key,
|
|
1117
1209
|
worktreePath: row.path,
|
|
@@ -1195,10 +1287,24 @@ export function buildDryRunDetailLines(agent, key, branch, platform = process.pl
|
|
|
1195
1287
|
*/
|
|
1196
1288
|
export function formatSummaryReport(rows) {
|
|
1197
1289
|
const lines = ["Summary:"];
|
|
1290
|
+
// Surface the single conductor run id near the top (first line stays
|
|
1291
|
+
// "Summary:" so existing parsers are unaffected). All spawnable rows carry the
|
|
1292
|
+
// same runId, so the first one present is canonical.
|
|
1293
|
+
const runId = rows.find((r) => r.runId)?.runId;
|
|
1294
|
+
if (runId) {
|
|
1295
|
+
lines.push(`run_id=${runId}`);
|
|
1296
|
+
}
|
|
1297
|
+
// BAPI-396: surface the supervisor peer-tab launch status once (run-level).
|
|
1298
|
+
const supervisorStatus = rows.find((r) => r.supervisorStatus)?.supervisorStatus;
|
|
1299
|
+
if (supervisorStatus) {
|
|
1300
|
+
lines.push(`supervisor=${supervisorStatus}`);
|
|
1301
|
+
}
|
|
1198
1302
|
for (const row of rows) {
|
|
1199
1303
|
let line = `${row.key} branch=${row.branch} status=${row.status}`;
|
|
1200
1304
|
if (row.path)
|
|
1201
1305
|
line += ` path=${row.path}`;
|
|
1306
|
+
if (row.workerId)
|
|
1307
|
+
line += ` worker_id=${row.workerId}`;
|
|
1202
1308
|
lines.push(line);
|
|
1203
1309
|
}
|
|
1204
1310
|
// Warnings section: create/spawn-failed row errors AND any non-fatal
|
|
@@ -1267,6 +1373,78 @@ export function buildMcpProvisioningDeps(deps) {
|
|
|
1267
1373
|
export async function materializeFileCredentialsForCreatedWorktrees(rows, _deps) {
|
|
1268
1374
|
return rows;
|
|
1269
1375
|
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Map a diagnostic kind to its observability policy. Expected setup gaps
|
|
1378
|
+
* (missing/unreadable/malformed credentials, unauthorized) and transient
|
|
1379
|
+
* degradation (network, config/tier unavailable, no-tier) are stderr-only so
|
|
1380
|
+
* routine setup friction never generates telemetry noise. Genuine server `5xx`
|
|
1381
|
+
* and unexpected internal HTTP failures may additionally be reported to Sentry.
|
|
1382
|
+
*/
|
|
1383
|
+
export function diagnosticReportingPolicyForKind(kind) {
|
|
1384
|
+
switch (kind) {
|
|
1385
|
+
case "server":
|
|
1386
|
+
case "http":
|
|
1387
|
+
return "stderr-and-sentry";
|
|
1388
|
+
default:
|
|
1389
|
+
return "stderr-only";
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
/** Build a secret-free diagnostic, deriving the reporting policy from the kind. */
|
|
1393
|
+
export function makeModelRoutingDiagnostic(fields) {
|
|
1394
|
+
return { ...fields, reportingPolicy: diagnosticReportingPolicyForKind(fields.kind) };
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Turn a diagnostic into exactly ONE actionable, stderr-safe line. Never
|
|
1398
|
+
* includes secrets, headers, tokens, or raw response bodies. Every degraded
|
|
1399
|
+
* state states that ticket spawning continues fail-open (assume a hard ticket,
|
|
1400
|
+
* default to premium/Opus when the agent supports it).
|
|
1401
|
+
*/
|
|
1402
|
+
export function formatModelRoutingDiagnosticLine(d) {
|
|
1403
|
+
const repo = d.repoName ?? "<unknown>";
|
|
1404
|
+
const target = d.storeTarget ?? (d.repoName ? `bapi:${d.repoName}` : "bapi:<repo>");
|
|
1405
|
+
const storePath = d.storePath ?? "~/.config/bridge/credentials.json";
|
|
1406
|
+
const failOpen = "Assuming hard ticket; defaulting to premium/Opus when available.";
|
|
1407
|
+
const ticketSuffix = d.ticketKey ? ` for ${d.ticketKey}` : "";
|
|
1408
|
+
const statusSuffix = typeof d.status === "number" ? ` (HTTP ${d.status})` : "";
|
|
1409
|
+
switch (d.kind) {
|
|
1410
|
+
case "repo-missing":
|
|
1411
|
+
return ("model routing skipped: could not resolve repo name (set BAPI_REPO_NAME or add a " +
|
|
1412
|
+
`valid .bridge/config). ${failOpen}`);
|
|
1413
|
+
case "credential-not-found":
|
|
1414
|
+
case "credential-missing-key":
|
|
1415
|
+
return (`model routing skipped: no BAPI_API_KEY for repo ${repo}. Run /install-bridge again, ` +
|
|
1416
|
+
`or add BAPI_API_KEY under ${target} in ${storePath}. ${failOpen}`);
|
|
1417
|
+
case "credential-read-error":
|
|
1418
|
+
return (`model routing skipped: could not read the credential store at ${storePath} for ${target}. ` +
|
|
1419
|
+
`Fix file permissions or rerun /install-bridge. ${failOpen}`);
|
|
1420
|
+
case "credential-parse-error":
|
|
1421
|
+
return (`model routing skipped: the credential store at ${storePath} is malformed (cannot parse ` +
|
|
1422
|
+
`${target}). Repair it or rerun /install-bridge. ${failOpen}`);
|
|
1423
|
+
case "unauthorized":
|
|
1424
|
+
return (`model routing degraded: a BAPI_API_KEY for ${target} was found but rejected by Bridge ` +
|
|
1425
|
+
`API${statusSuffix}. Rerun /install-bridge or rotate the stored ${target} key. ${failOpen}`);
|
|
1426
|
+
case "network":
|
|
1427
|
+
return ("model routing degraded: could not reach Bridge API (network error or timeout). Ticket " +
|
|
1428
|
+
`spawning continues fail-open. ${failOpen}`);
|
|
1429
|
+
case "server":
|
|
1430
|
+
return (`model routing degraded: Bridge API returned a server error${statusSuffix}. Ticket ` +
|
|
1431
|
+
`spawning continues fail-open. ${failOpen}`);
|
|
1432
|
+
case "http":
|
|
1433
|
+
return (`model routing degraded: unexpected Bridge API response${statusSuffix}. Ticket spawning ` +
|
|
1434
|
+
`continues fail-open. ${failOpen}`);
|
|
1435
|
+
case "config-unavailable":
|
|
1436
|
+
return ("model routing degraded: could not fetch routing config from Bridge API. Ticket spawning " +
|
|
1437
|
+
`continues fail-open. ${failOpen}`);
|
|
1438
|
+
case "tier-fetch-unavailable":
|
|
1439
|
+
return (`model routing degraded: could not fetch the model tier${ticketSuffix} from Bridge API. ` +
|
|
1440
|
+
`Ticket spawning continues fail-open. ${failOpen}`);
|
|
1441
|
+
case "no-tier":
|
|
1442
|
+
return (`model routing: Bridge API returned no usable tier${ticketSuffix}. Ticket spawning ` +
|
|
1443
|
+
`continues fail-open. ${failOpen}`);
|
|
1444
|
+
default:
|
|
1445
|
+
return `model routing degraded: ${d.message} ${failOpen}`;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1270
1448
|
// ---------------------------------------------------------------------------
|
|
1271
1449
|
// BAPI-365: difficulty-based model routing (fail-open at the spawn boundary)
|
|
1272
1450
|
// ---------------------------------------------------------------------------
|
|
@@ -1284,25 +1462,17 @@ function startTicketsGetHeaders(access) {
|
|
|
1284
1462
|
}
|
|
1285
1463
|
/**
|
|
1286
1464
|
* Resolve the repo name for routing: prefer `BAPI_REPO_NAME`, then `.bridge/config`.
|
|
1287
|
-
* Returns `null` when neither is available.
|
|
1465
|
+
* Returns `null` when neither is available. Thin wrapper over the shared
|
|
1466
|
+
* {@link resolveSharedStartTicketsRepoName} helper (see `start-tickets-repo.ts`)
|
|
1467
|
+
* so `start-tickets`, doctor, migration, and install persistence all share one
|
|
1468
|
+
* repo-identity implementation and the `bapi:<repo>` target can never drift.
|
|
1288
1469
|
*/
|
|
1289
1470
|
export async function resolveStartTicketsRepoName(deps) {
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
const result = await readBridgeConfig(deps.cwd, {
|
|
1296
|
-
readFile: (filePath) => readFile(filePath, "utf-8"),
|
|
1297
|
-
});
|
|
1298
|
-
if (result.ok && result.manifest.repoName) {
|
|
1299
|
-
return result.manifest.repoName;
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
catch {
|
|
1303
|
-
// fall through to null
|
|
1304
|
-
}
|
|
1305
|
-
return null;
|
|
1471
|
+
return resolveSharedStartTicketsRepoName({
|
|
1472
|
+
env: deps.env,
|
|
1473
|
+
cwd: deps.cwd,
|
|
1474
|
+
readFile: (filePath) => readFile(filePath, "utf-8"),
|
|
1475
|
+
});
|
|
1306
1476
|
}
|
|
1307
1477
|
/**
|
|
1308
1478
|
* Resolve Bridge API access (repo name + API key + base URL). Returns a
|
|
@@ -1310,13 +1480,26 @@ export async function resolveStartTicketsRepoName(deps) {
|
|
|
1310
1480
|
* caller can degrade to the agent default model.
|
|
1311
1481
|
*/
|
|
1312
1482
|
export async function resolveStartTicketsBridgeApiAccess(deps) {
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1483
|
+
const repoResult = await resolveRequiredStartTicketsRepoName({
|
|
1484
|
+
env: deps.env,
|
|
1485
|
+
cwd: deps.cwd,
|
|
1486
|
+
readFile: (p) => readFile(p, "utf-8"),
|
|
1487
|
+
});
|
|
1488
|
+
if (!repoResult.ok) {
|
|
1315
1489
|
return {
|
|
1316
1490
|
ok: false,
|
|
1317
1491
|
warning: "model routing: could not resolve repo name (set BAPI_REPO_NAME or .bridge/config); using agent default model",
|
|
1492
|
+
diagnostic: makeModelRoutingDiagnostic({
|
|
1493
|
+
kind: "repo-missing",
|
|
1494
|
+
message: "could not resolve repo name for model routing",
|
|
1495
|
+
}),
|
|
1318
1496
|
};
|
|
1319
1497
|
}
|
|
1498
|
+
const repoName = repoResult.repoName;
|
|
1499
|
+
// Logical store target + path for diagnostics and remediation text only — the
|
|
1500
|
+
// resolved key value is NEVER recorded in any diagnostic field.
|
|
1501
|
+
const storeTarget = `bapi:${repoName}`;
|
|
1502
|
+
const storePath = getPrimaryCredentialStorePath({ env: deps.env, homedir: os.homedir });
|
|
1320
1503
|
let credResult;
|
|
1321
1504
|
try {
|
|
1322
1505
|
credResult = await resolveBapiCredentials(repoName, {
|
|
@@ -1328,10 +1511,39 @@ export async function resolveStartTicketsBridgeApiAccess(deps) {
|
|
|
1328
1511
|
});
|
|
1329
1512
|
}
|
|
1330
1513
|
catch {
|
|
1331
|
-
return {
|
|
1514
|
+
return {
|
|
1515
|
+
ok: false,
|
|
1516
|
+
warning: "model routing: failed to resolve Bridge API credentials; using agent default model",
|
|
1517
|
+
diagnostic: makeModelRoutingDiagnostic({
|
|
1518
|
+
kind: "credential-read-error",
|
|
1519
|
+
repoName,
|
|
1520
|
+
storeTarget,
|
|
1521
|
+
storePath,
|
|
1522
|
+
message: "failed to resolve Bridge API credentials",
|
|
1523
|
+
}),
|
|
1524
|
+
};
|
|
1332
1525
|
}
|
|
1333
1526
|
if (!credResult.ok) {
|
|
1334
|
-
|
|
1527
|
+
// Preserve the resolver's distinct failure kinds rather than collapsing them
|
|
1528
|
+
// into one generic "unavailable" warning.
|
|
1529
|
+
const kind = credResult.kind === "not-found"
|
|
1530
|
+
? "credential-not-found"
|
|
1531
|
+
: credResult.kind === "read-error"
|
|
1532
|
+
? "credential-read-error"
|
|
1533
|
+
: credResult.kind === "parse-error"
|
|
1534
|
+
? "credential-parse-error"
|
|
1535
|
+
: "credential-missing-key";
|
|
1536
|
+
return {
|
|
1537
|
+
ok: false,
|
|
1538
|
+
warning: "model routing: Bridge API credentials unavailable; using agent default model",
|
|
1539
|
+
diagnostic: makeModelRoutingDiagnostic({
|
|
1540
|
+
kind,
|
|
1541
|
+
repoName,
|
|
1542
|
+
storeTarget,
|
|
1543
|
+
storePath,
|
|
1544
|
+
message: "Bridge API credentials unavailable",
|
|
1545
|
+
}),
|
|
1546
|
+
};
|
|
1335
1547
|
}
|
|
1336
1548
|
const baseUrlRaw = deps.env.BAPI_BASE_URL;
|
|
1337
1549
|
const baseUrl = typeof baseUrlRaw === "string" && baseUrlRaw.trim().length > 0
|
|
@@ -1352,15 +1564,67 @@ export function buildStartTicketsJiraUrl(baseUrl, apiPath, params = {}) {
|
|
|
1352
1564
|
* GET JSON with an `AbortController` timeout. Throws a generic error (no secret
|
|
1353
1565
|
* material) on a non-2xx response; always clears the timeout.
|
|
1354
1566
|
*/
|
|
1567
|
+
/**
|
|
1568
|
+
* Secret-free HTTP error for the CLI's Bridge API calls. Carries a coarse `kind`
|
|
1569
|
+
* and optional `status` so callers can classify the routing degradation. The
|
|
1570
|
+
* message is generic and NEVER includes request headers, the API key, or the
|
|
1571
|
+
* response body.
|
|
1572
|
+
*/
|
|
1573
|
+
export class StartTicketsHttpError extends Error {
|
|
1574
|
+
kind;
|
|
1575
|
+
status;
|
|
1576
|
+
constructor(kind, status) {
|
|
1577
|
+
super(`Bridge API request failed (${kind}${typeof status === "number" ? `, status ${status}` : ""})`);
|
|
1578
|
+
this.name = "StartTicketsHttpError";
|
|
1579
|
+
this.kind = kind;
|
|
1580
|
+
this.status = status;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* Build a routing diagnostic from a caught fetch error. A
|
|
1585
|
+
* {@link StartTicketsHttpError} maps to its `kind` (unauthorized/server/network/
|
|
1586
|
+
* http); any other error uses `fallbackKind` (e.g. config/tier unavailable).
|
|
1587
|
+
*/
|
|
1588
|
+
function diagnosticFromFetchError(err, opts) {
|
|
1589
|
+
const base = {
|
|
1590
|
+
repoName: opts.repoName,
|
|
1591
|
+
storeTarget: opts.repoName ? `bapi:${opts.repoName}` : undefined,
|
|
1592
|
+
ticketKey: opts.ticketKey,
|
|
1593
|
+
message: opts.message,
|
|
1594
|
+
};
|
|
1595
|
+
if (err instanceof StartTicketsHttpError) {
|
|
1596
|
+
return makeModelRoutingDiagnostic({ ...base, kind: err.kind, status: err.status });
|
|
1597
|
+
}
|
|
1598
|
+
return makeModelRoutingDiagnostic({ ...base, kind: opts.fallbackKind });
|
|
1599
|
+
}
|
|
1355
1600
|
export async function fetchJsonWithTimeout(url, headers, timeoutMs) {
|
|
1356
1601
|
const controller = new AbortController();
|
|
1357
1602
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1358
1603
|
try {
|
|
1359
|
-
|
|
1604
|
+
let resp;
|
|
1605
|
+
try {
|
|
1606
|
+
resp = await fetch(url, { headers, signal: controller.signal });
|
|
1607
|
+
}
|
|
1608
|
+
catch {
|
|
1609
|
+
// Aborts (timeout), DNS/connection failures, and other fetch exceptions.
|
|
1610
|
+
throw new StartTicketsHttpError("network");
|
|
1611
|
+
}
|
|
1360
1612
|
if (!resp.ok) {
|
|
1361
|
-
|
|
1613
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
1614
|
+
throw new StartTicketsHttpError("unauthorized", resp.status);
|
|
1615
|
+
}
|
|
1616
|
+
if (resp.status >= 500) {
|
|
1617
|
+
throw new StartTicketsHttpError("server", resp.status);
|
|
1618
|
+
}
|
|
1619
|
+
throw new StartTicketsHttpError("http", resp.status);
|
|
1620
|
+
}
|
|
1621
|
+
try {
|
|
1622
|
+
return await resp.json();
|
|
1623
|
+
}
|
|
1624
|
+
catch {
|
|
1625
|
+
// Body read/parse failure (often an abort mid-read) — treat as network.
|
|
1626
|
+
throw new StartTicketsHttpError("network");
|
|
1362
1627
|
}
|
|
1363
|
-
return await resp.json();
|
|
1364
1628
|
}
|
|
1365
1629
|
finally {
|
|
1366
1630
|
clearTimeout(timer);
|
|
@@ -1397,7 +1661,8 @@ export async function fetchStartTicketsConfigField(access, fieldName) {
|
|
|
1397
1661
|
/**
|
|
1398
1662
|
* Fetch the repo's routing config once. A missing/null enable value defaults to
|
|
1399
1663
|
* enabled (the feature ships ON); an explicit boolean `false` disables routing.
|
|
1400
|
-
* Any fetch failure returns a structured failure
|
|
1664
|
+
* Any fetch failure returns a structured failure (with a classified diagnostic)
|
|
1665
|
+
* so the caller fails open to premium/Opus.
|
|
1401
1666
|
*/
|
|
1402
1667
|
export async function fetchDifficultyModelRoutingConfig(access) {
|
|
1403
1668
|
try {
|
|
@@ -1406,13 +1671,22 @@ export async function fetchDifficultyModelRoutingConfig(access) {
|
|
|
1406
1671
|
const enabled = enabledValue === false ? false : true;
|
|
1407
1672
|
return { ok: true, config: { enabled, overrides: normalizeDifficultyModelTierOverrides(overridesValue) } };
|
|
1408
1673
|
}
|
|
1409
|
-
catch {
|
|
1410
|
-
return {
|
|
1674
|
+
catch (err) {
|
|
1675
|
+
return {
|
|
1676
|
+
ok: false,
|
|
1677
|
+
warning: "model routing: failed to fetch routing config; using agent default model",
|
|
1678
|
+
diagnostic: diagnosticFromFetchError(err, {
|
|
1679
|
+
repoName: access.repoName,
|
|
1680
|
+
fallbackKind: "config-unavailable",
|
|
1681
|
+
message: "failed to fetch routing config",
|
|
1682
|
+
}),
|
|
1683
|
+
};
|
|
1411
1684
|
}
|
|
1412
1685
|
}
|
|
1413
1686
|
/**
|
|
1414
1687
|
* Fetch the coarse tier for one ticket with a bounded timeout. Never throws to
|
|
1415
|
-
* the batch orchestrator: failures/timeouts return a structured warning
|
|
1688
|
+
* the batch orchestrator: failures/timeouts return a structured warning plus a
|
|
1689
|
+
* classified diagnostic that preserves the ticket key in metadata.
|
|
1416
1690
|
*/
|
|
1417
1691
|
export async function fetchTicketModelTierForStartTickets(access, ticket) {
|
|
1418
1692
|
try {
|
|
@@ -1426,8 +1700,17 @@ export async function fetchTicketModelTierForStartTickets(access, ticket) {
|
|
|
1426
1700
|
: "fallback";
|
|
1427
1701
|
return { ok: true, value: { difficulty, tier, source } };
|
|
1428
1702
|
}
|
|
1429
|
-
catch {
|
|
1430
|
-
return {
|
|
1703
|
+
catch (err) {
|
|
1704
|
+
return {
|
|
1705
|
+
ok: false,
|
|
1706
|
+
warning: `model routing: tier lookup failed for ${ticket}; using agent default model`,
|
|
1707
|
+
diagnostic: diagnosticFromFetchError(err, {
|
|
1708
|
+
repoName: access.repoName,
|
|
1709
|
+
ticketKey: ticket,
|
|
1710
|
+
fallbackKind: "tier-fetch-unavailable",
|
|
1711
|
+
message: `tier lookup failed for ${ticket}`,
|
|
1712
|
+
}),
|
|
1713
|
+
};
|
|
1431
1714
|
}
|
|
1432
1715
|
}
|
|
1433
1716
|
/**
|
|
@@ -1509,8 +1792,12 @@ export async function validateResolvedModelAliasForAgent(deps, agent, candidate)
|
|
|
1509
1792
|
}
|
|
1510
1793
|
return { ok: true, alias: candidate };
|
|
1511
1794
|
}
|
|
1512
|
-
/**
|
|
1513
|
-
|
|
1795
|
+
/**
|
|
1796
|
+
* Return a copy of `row` with default (no-routing) metadata and a reason. An
|
|
1797
|
+
* optional `diagnostic` is stamped onto the row so the CLI can later emit one
|
|
1798
|
+
* de-duplicated invocation-level routing line.
|
|
1799
|
+
*/
|
|
1800
|
+
export function applyDefaultModelRoutingMetadata(row, reason, diagnostic) {
|
|
1514
1801
|
return {
|
|
1515
1802
|
...row,
|
|
1516
1803
|
difficulty: row.difficulty ?? null,
|
|
@@ -1518,8 +1805,53 @@ export function applyDefaultModelRoutingMetadata(row, reason) {
|
|
|
1518
1805
|
modelAlias: null,
|
|
1519
1806
|
modelRoutingSource: row.modelRoutingSource ?? "default",
|
|
1520
1807
|
modelRoutingReason: reason,
|
|
1808
|
+
...(diagnostic ? { modelRoutingDiagnostic: diagnostic } : {}),
|
|
1521
1809
|
};
|
|
1522
1810
|
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Resolve + validate the premium (Opus) alias ONCE and return a reusable applier.
|
|
1813
|
+
*
|
|
1814
|
+
* The premium alias is identical for every row in a batch, and validating it for
|
|
1815
|
+
* `cursor-agent` spawns a `cursor-agent --list-models` probe — so resolving per
|
|
1816
|
+
* row would fire N identical subprocess probes for a batch of N tickets. Hoisting
|
|
1817
|
+
* the resolve+validate here means at most one probe regardless of batch size
|
|
1818
|
+
* (claude's alias is static and never probes either way).
|
|
1819
|
+
*
|
|
1820
|
+
* The premium fallback deliberately reverses the older "omit --model" fail-safe:
|
|
1821
|
+
* an unknown/hard ticket should run on the strongest model rather than silently
|
|
1822
|
+
* downgrade. If the premium alias cannot be resolved/validated for this agent
|
|
1823
|
+
* (e.g. it doesn't support --model, or verification fails) the applier falls back
|
|
1824
|
+
* to the true no-routing default so we never emit an invalid --model.
|
|
1825
|
+
*/
|
|
1826
|
+
async function makePremiumFallbackApplier(deps, agent, overrides) {
|
|
1827
|
+
const alias = resolveModelAlias(agent, "premium", overrides);
|
|
1828
|
+
const validation = alias ? await validateResolvedModelAliasForAgent(deps, agent, alias) : null;
|
|
1829
|
+
return (row, warning, diagnostic) => {
|
|
1830
|
+
if (validation && validation.ok) {
|
|
1831
|
+
const reason = `${warning}; defaulting to premium/Opus (model=${validation.alias})`;
|
|
1832
|
+
return appendSummaryRowWarning({
|
|
1833
|
+
...row,
|
|
1834
|
+
difficulty: row.difficulty ?? null,
|
|
1835
|
+
modelTier: "premium",
|
|
1836
|
+
modelAlias: validation.alias,
|
|
1837
|
+
modelRoutingSource: "fallback",
|
|
1838
|
+
modelRoutingReason: reason,
|
|
1839
|
+
...(diagnostic ? { modelRoutingDiagnostic: diagnostic } : {}),
|
|
1840
|
+
}, warning);
|
|
1841
|
+
}
|
|
1842
|
+
// Premium alias unavailable for this agent — fall back to agent default.
|
|
1843
|
+
return appendSummaryRowWarning(applyDefaultModelRoutingMetadata(row, warning, diagnostic), warning);
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* Apply the premium fallback to every eligible row (used by batch error
|
|
1848
|
+
* branches). An optional shared `diagnostic` is stamped onto every eligible row
|
|
1849
|
+
* receiving the fallback so the CLI can de-duplicate it to one invocation line.
|
|
1850
|
+
*/
|
|
1851
|
+
async function applyPremiumFallbackToEligibleRows(deps, rows, isEligible, agent, overrides, warning, diagnostic) {
|
|
1852
|
+
const apply = await makePremiumFallbackApplier(deps, agent, overrides);
|
|
1853
|
+
return rows.map((r) => (isEligible(r) ? apply(r, warning, diagnostic) : r));
|
|
1854
|
+
}
|
|
1523
1855
|
/** Real spawn path: a worktree was created and has a path. */
|
|
1524
1856
|
const isCreatedRoutingEligible = (r) => r.status === "created" && !!r.path;
|
|
1525
1857
|
/** Dry-run path: rows have no worktree path yet but are eligible for a preview. */
|
|
@@ -1541,16 +1873,15 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
|
|
|
1541
1873
|
}
|
|
1542
1874
|
const accessResult = await resolveStartTicketsBridgeApiAccess(deps);
|
|
1543
1875
|
if (!accessResult.ok) {
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1876
|
+
// Genuine error reaching the backend — default unknown difficulty to premium/Opus,
|
|
1877
|
+
// stamping the access diagnostic onto every eligible row.
|
|
1878
|
+
return applyPremiumFallbackToEligibleRows(deps, rows, isEligible, agent, null, accessResult.warning, accessResult.diagnostic);
|
|
1547
1879
|
}
|
|
1548
1880
|
const access = accessResult.access;
|
|
1549
1881
|
const configResult = await fetchDifficultyModelRoutingConfig(access);
|
|
1550
1882
|
if (!configResult.ok) {
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
: r);
|
|
1883
|
+
// Can't read routing config — treat as error and default to premium/Opus.
|
|
1884
|
+
return applyPremiumFallbackToEligibleRows(deps, rows, isEligible, agent, null, configResult.warning, configResult.diagnostic);
|
|
1554
1885
|
}
|
|
1555
1886
|
const { enabled, overrides } = configResult.config;
|
|
1556
1887
|
if (!enabled) {
|
|
@@ -1558,6 +1889,14 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
|
|
|
1558
1889
|
return rows.map((r) => (isEligible(r) ? applyDefaultModelRoutingMetadata(r, reason) : r));
|
|
1559
1890
|
}
|
|
1560
1891
|
const tierMap = await fetchTicketModelTiersForRows(access, eligible, options.maxParallel, isEligible);
|
|
1892
|
+
// Lazily resolve+validate the premium fallback alias at most once for this run
|
|
1893
|
+
// (the alias is identical across rows; cursor-agent validation spawns a probe).
|
|
1894
|
+
let premiumApplier = null;
|
|
1895
|
+
const applyPremiumFallback = async (row, warning, diagnostic) => {
|
|
1896
|
+
if (!premiumApplier)
|
|
1897
|
+
premiumApplier = await makePremiumFallbackApplier(deps, agent, overrides);
|
|
1898
|
+
return premiumApplier(row, warning, diagnostic);
|
|
1899
|
+
};
|
|
1561
1900
|
const out = [];
|
|
1562
1901
|
for (const row of rows) {
|
|
1563
1902
|
if (!isEligible(row)) {
|
|
@@ -1566,10 +1905,21 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
|
|
|
1566
1905
|
}
|
|
1567
1906
|
const tierResult = tierMap.get(row.key);
|
|
1568
1907
|
if (!tierResult || !tierResult.ok) {
|
|
1908
|
+
// Error reaching the per-ticket tier endpoint — default to premium/Opus,
|
|
1909
|
+
// stamping the per-ticket tier-fetch diagnostic.
|
|
1569
1910
|
const warning = tierResult && !tierResult.ok
|
|
1570
1911
|
? tierResult.warning
|
|
1571
|
-
: `model routing: no tier resolved for ${row.key};
|
|
1572
|
-
|
|
1912
|
+
: `model routing: no tier resolved for ${row.key}; assuming hard ticket`;
|
|
1913
|
+
const diagnostic = tierResult && !tierResult.ok
|
|
1914
|
+
? tierResult.diagnostic
|
|
1915
|
+
: makeModelRoutingDiagnostic({
|
|
1916
|
+
kind: "tier-fetch-unavailable",
|
|
1917
|
+
repoName: access.repoName,
|
|
1918
|
+
storeTarget: `bapi:${access.repoName}`,
|
|
1919
|
+
ticketKey: row.key,
|
|
1920
|
+
message: `no tier resolved for ${row.key}`,
|
|
1921
|
+
});
|
|
1922
|
+
out.push(await applyPremiumFallback(row, warning, diagnostic));
|
|
1573
1923
|
continue;
|
|
1574
1924
|
}
|
|
1575
1925
|
const { difficulty, tier, source } = tierResult.value;
|
|
@@ -1580,8 +1930,17 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
|
|
|
1580
1930
|
modelRoutingSource: source,
|
|
1581
1931
|
};
|
|
1582
1932
|
if (!tier) {
|
|
1583
|
-
|
|
1584
|
-
|
|
1933
|
+
// Backend reported no usable difficulty — default to premium/Opus. (The
|
|
1934
|
+
// backend now returns a premium fallback itself, so this is defensive.)
|
|
1935
|
+
const warning = `model routing: ${row.key} resolved no tier (source=${source}); assuming hard ticket`;
|
|
1936
|
+
const diagnostic = makeModelRoutingDiagnostic({
|
|
1937
|
+
kind: "no-tier",
|
|
1938
|
+
repoName: access.repoName,
|
|
1939
|
+
storeTarget: `bapi:${access.repoName}`,
|
|
1940
|
+
ticketKey: row.key,
|
|
1941
|
+
message: `${row.key} resolved no tier (source=${source})`,
|
|
1942
|
+
});
|
|
1943
|
+
out.push(await applyPremiumFallback(baseRow, warning, diagnostic));
|
|
1585
1944
|
continue;
|
|
1586
1945
|
}
|
|
1587
1946
|
const alias = resolveModelAlias(agent, tier, overrides);
|
|
@@ -1632,7 +1991,175 @@ export function formatModelRoutingLine(row, agent) {
|
|
|
1632
1991
|
const model = row.modelAlias ?? "default";
|
|
1633
1992
|
return `${row.key} difficulty=${difficulty} tier=${tier} agent=${agent.name} model=${model}`;
|
|
1634
1993
|
}
|
|
1994
|
+
/** Stable de-dup key for an invocation-level routing diagnostic. */
|
|
1995
|
+
function modelRoutingDiagnosticKey(d) {
|
|
1996
|
+
return [
|
|
1997
|
+
d.kind,
|
|
1998
|
+
d.repoName ?? "",
|
|
1999
|
+
d.storeTarget ?? "",
|
|
2000
|
+
d.storePath ?? "",
|
|
2001
|
+
d.ticketKey ?? "",
|
|
2002
|
+
typeof d.status === "number" ? String(d.status) : "",
|
|
2003
|
+
].join("|");
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* Scan routed rows, extract `modelRoutingDiagnostic`, and de-duplicate by stable
|
|
2007
|
+
* key. An identical credential problem shared by many rows collapses to one
|
|
2008
|
+
* diagnostic (its `ticketKey` is undefined), while distinct per-ticket failures
|
|
2009
|
+
* (which carry a `ticketKey`) are preserved.
|
|
2010
|
+
*/
|
|
2011
|
+
export function collectInvocationModelRoutingDiagnostics(rows) {
|
|
2012
|
+
const byKey = new Map();
|
|
2013
|
+
for (const row of rows) {
|
|
2014
|
+
const d = row.modelRoutingDiagnostic;
|
|
2015
|
+
if (!d)
|
|
2016
|
+
continue;
|
|
2017
|
+
const key = modelRoutingDiagnosticKey(d);
|
|
2018
|
+
if (!byKey.has(key))
|
|
2019
|
+
byKey.set(key, d);
|
|
2020
|
+
}
|
|
2021
|
+
return [...byKey.values()];
|
|
2022
|
+
}
|
|
2023
|
+
/** Priority rank (lower = more important) for choosing the primary diagnostic. */
|
|
2024
|
+
function modelRoutingDiagnosticPriority(kind) {
|
|
2025
|
+
switch (kind) {
|
|
2026
|
+
case "repo-missing":
|
|
2027
|
+
case "credential-not-found":
|
|
2028
|
+
case "credential-missing-key":
|
|
2029
|
+
case "credential-read-error":
|
|
2030
|
+
case "credential-parse-error":
|
|
2031
|
+
return 0;
|
|
2032
|
+
case "unauthorized":
|
|
2033
|
+
return 1;
|
|
2034
|
+
case "server":
|
|
2035
|
+
case "network":
|
|
2036
|
+
case "http":
|
|
2037
|
+
case "config-unavailable":
|
|
2038
|
+
case "tier-fetch-unavailable":
|
|
2039
|
+
return 2;
|
|
2040
|
+
case "no-tier":
|
|
2041
|
+
return 3;
|
|
2042
|
+
default:
|
|
2043
|
+
return 4;
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
/**
|
|
2047
|
+
* Select the highest-priority diagnostic when several degraded states coexist:
|
|
2048
|
+
* credential setup issues first, then unauthorized, then server/network/config,
|
|
2049
|
+
* then no-tier. Returns null when there are none.
|
|
2050
|
+
*/
|
|
2051
|
+
export function selectPrimaryInvocationModelRoutingDiagnostic(diagnostics) {
|
|
2052
|
+
let best = null;
|
|
2053
|
+
let bestRank = Number.POSITIVE_INFINITY;
|
|
2054
|
+
for (const d of diagnostics) {
|
|
2055
|
+
const rank = modelRoutingDiagnosticPriority(d.kind);
|
|
2056
|
+
if (rank < bestRank) {
|
|
2057
|
+
best = d;
|
|
2058
|
+
bestRank = rank;
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
return best;
|
|
2062
|
+
}
|
|
2063
|
+
/**
|
|
2064
|
+
* Emit at most ONE actionable routing diagnostic line to stderr for this
|
|
2065
|
+
* invocation. The human-facing stderr line is mandatory and independent of any
|
|
2066
|
+
* telemetry hook; `reportingPolicy` is carried for a future Sentry path, but the
|
|
2067
|
+
* CLI never emits Sentry events for expected setup gaps (missing/unauthorized
|
|
2068
|
+
* credentials). No-op when there is no diagnostic.
|
|
2069
|
+
*/
|
|
2070
|
+
export function emitInvocationModelRoutingDiagnostic(diagnostic, errorLog) {
|
|
2071
|
+
if (!diagnostic)
|
|
2072
|
+
return;
|
|
2073
|
+
errorLog(formatModelRoutingDiagnosticLine(diagnostic));
|
|
2074
|
+
}
|
|
2075
|
+
/** Convenience: collect → select → emit the single invocation diagnostic. */
|
|
2076
|
+
export function emitInvocationModelRoutingDiagnosticForRows(rows, errorLog) {
|
|
2077
|
+
emitInvocationModelRoutingDiagnostic(selectPrimaryInvocationModelRoutingDiagnostic(collectInvocationModelRoutingDiagnostics(rows)), errorLog);
|
|
2078
|
+
}
|
|
2079
|
+
/**
|
|
2080
|
+
* BAPI-394 conductor provisioning step (run id, per-worker id/env, Claude hook
|
|
2081
|
+
* injection, run-level `run.started`). Fully fail-open: any unexpected failure
|
|
2082
|
+
* returns the input rows unchanged so a conductor problem never aborts a real
|
|
2083
|
+
* start-tickets run. Per-worktree hook failures are handled inside
|
|
2084
|
+
* {@link provisionConductorHooksForRows} (they mark only the affected row).
|
|
2085
|
+
*/
|
|
2086
|
+
async function provisionConductorForRows(rows, deps, options, agent, overrides) {
|
|
2087
|
+
const createConductorContextFn = overrides.createConductorContext ?? createStartTicketsConductorContext;
|
|
2088
|
+
const provisionConductorFn = overrides.provisionConductorHooksForRows ?? provisionConductorHooksForRows;
|
|
2089
|
+
const emitRunStartedFn = overrides.emitStartTicketsRunStarted ?? emitStartTicketsRunStarted;
|
|
2090
|
+
try {
|
|
2091
|
+
const context = await createConductorContextFn({ keys: options.keys, autoApprove: options.autoApprove, epic: options.epic }, agent, {
|
|
2092
|
+
env: deps.env,
|
|
2093
|
+
cwd: deps.cwd,
|
|
2094
|
+
readFile: (filePath) => readFile(filePath, "utf-8"),
|
|
2095
|
+
});
|
|
2096
|
+
const provisioned = await provisionConductorFn(rows, context, {
|
|
2097
|
+
readFile: (filePath) => readFile(filePath, "utf-8"),
|
|
2098
|
+
writeFile: (filePath, data) => writeFile(filePath, data, "utf-8"),
|
|
2099
|
+
mkdir: (dirPath, opts) => mkdir(dirPath, opts),
|
|
2100
|
+
env: deps.env,
|
|
2101
|
+
});
|
|
2102
|
+
const withRunStarted = await emitRunStartedFn(context, provisioned, {
|
|
2103
|
+
keys: options.keys,
|
|
2104
|
+
dryRun: options.dryRun,
|
|
2105
|
+
});
|
|
2106
|
+
return { rows: withRunStarted, context };
|
|
2107
|
+
}
|
|
2108
|
+
catch {
|
|
2109
|
+
// Conductor observability is best-effort; never abort the run.
|
|
2110
|
+
return { rows, context: null };
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* BAPI-396: launch the conductor supervisor as a visible peer terminal tab for
|
|
2115
|
+
* the run. Best-effort and run-AFTER `run.started`: a spawn failure records a
|
|
2116
|
+
* `failed` status (workers still spawn) and never aborts the run. Disabled
|
|
2117
|
+
* (`supervisorMode=off`) or dry-run launches are reported as `skipped` without
|
|
2118
|
+
* spawning. Returns the resolved {@link SupervisorLaunchStatus}.
|
|
2119
|
+
*/
|
|
2120
|
+
async function launchSupervisorTab(context, deps, options, terminal) {
|
|
2121
|
+
if (options.dryRun || !isSupervisorLaunchEnabled(context)) {
|
|
2122
|
+
return "skipped";
|
|
2123
|
+
}
|
|
2124
|
+
try {
|
|
2125
|
+
const shellCommand = buildSupervisorTabCommand(context, deps.platform);
|
|
2126
|
+
const result = await deps.spawnTerminalTab(deps, terminal, shellCommand, {
|
|
2127
|
+
key: supervisorSpawnKey(options.keys),
|
|
2128
|
+
worktreePath: deps.cwd,
|
|
2129
|
+
});
|
|
2130
|
+
return result.ok ? "spawned" : "failed";
|
|
2131
|
+
}
|
|
2132
|
+
catch {
|
|
2133
|
+
return "failed";
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
/** Stamp the supervisor launch status onto every row (run-level field). */
|
|
2137
|
+
function stampSupervisorStatus(rows, status) {
|
|
2138
|
+
return rows.map((row) => ({ ...row, supervisorStatus: status }));
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Default epic dispatch correlation implementation (BAPI-409 idempotency guard).
|
|
2142
|
+
* Resolves Bridge API credentials from the environment and POSTs a run_spawned
|
|
2143
|
+
* transition for the given dispatch key before tabs are spawned. Throws on any
|
|
2144
|
+
* failure so the caller can abort the spawn (preventing duplicate dispatches).
|
|
2145
|
+
*/
|
|
2146
|
+
async function defaultClaimEpicDispatch(dispatchKey, runId, deps) {
|
|
2147
|
+
const accessResult = await resolveConductorBridgeApiAccess({ env: deps.env, cwd: deps.cwd });
|
|
2148
|
+
if (!accessResult.ok) {
|
|
2149
|
+
throw new Error(`Epic dispatch correlation failed: ${accessResult.error}`);
|
|
2150
|
+
}
|
|
2151
|
+
await transitionEpicDispatch(accessResult.access, {
|
|
2152
|
+
dispatchKey,
|
|
2153
|
+
nextStatus: "run_spawned",
|
|
2154
|
+
runId,
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
1635
2157
|
export async function orchestrateStartTickets(deps, options, overrides = {}) {
|
|
2158
|
+
// Thread dependency-derived base branch from epic identity through the
|
|
2159
|
+
// existing --base-branch path (no new branch-cutting or refresh logic).
|
|
2160
|
+
if (options.epic?.base_branch) {
|
|
2161
|
+
options = { ...options, baseBranch: options.epic.base_branch };
|
|
2162
|
+
}
|
|
1636
2163
|
if (options.dryRun) {
|
|
1637
2164
|
return { ok: true, rows: buildDryRunResults(options.keys, options.branchOverrides) };
|
|
1638
2165
|
}
|
|
@@ -1673,20 +2200,85 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
|
|
|
1673
2200
|
// and BEFORE tab spawning (currently a pass-through seam; see
|
|
1674
2201
|
// materializeFileCredentialsForCreatedWorktrees).
|
|
1675
2202
|
const materialized = await materializeFn(provisioned, deps);
|
|
1676
|
-
// BAPI-
|
|
1677
|
-
// and
|
|
1678
|
-
//
|
|
2203
|
+
// BAPI-394: provision conductor identity (run id, per-worker id + env, Claude
|
|
2204
|
+
// hook injection) and emit the run-level `run.started` event AFTER
|
|
2205
|
+
// materialization and BEFORE model routing. The conductor stage is opted into
|
|
2206
|
+
// by the caller supplying the `createConductorContext` seam (the packaged CLI
|
|
2207
|
+
// does this); direct callers/tests that omit it get the pre-conductor pipeline
|
|
2208
|
+
// unchanged. Fully fail-open: any unexpected failure here leaves the run
|
|
2209
|
+
// untouched (no conductor identity) rather than aborting the spawn.
|
|
2210
|
+
// BAPI-396: launch the supervisor peer tab AFTER `run.started` is emitted
|
|
2211
|
+
// (inside provisionConductorForRows) and BEFORE worker tabs spawn. Fully
|
|
2212
|
+
// best-effort: a failure marks the run-level status `failed` but workers still
|
|
2213
|
+
// spawn. `terminal` is resolved up-front so the supervisor tab and worker tabs
|
|
2214
|
+
// share the same terminal choice.
|
|
2215
|
+
// BAPI-409: defensive check — epic dispatch requires conductor context to mint
|
|
2216
|
+
// a run_id for correlation. Fail fast rather than silently bypassing the claim.
|
|
2217
|
+
if (options.epic?.dispatch_key && !overrides.createConductorContext) {
|
|
2218
|
+
return {
|
|
2219
|
+
ok: false,
|
|
2220
|
+
error: "Epic dispatch requires conductor context (createConductorContext seam must be injected when dispatch_key is set)",
|
|
2221
|
+
};
|
|
2222
|
+
}
|
|
2223
|
+
const terminal = detectTerminalFn(options.terminal, deps.env);
|
|
2224
|
+
let conductorReady;
|
|
2225
|
+
if (overrides.createConductorContext) {
|
|
2226
|
+
const provisionResult = await provisionConductorForRows(materialized, deps, options, agent, overrides);
|
|
2227
|
+
// BAPI-409: if epic dispatch requires a claim but conductor provisioning
|
|
2228
|
+
// failed (context is null from its own fail-open error handler), treat it
|
|
2229
|
+
// as a HARD error — we cannot mint or correlate a run_id without context,
|
|
2230
|
+
// and spawning without claiming would allow ghost re-dispatches on the next
|
|
2231
|
+
// epic-tick wakeup.
|
|
2232
|
+
if (options.epic?.dispatch_key && !provisionResult.context) {
|
|
2233
|
+
return {
|
|
2234
|
+
ok: false,
|
|
2235
|
+
error: "Epic dispatch correlation unavailable: conductor provisioning failed (cannot claim dispatch without run_id)",
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
if (provisionResult.context) {
|
|
2239
|
+
// BAPI-409: claim the dispatch_key BEFORE spawning tabs. The idempotency
|
|
2240
|
+
// guard ensures that if this invocation crashes after spawn the next
|
|
2241
|
+
// epic-tick wakeup cannot re-dispatch the same run. Failures are HARD
|
|
2242
|
+
// errors (not fail-open) to prevent ghost spawns.
|
|
2243
|
+
if (options.epic?.dispatch_key) {
|
|
2244
|
+
const claimFn = overrides.claimEpicDispatch ?? defaultClaimEpicDispatch;
|
|
2245
|
+
try {
|
|
2246
|
+
await claimFn(options.epic.dispatch_key, provisionResult.context.runId, deps);
|
|
2247
|
+
}
|
|
2248
|
+
catch (e) {
|
|
2249
|
+
const msg = e instanceof Error ? e.message : "epic dispatch correlation failed";
|
|
2250
|
+
return { ok: false, error: `Epic dispatch correlation failed (idempotency guard): ${msg}` };
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
const supervisorStatus = await launchSupervisorTab(provisionResult.context, deps, options, terminal);
|
|
2254
|
+
conductorReady = stampSupervisorStatus(provisionResult.rows, supervisorStatus);
|
|
2255
|
+
}
|
|
2256
|
+
else {
|
|
2257
|
+
conductorReady = provisionResult.rows;
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
else {
|
|
2261
|
+
conductorReady = materialized;
|
|
2262
|
+
}
|
|
2263
|
+
// BAPI-365: resolve per-ticket model routing AFTER conductor provisioning and
|
|
2264
|
+
// BEFORE building the spawn command. Always fail-open: routing never aborts a
|
|
2265
|
+
// spawn — at worst a ticket runs on the agent's default model.
|
|
1679
2266
|
const resolveRoutingFn = overrides.resolveModelRoutingForRows ?? resolveModelRoutingForRows;
|
|
1680
|
-
const routed = await resolveRoutingFn(deps,
|
|
2267
|
+
const routed = await resolveRoutingFn(deps, conductorReady, options, agent);
|
|
2268
|
+
// Per-ticket routing DECISION lines stay (one per spawnable ticket). The
|
|
2269
|
+
// noisy per-row routing WARNING emission is intentionally gone (BAPI-377): the
|
|
2270
|
+
// CLI now emits a single de-duplicated, actionable routing diagnostic per
|
|
2271
|
+
// invocation from the structured `modelRoutingDiagnostic` stamped on rows.
|
|
1681
2272
|
for (const row of routed) {
|
|
1682
2273
|
if (row.status !== "created" || !row.path)
|
|
1683
2274
|
continue;
|
|
1684
2275
|
overrides.modelRoutingLog?.(formatModelRoutingLine(row, agent));
|
|
1685
|
-
if (row.modelAlias == null && row.modelRoutingReason) {
|
|
1686
|
-
overrides.modelRoutingWarningLog?.(`${row.key}: ${row.modelRoutingReason}`);
|
|
1687
|
-
}
|
|
1688
2276
|
}
|
|
1689
|
-
|
|
2277
|
+
// Single invocation-level routing diagnostic to stderr (de-duplicated across
|
|
2278
|
+
// rows). Fail-open: emission is best-effort and never blocks spawning.
|
|
2279
|
+
if (overrides.modelRoutingWarningLog) {
|
|
2280
|
+
emitInvocationModelRoutingDiagnosticForRows(routed, overrides.modelRoutingWarningLog);
|
|
2281
|
+
}
|
|
1690
2282
|
const rows = await spawnTabsFn(deps, routed, terminal, platformConfig.config.buildAgentShellCommand);
|
|
1691
2283
|
return { ok: true, rows };
|
|
1692
2284
|
}
|
|
@@ -1759,6 +2351,9 @@ export async function runStartTicketsCli(argv, overrides = {}) {
|
|
|
1759
2351
|
}
|
|
1760
2352
|
}
|
|
1761
2353
|
log("");
|
|
2354
|
+
// Preview lines and summaries stay on stdout; the single actionable routing
|
|
2355
|
+
// diagnostic (de-duplicated across tickets) goes to stderr, matching a real run.
|
|
2356
|
+
emitInvocationModelRoutingDiagnosticForRows(routedDryRunRows, errorLog);
|
|
1762
2357
|
}
|
|
1763
2358
|
// Thread the routing decision/warning sinks through to the orchestrator. The
|
|
1764
2359
|
// dry-run path short-circuits inside orchestrate before any routing (the
|
|
@@ -1766,6 +2361,12 @@ export async function runStartTicketsCli(argv, overrides = {}) {
|
|
|
1766
2361
|
const result = await orchestrate(deps, options, {
|
|
1767
2362
|
modelRoutingLog: log,
|
|
1768
2363
|
modelRoutingWarningLog: errorLog,
|
|
2364
|
+
// BAPI-394: opt the packaged CLI into conductor observability. Supplying the
|
|
2365
|
+
// context seam activates the conductor stage inside orchestrate; the other
|
|
2366
|
+
// two seams fall back to their real module implementations.
|
|
2367
|
+
createConductorContext: createStartTicketsConductorContext,
|
|
2368
|
+
provisionConductorHooksForRows,
|
|
2369
|
+
emitStartTicketsRunStarted,
|
|
1769
2370
|
});
|
|
1770
2371
|
if (!result.ok) {
|
|
1771
2372
|
errorLog(`Error: ${result.error}`);
|