bosun 0.41.3 → 0.41.4
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/agent/agent-pool.mjs +9 -2
- package/agent/agent-supervisor.mjs +113 -3
- package/infra/monitor.mjs +7 -0
- package/package.json +1 -1
- package/server/setup-web-server.mjs +58 -5
- package/server/ui-server.mjs +200 -0
- package/tools/syntax-check.mjs +8 -1
- package/ui/demo-defaults.js +4 -4
- package/ui/modules/state.js +22 -0
- package/ui/tabs/tasks.js +63 -1
- package/workflow/workflow-nodes.mjs +54 -11
- package/workflow-templates/task-batch.mjs +24 -2
package/agent/agent-pool.mjs
CHANGED
|
@@ -2846,6 +2846,10 @@ function isPoisonedCodexResumeError(errorValue) {
|
|
|
2846
2846
|
);
|
|
2847
2847
|
}
|
|
2848
2848
|
|
|
2849
|
+
function isCodexResumeTimeoutError(errorValue) {
|
|
2850
|
+
return String(errorValue || "").toLowerCase().includes("codex resume timeout");
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2849
2853
|
/**
|
|
2850
2854
|
* Resume an existing Codex thread and run a follow-up prompt.
|
|
2851
2855
|
* Uses `codex.resumeThread(threadId)` from @openai/codex-sdk.
|
|
@@ -3017,6 +3021,7 @@ async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) {
|
|
|
3017
3021
|
: `Thread resume error: ${err.message}`,
|
|
3018
3022
|
sdk: "codex",
|
|
3019
3023
|
threadId: null,
|
|
3024
|
+
staleResumeState: isTimeout,
|
|
3020
3025
|
poisonedResumeState:
|
|
3021
3026
|
!isTimeout && isPoisonedCodexResumeError(err.message),
|
|
3022
3027
|
};
|
|
@@ -3194,10 +3199,12 @@ export async function launchOrResumeThread(
|
|
|
3194
3199
|
// Resume failed — fall through to fresh launch
|
|
3195
3200
|
if (
|
|
3196
3201
|
result.poisonedResumeState ||
|
|
3197
|
-
|
|
3202
|
+
result.staleResumeState ||
|
|
3203
|
+
isPoisonedCodexResumeError(result.error) ||
|
|
3204
|
+
isCodexResumeTimeoutError(result.error)
|
|
3198
3205
|
) {
|
|
3199
3206
|
console.warn(
|
|
3200
|
-
`${TAG} resume failed for task "${taskKey}" with corrupted state: ${result.error}. Dropping cached thread metadata and starting fresh.`,
|
|
3207
|
+
`${TAG} resume failed for task "${taskKey}" with stale or corrupted state: ${result.error}. Dropping cached thread metadata and starting fresh.`,
|
|
3201
3208
|
);
|
|
3202
3209
|
threadRegistry.delete(taskKey);
|
|
3203
3210
|
} else {
|
|
@@ -30,6 +30,12 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
const TAG = "[agent-supervisor]";
|
|
33
|
+
const API_ERROR_CONTINUE_COOLDOWNS_MS = Object.freeze([
|
|
34
|
+
3 * 60_000,
|
|
35
|
+
5 * 60_000,
|
|
36
|
+
5 * 60_000,
|
|
37
|
+
]);
|
|
38
|
+
const API_ERROR_RECOVERY_RESET_MS = 15 * 60_000;
|
|
33
39
|
|
|
34
40
|
// ── Situation Types (30+ edge cases) ────────────────────────────────────────
|
|
35
41
|
|
|
@@ -140,7 +146,7 @@ const INTERVENTION_LADDER = {
|
|
|
140
146
|
[SITUATION.PRE_PUSH_FAILURE]: [INTERVENTION.INJECT_PROMPT, INTERVENTION.INJECT_PROMPT, INTERVENTION.FORCE_NEW_THREAD, INTERVENTION.BLOCK_AND_NOTIFY],
|
|
141
147
|
|
|
142
148
|
[SITUATION.RATE_LIMITED]: [INTERVENTION.COOLDOWN, INTERVENTION.COOLDOWN, INTERVENTION.PAUSE_EXECUTOR],
|
|
143
|
-
[SITUATION.API_ERROR]: [INTERVENTION.
|
|
149
|
+
[SITUATION.API_ERROR]: [INTERVENTION.FORCE_NEW_THREAD, INTERVENTION.REDISPATCH_TASK, INTERVENTION.BLOCK_AND_NOTIFY],
|
|
144
150
|
[SITUATION.TOKEN_OVERFLOW]: [INTERVENTION.FORCE_NEW_THREAD, INTERVENTION.FORCE_NEW_THREAD, INTERVENTION.BLOCK_AND_NOTIFY],
|
|
145
151
|
[SITUATION.SESSION_EXPIRED]: [INTERVENTION.FORCE_NEW_THREAD, INTERVENTION.FORCE_NEW_THREAD, INTERVENTION.BLOCK_AND_NOTIFY],
|
|
146
152
|
[SITUATION.MODEL_ERROR]: [INTERVENTION.BLOCK_AND_NOTIFY], // Not retryable — wrong model name
|
|
@@ -443,13 +449,15 @@ export class AgentSupervisor {
|
|
|
443
449
|
const signals = this._gatherSignals(taskId, context);
|
|
444
450
|
const situation = this._diagnose(signals, context);
|
|
445
451
|
const healthScore = this._computeHealthScore(signals);
|
|
452
|
+
const recoveryOverride = this._selectRecoveryIntervention(taskId, situation, context, state);
|
|
446
453
|
const attemptIndex = Math.min(
|
|
447
454
|
state.interventionCount,
|
|
448
455
|
(INTERVENTION_LADDER[situation] || [INTERVENTION.NONE]).length - 1,
|
|
449
456
|
);
|
|
450
|
-
const intervention =
|
|
457
|
+
const intervention = recoveryOverride?.intervention
|
|
458
|
+
|| (INTERVENTION_LADDER[situation] || [INTERVENTION.NONE])[attemptIndex];
|
|
451
459
|
const prompt = this._buildPrompt(situation, taskId, context);
|
|
452
|
-
const reason = this._buildReason(situation, signals, context);
|
|
460
|
+
const reason = recoveryOverride?.reason || this._buildReason(situation, signals, context);
|
|
453
461
|
|
|
454
462
|
// Record
|
|
455
463
|
state.situationHistory.push({ situation, ts: Date.now() });
|
|
@@ -485,6 +493,9 @@ export class AgentSupervisor {
|
|
|
485
493
|
break;
|
|
486
494
|
|
|
487
495
|
case INTERVENTION.CONTINUE_SIGNAL:
|
|
496
|
+
if (situation === SITUATION.API_ERROR) {
|
|
497
|
+
this._recordApiErrorContinue(taskId);
|
|
498
|
+
}
|
|
488
499
|
if (this._sendContinueSignal) {
|
|
489
500
|
this._sendContinueSignal(taskId);
|
|
490
501
|
}
|
|
@@ -725,6 +736,12 @@ export class AgentSupervisor {
|
|
|
725
736
|
qualityScore: state.qualityScore,
|
|
726
737
|
reviewVerdict: state.reviewVerdict,
|
|
727
738
|
reviewIssueCount: state.reviewIssues?.length || 0,
|
|
739
|
+
apiErrorRecovery: state.apiErrorRecovery
|
|
740
|
+
? {
|
|
741
|
+
...state.apiErrorRecovery,
|
|
742
|
+
cooldownRemainingMs: Math.max(0, Number(state.apiErrorRecovery.cooldownUntil || 0) - Date.now()),
|
|
743
|
+
}
|
|
744
|
+
: null,
|
|
728
745
|
recentSituations: state.situationHistory.slice(-10),
|
|
729
746
|
};
|
|
730
747
|
}
|
|
@@ -789,11 +806,104 @@ export class AgentSupervisor {
|
|
|
789
806
|
qualityScore: null,
|
|
790
807
|
reviewVerdict: null,
|
|
791
808
|
reviewIssues: null,
|
|
809
|
+
apiErrorRecovery: null,
|
|
792
810
|
});
|
|
793
811
|
}
|
|
794
812
|
return this._taskState.get(taskId);
|
|
795
813
|
}
|
|
796
814
|
|
|
815
|
+
_normalizeApiErrorSignature(context) {
|
|
816
|
+
const raw = String(context?.error || context?.output || "").trim().toLowerCase();
|
|
817
|
+
if (!raw) return "api_error";
|
|
818
|
+
return raw
|
|
819
|
+
.replace(/\s+/g, " ")
|
|
820
|
+
.replace(/\b\d{2,}\b/g, "#")
|
|
821
|
+
.slice(0, 240);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
_selectRecoveryIntervention(taskId, situation, context, state) {
|
|
825
|
+
if (situation !== SITUATION.API_ERROR) {
|
|
826
|
+
if (state?.apiErrorRecovery) state.apiErrorRecovery = null;
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const now = Date.now();
|
|
831
|
+
const signature = this._normalizeApiErrorSignature(context);
|
|
832
|
+
const current = state.apiErrorRecovery || {
|
|
833
|
+
signature,
|
|
834
|
+
continueAttempts: 0,
|
|
835
|
+
lastErrorAt: 0,
|
|
836
|
+
cooldownUntil: 0,
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
const shouldReset =
|
|
840
|
+
current.signature !== signature ||
|
|
841
|
+
(current.lastErrorAt > 0 && now - current.lastErrorAt > API_ERROR_RECOVERY_RESET_MS);
|
|
842
|
+
|
|
843
|
+
const nextState = shouldReset
|
|
844
|
+
? {
|
|
845
|
+
signature,
|
|
846
|
+
continueAttempts: 0,
|
|
847
|
+
lastErrorAt: now,
|
|
848
|
+
cooldownUntil: 0,
|
|
849
|
+
}
|
|
850
|
+
: {
|
|
851
|
+
...current,
|
|
852
|
+
signature,
|
|
853
|
+
lastErrorAt: now,
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
state.apiErrorRecovery = nextState;
|
|
857
|
+
|
|
858
|
+
if (Number(nextState.cooldownUntil || 0) > now) {
|
|
859
|
+
const remainingMs = Math.max(0, nextState.cooldownUntil - now);
|
|
860
|
+
return {
|
|
861
|
+
intervention: INTERVENTION.COOLDOWN,
|
|
862
|
+
reason: `Transient API failure on cooldown for ${Math.ceil(remainingMs / 60000)} minute(s) before retrying the same thread.`,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (nextState.continueAttempts < API_ERROR_CONTINUE_COOLDOWNS_MS.length) {
|
|
867
|
+
const cooldownMs = API_ERROR_CONTINUE_COOLDOWNS_MS[nextState.continueAttempts];
|
|
868
|
+
return {
|
|
869
|
+
intervention: INTERVENTION.CONTINUE_SIGNAL,
|
|
870
|
+
reason: `Transient API failure — continue the current thread and back off for ${Math.ceil(cooldownMs / 60000)} minute(s) if it repeats.`,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const ladder = INTERVENTION_LADDER[SITUATION.API_ERROR] || [INTERVENTION.BLOCK_AND_NOTIFY];
|
|
875
|
+
const escalationIndex = Math.min(
|
|
876
|
+
nextState.continueAttempts - API_ERROR_CONTINUE_COOLDOWNS_MS.length,
|
|
877
|
+
ladder.length - 1,
|
|
878
|
+
);
|
|
879
|
+
const escalation = ladder[escalationIndex];
|
|
880
|
+
const escalationReason = escalation === INTERVENTION.FORCE_NEW_THREAD
|
|
881
|
+
? "Repeated API failures survived 3 continue attempts — forcing a fresh thread."
|
|
882
|
+
: escalation === INTERVENTION.REDISPATCH_TASK
|
|
883
|
+
? "Repeated API failures survived continue attempts and a fresh thread — redispatching the task."
|
|
884
|
+
: "Repeated API failures survived all automated recovery attempts — blocking for human review.";
|
|
885
|
+
return {
|
|
886
|
+
intervention: escalation,
|
|
887
|
+
reason: escalationReason,
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
_recordApiErrorContinue(taskId) {
|
|
892
|
+
const state = this._getTaskState(taskId);
|
|
893
|
+
if (!state?.apiErrorRecovery) return;
|
|
894
|
+
const attemptIndex = Math.min(
|
|
895
|
+
state.apiErrorRecovery.continueAttempts,
|
|
896
|
+
API_ERROR_CONTINUE_COOLDOWNS_MS.length - 1,
|
|
897
|
+
);
|
|
898
|
+
const cooldownMs = API_ERROR_CONTINUE_COOLDOWNS_MS[attemptIndex] || 0;
|
|
899
|
+
state.apiErrorRecovery = {
|
|
900
|
+
...state.apiErrorRecovery,
|
|
901
|
+
continueAttempts: Number(state.apiErrorRecovery.continueAttempts || 0) + 1,
|
|
902
|
+
cooldownUntil: cooldownMs > 0 ? Date.now() + cooldownMs : 0,
|
|
903
|
+
lastErrorAt: Date.now(),
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
797
907
|
_getTaskState(taskId) {
|
|
798
908
|
return this._taskState.get(taskId) || null;
|
|
799
909
|
}
|
package/infra/monitor.mjs
CHANGED
|
@@ -724,6 +724,13 @@ async function ensureWorkflowAutomationEngine() {
|
|
|
724
724
|
);
|
|
725
725
|
}
|
|
726
726
|
}
|
|
727
|
+
|
|
728
|
+
// Resume runs paused by a previous monitor shutdown after services are wired.
|
|
729
|
+
if (typeof engine.resumeInterruptedRuns === "function") {
|
|
730
|
+
engine.resumeInterruptedRuns().catch((err) => {
|
|
731
|
+
console.warn(`[workflows] Failed to resume interrupted runs: ${err?.message || err}`);
|
|
732
|
+
});
|
|
733
|
+
}
|
|
727
734
|
workflowAutomationInitDone = true;
|
|
728
735
|
return engine;
|
|
729
736
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosun",
|
|
3
|
-
"version": "0.41.
|
|
3
|
+
"version": "0.41.4",
|
|
4
4
|
"description": "Bosun Autonomous Engineering — manages AI agent executors with failover, extremely powerful workflow builder, and a massive amount of included default workflow templates for autonomous engineering, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -39,6 +39,61 @@ function trimTrailingSlashes(value) {
|
|
|
39
39
|
return out;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
function isAzureOpenAIHost(value) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = value instanceof URL ? value : new URL(String(value || "").trim());
|
|
45
|
+
const host = String(parsed.hostname || "").toLowerCase();
|
|
46
|
+
return host === "openai.azure.com" || host.endsWith(".openai.azure.com");
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildModelsProbeRequest({ apiKey = "", baseUrl = "" } = {}) {
|
|
53
|
+
const trimmedBase = String(baseUrl || "").trim();
|
|
54
|
+
const fallbackBase = "https://api.openai.com";
|
|
55
|
+
const headers = { "Content-Type": "application/json" };
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const parsed = new URL(trimmedBase || fallbackBase);
|
|
59
|
+
const pathname = trimTrailingSlashes(parsed.pathname || "");
|
|
60
|
+
const lowerPath = pathname.toLowerCase();
|
|
61
|
+
const isAzure = isAzureOpenAIHost(parsed);
|
|
62
|
+
|
|
63
|
+
if (apiKey) {
|
|
64
|
+
if (isAzure) headers["api-key"] = apiKey;
|
|
65
|
+
else headers.Authorization = `Bearer ${apiKey}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (lowerPath.endsWith("/models")) {
|
|
69
|
+
return { endpoint: parsed.toString(), headers };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (isAzure || lowerPath === "/openai" || lowerPath.startsWith("/openai/")) {
|
|
73
|
+
parsed.pathname = "/openai/models";
|
|
74
|
+
if (!parsed.searchParams.has("api-version")) {
|
|
75
|
+
parsed.searchParams.set("api-version", "2024-10-21");
|
|
76
|
+
}
|
|
77
|
+
return { endpoint: parsed.toString(), headers };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const v1Match = lowerPath.match(/^(.*\/v1)(?:\/.*)?$/);
|
|
81
|
+
if (v1Match) {
|
|
82
|
+
parsed.pathname = `${v1Match[1]}/models`;
|
|
83
|
+
parsed.search = "";
|
|
84
|
+
return { endpoint: parsed.toString(), headers };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
parsed.pathname = `${pathname || ""}/v1/models`;
|
|
88
|
+
parsed.search = "";
|
|
89
|
+
return { endpoint: parsed.toString(), headers };
|
|
90
|
+
} catch {
|
|
91
|
+
const resolvedBase = trimTrailingSlashes(trimmedBase || fallbackBase);
|
|
92
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
93
|
+
return { endpoint: `${resolvedBase}/v1/models`, headers };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
42
97
|
|
|
43
98
|
// ── Vendor file serving (hoisting-safe) ───────────────────────────────────────────
|
|
44
99
|
// Resolution order:
|
|
@@ -1750,13 +1805,9 @@ async function handleModelsProbe(body) {
|
|
|
1750
1805
|
}
|
|
1751
1806
|
|
|
1752
1807
|
// For OpenAI / compatible endpoints, try GET /v1/models
|
|
1753
|
-
const
|
|
1754
|
-
const endpoint = `${resolvedBase}/v1/models`;
|
|
1808
|
+
const { endpoint, headers } = buildModelsProbeRequest({ apiKey, baseUrl });
|
|
1755
1809
|
|
|
1756
1810
|
try {
|
|
1757
|
-
const headers = { "Content-Type": "application/json" };
|
|
1758
|
-
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
1759
|
-
|
|
1760
1811
|
const controller = new AbortController();
|
|
1761
1812
|
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
1762
1813
|
|
|
@@ -3008,7 +3059,9 @@ export async function startSetupServer(options = {}) {
|
|
|
3008
3059
|
export {
|
|
3009
3060
|
applyTelegramMiniAppSetupEnv,
|
|
3010
3061
|
applyNonBlockingSetupEnvDefaults,
|
|
3062
|
+
buildModelsProbeRequest,
|
|
3011
3063
|
handleTelegramChatIdLookup,
|
|
3064
|
+
isAzureOpenAIHost,
|
|
3012
3065
|
normalizeWorkflowTemplateOverrides,
|
|
3013
3066
|
normalizeTelegramUiPort,
|
|
3014
3067
|
normalizeRepoConfigEntry,
|
package/server/ui-server.mjs
CHANGED
|
@@ -7032,13 +7032,58 @@ function extractSafeErrorMessage(payload) {
|
|
|
7032
7032
|
return "Internal server error";
|
|
7033
7033
|
}
|
|
7034
7034
|
|
|
7035
|
+
function createRequestDiagnosticId() {
|
|
7036
|
+
return `req_${randomBytes(6).toString("hex")}`;
|
|
7037
|
+
}
|
|
7038
|
+
|
|
7039
|
+
function ensureResponseDiagnosticId(res) {
|
|
7040
|
+
if (!res || typeof res !== "object") return createRequestDiagnosticId();
|
|
7041
|
+
if (!res.__bosunDiagnosticId) {
|
|
7042
|
+
res.__bosunDiagnosticId = createRequestDiagnosticId();
|
|
7043
|
+
}
|
|
7044
|
+
return res.__bosunDiagnosticId;
|
|
7045
|
+
}
|
|
7046
|
+
|
|
7047
|
+
function describePayloadForErrorLog(payload, depth = 0) {
|
|
7048
|
+
if (payload instanceof Error) {
|
|
7049
|
+
const described = {
|
|
7050
|
+
name: String(payload.name || "Error"),
|
|
7051
|
+
message: String(payload.message || ""),
|
|
7052
|
+
};
|
|
7053
|
+
if (payload.stack) described.stack = String(payload.stack);
|
|
7054
|
+
if (payload.code != null) described.code = String(payload.code);
|
|
7055
|
+
if (depth < 3 && payload.cause) {
|
|
7056
|
+
described.cause = describePayloadForErrorLog(payload.cause, depth + 1);
|
|
7057
|
+
}
|
|
7058
|
+
return described;
|
|
7059
|
+
}
|
|
7060
|
+
return makeJsonSafe(payload, { maxDepth: 6 });
|
|
7061
|
+
}
|
|
7062
|
+
|
|
7063
|
+
function logJsonFailure(res, statusCode, payload, diagnosticId) {
|
|
7064
|
+
const requestContext = res?.__bosunRequestContext || {};
|
|
7065
|
+
console.error("[ui-server] request failed", {
|
|
7066
|
+
diagnosticId,
|
|
7067
|
+
statusCode,
|
|
7068
|
+
method: requestContext.method || null,
|
|
7069
|
+
path: requestContext.path || null,
|
|
7070
|
+
query: requestContext.query || "",
|
|
7071
|
+
payload: describePayloadForErrorLog(payload),
|
|
7072
|
+
});
|
|
7073
|
+
}
|
|
7074
|
+
|
|
7035
7075
|
function jsonResponse(res, statusCode, payload) {
|
|
7076
|
+
const diagnosticId = statusCode >= 500 ? ensureResponseDiagnosticId(res) : null;
|
|
7077
|
+
if (statusCode >= 500) {
|
|
7078
|
+
logJsonFailure(res, statusCode, payload, diagnosticId);
|
|
7079
|
+
}
|
|
7036
7080
|
const normalizedPayload = normalizeJsonResponsePayload(payload);
|
|
7037
7081
|
const safePayload =
|
|
7038
7082
|
statusCode >= 500
|
|
7039
7083
|
? {
|
|
7040
7084
|
ok: false,
|
|
7041
7085
|
error: extractSafeErrorMessage(normalizedPayload),
|
|
7086
|
+
diagnosticId,
|
|
7042
7087
|
}
|
|
7043
7088
|
: normalizedPayload;
|
|
7044
7089
|
const body = JSON.stringify(safePayload, null, 2);
|
|
@@ -7890,6 +7935,129 @@ function withTaskRuntimeSnapshot(task) {
|
|
|
7890
7935
|
};
|
|
7891
7936
|
}
|
|
7892
7937
|
|
|
7938
|
+
function normalizeTaskDiagnosticText(value) {
|
|
7939
|
+
const text = String(value || "").trim();
|
|
7940
|
+
return text ? text.replace(/\s+/g, " ") : "";
|
|
7941
|
+
}
|
|
7942
|
+
|
|
7943
|
+
function buildTaskStableCause(task, supervisorDiagnostics = null) {
|
|
7944
|
+
const lastError = normalizeTaskDiagnosticText(task?.lastError || "");
|
|
7945
|
+
const blockedReason = normalizeTaskDiagnosticText(task?.blockedReason || "");
|
|
7946
|
+
const errorPattern = String(task?.errorPattern || "").trim().toLowerCase();
|
|
7947
|
+
const apiErrorRecovery = supervisorDiagnostics?.apiErrorRecovery || null;
|
|
7948
|
+
const apiSignature = normalizeTaskDiagnosticText(apiErrorRecovery?.signature || "");
|
|
7949
|
+
const lastErrorLower = lastError.toLowerCase();
|
|
7950
|
+
const blockedReasonLower = blockedReason.toLowerCase();
|
|
7951
|
+
|
|
7952
|
+
if (lastErrorLower.includes("codex resume timeout")) {
|
|
7953
|
+
return {
|
|
7954
|
+
code: "codex_resume_timeout",
|
|
7955
|
+
title: "Codex resume timed out",
|
|
7956
|
+
severity: "warning",
|
|
7957
|
+
summary: "Bosun timed out while resuming a cached Codex thread and will start fresh on the next attempt.",
|
|
7958
|
+
};
|
|
7959
|
+
}
|
|
7960
|
+
if (
|
|
7961
|
+
lastErrorLower.includes("invalid_encrypted_content") ||
|
|
7962
|
+
lastErrorLower.includes("state db missing rollout path") ||
|
|
7963
|
+
lastErrorLower.includes("could not be verified") ||
|
|
7964
|
+
lastErrorLower.includes("tool_call_id")
|
|
7965
|
+
) {
|
|
7966
|
+
return {
|
|
7967
|
+
code: "codex_resume_corrupted_state",
|
|
7968
|
+
title: "Codex resume state is corrupted",
|
|
7969
|
+
severity: "error",
|
|
7970
|
+
summary: "Bosun detected poisoned Codex thread metadata and will discard the cached resume state.",
|
|
7971
|
+
};
|
|
7972
|
+
}
|
|
7973
|
+
if (errorPattern === "rate_limit") {
|
|
7974
|
+
return {
|
|
7975
|
+
code: "agent_rate_limit",
|
|
7976
|
+
title: "Agent is rate limited",
|
|
7977
|
+
severity: "warning",
|
|
7978
|
+
summary: "The assigned agent hit a rate limit and Bosun is waiting before retrying.",
|
|
7979
|
+
};
|
|
7980
|
+
}
|
|
7981
|
+
if (errorPattern === "token_overflow") {
|
|
7982
|
+
return {
|
|
7983
|
+
code: "token_overflow",
|
|
7984
|
+
title: "Context window exhausted",
|
|
7985
|
+
severity: "error",
|
|
7986
|
+
summary: "The current task exceeded the model context budget and needs a smaller prompt or a fresh session.",
|
|
7987
|
+
};
|
|
7988
|
+
}
|
|
7989
|
+
if (errorPattern === "api_error" || apiErrorRecovery) {
|
|
7990
|
+
return {
|
|
7991
|
+
code: Number(apiErrorRecovery?.cooldownUntil || 0) > Date.now()
|
|
7992
|
+
? "api_error_cooldown"
|
|
7993
|
+
: "api_error_recovery",
|
|
7994
|
+
title: "Transient API failure",
|
|
7995
|
+
severity: "warning",
|
|
7996
|
+
summary: "Bosun detected a backend API failure and is applying the task-level recovery ladder before escalating.",
|
|
7997
|
+
};
|
|
7998
|
+
}
|
|
7999
|
+
if (blockedReason && blockedReasonLower.includes("dependency")) {
|
|
8000
|
+
return {
|
|
8001
|
+
code: "dependency_blocked",
|
|
8002
|
+
title: "Dependency is still blocked",
|
|
8003
|
+
severity: "warning",
|
|
8004
|
+
summary: "Bosun is holding this task until one or more dependencies finish.",
|
|
8005
|
+
};
|
|
8006
|
+
}
|
|
8007
|
+
if (blockedReason) {
|
|
8008
|
+
return {
|
|
8009
|
+
code: "task_blocked",
|
|
8010
|
+
title: "Task is blocked",
|
|
8011
|
+
severity: "warning",
|
|
8012
|
+
summary: "Bosun recorded a blocking condition for this task and will not dispatch it until the condition clears.",
|
|
8013
|
+
};
|
|
8014
|
+
}
|
|
8015
|
+
if (lastError || apiSignature) {
|
|
8016
|
+
return {
|
|
8017
|
+
code: "agent_runtime_error",
|
|
8018
|
+
title: "Agent runtime error",
|
|
8019
|
+
severity: "error",
|
|
8020
|
+
summary: "Bosun recorded an agent-side runtime failure for this task.",
|
|
8021
|
+
};
|
|
8022
|
+
}
|
|
8023
|
+
return null;
|
|
8024
|
+
}
|
|
8025
|
+
|
|
8026
|
+
function buildTaskDiagnostics(task, supervisorDiagnostics = null) {
|
|
8027
|
+
if (!task || typeof task !== "object") return null;
|
|
8028
|
+
const apiErrorRecovery = supervisorDiagnostics?.apiErrorRecovery
|
|
8029
|
+
? makeJsonSafe(supervisorDiagnostics.apiErrorRecovery, { maxDepth: 4 })
|
|
8030
|
+
: null;
|
|
8031
|
+
const diagnostics = {
|
|
8032
|
+
stableCause: buildTaskStableCause(task, supervisorDiagnostics),
|
|
8033
|
+
lastError: normalizeTaskDiagnosticText(task?.lastError || "") || null,
|
|
8034
|
+
errorPattern: normalizeTaskDiagnosticText(task?.errorPattern || "") || null,
|
|
8035
|
+
blockedReason: normalizeTaskDiagnosticText(task?.blockedReason || "") || null,
|
|
8036
|
+
cooldownUntil: task?.cooldownUntil || apiErrorRecovery?.cooldownUntil || null,
|
|
8037
|
+
supervisor: supervisorDiagnostics
|
|
8038
|
+
? {
|
|
8039
|
+
interventionCount: Number(supervisorDiagnostics.interventionCount || 0),
|
|
8040
|
+
lastIntervention: supervisorDiagnostics.lastIntervention || null,
|
|
8041
|
+
lastDecision: supervisorDiagnostics.lastDecision
|
|
8042
|
+
? makeJsonSafe(supervisorDiagnostics.lastDecision, { maxDepth: 3 })
|
|
8043
|
+
: null,
|
|
8044
|
+
apiErrorRecovery,
|
|
8045
|
+
}
|
|
8046
|
+
: null,
|
|
8047
|
+
};
|
|
8048
|
+
if (
|
|
8049
|
+
!diagnostics.stableCause &&
|
|
8050
|
+
!diagnostics.lastError &&
|
|
8051
|
+
!diagnostics.errorPattern &&
|
|
8052
|
+
!diagnostics.blockedReason &&
|
|
8053
|
+
!diagnostics.cooldownUntil &&
|
|
8054
|
+
!diagnostics.supervisor
|
|
8055
|
+
) {
|
|
8056
|
+
return null;
|
|
8057
|
+
}
|
|
8058
|
+
return diagnostics;
|
|
8059
|
+
}
|
|
8060
|
+
|
|
7893
8061
|
async function maybeStartTaskFromLifecycleAction({
|
|
7894
8062
|
taskId,
|
|
7895
8063
|
updatedTask,
|
|
@@ -11364,6 +11532,12 @@ async function handleApi(req, res, url) {
|
|
|
11364
11532
|
reqUrl: url,
|
|
11365
11533
|
adapter,
|
|
11366
11534
|
});
|
|
11535
|
+
const supervisor = typeof uiDeps.getAgentSupervisor === "function"
|
|
11536
|
+
? uiDeps.getAgentSupervisor()
|
|
11537
|
+
: null;
|
|
11538
|
+
const supervisorDiagnostics = typeof supervisor?.getTaskDiagnostics === "function"
|
|
11539
|
+
? supervisor.getTaskDiagnostics(detailTask.id)
|
|
11540
|
+
: null;
|
|
11367
11541
|
|
|
11368
11542
|
const sprintId = resolveTaskSprintId(detailTask);
|
|
11369
11543
|
const sprintDag = includeDag && sprintId ? await getSprintDagData(sprintId) : null;
|
|
@@ -11373,6 +11547,7 @@ async function handleApi(req, res, url) {
|
|
|
11373
11547
|
workflowRuns: mergedWorkflowRuns,
|
|
11374
11548
|
workspaceDir: workspaceContext?.workspaceDir || repoRoot,
|
|
11375
11549
|
});
|
|
11550
|
+
const diagnostics = buildTaskDiagnostics(detailTask, supervisorDiagnostics);
|
|
11376
11551
|
|
|
11377
11552
|
detailTask.meta = {
|
|
11378
11553
|
...(detailTask.meta || {}),
|
|
@@ -11381,6 +11556,7 @@ async function handleApi(req, res, url) {
|
|
|
11381
11556
|
timelineCount: Array.isArray(detailTask.timeline) ? detailTask.timeline.length : 0,
|
|
11382
11557
|
canStart,
|
|
11383
11558
|
blockedContext,
|
|
11559
|
+
...(diagnostics ? { diagnostics } : {}),
|
|
11384
11560
|
...(sprintId ? { sprintId } : {}),
|
|
11385
11561
|
...(sprintDag ? { sprintDag: sprintDag.data } : {}),
|
|
11386
11562
|
...(globalDag ? { dagOfDags: globalDag.data } : {}),
|
|
@@ -11389,6 +11565,7 @@ async function handleApi(req, res, url) {
|
|
|
11389
11565
|
if (globalDag) detailTask.dagOfDags = globalDag.data;
|
|
11390
11566
|
detailTask.canStart = canStart;
|
|
11391
11567
|
detailTask.blockedContext = blockedContext;
|
|
11568
|
+
if (diagnostics) detailTask.diagnostics = diagnostics;
|
|
11392
11569
|
detailTask = withTaskRuntimeSnapshot(detailTask);
|
|
11393
11570
|
}
|
|
11394
11571
|
jsonResponse(res, 200, { ok: true, data: detailTask });
|
|
@@ -19169,8 +19346,16 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
19169
19346
|
req.url || "/",
|
|
19170
19347
|
`http://${req.headers.host || "localhost"}`,
|
|
19171
19348
|
);
|
|
19349
|
+
res.__bosunRequestContext = {
|
|
19350
|
+
diagnosticId: ensureResponseDiagnosticId(res),
|
|
19351
|
+
method: String(req?.method || "GET").toUpperCase(),
|
|
19352
|
+
path: url.pathname,
|
|
19353
|
+
query: url.search || "",
|
|
19354
|
+
};
|
|
19172
19355
|
const webhookPath = getGitHubWebhookPath();
|
|
19173
19356
|
|
|
19357
|
+
try {
|
|
19358
|
+
|
|
19174
19359
|
// Token exchange: ?token=<hex> → set session cookie and redirect to clean URL
|
|
19175
19360
|
const qToken = url.searchParams.get("token");
|
|
19176
19361
|
if (qToken && sessionToken) {
|
|
@@ -19312,6 +19497,21 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
19312
19497
|
}
|
|
19313
19498
|
}
|
|
19314
19499
|
await handleStatic(req, res, url);
|
|
19500
|
+
} catch (err) {
|
|
19501
|
+
if (res.headersSent) {
|
|
19502
|
+
console.error("[ui-server] unhandled request failure after headers sent", {
|
|
19503
|
+
diagnosticId: ensureResponseDiagnosticId(res),
|
|
19504
|
+
payload: describePayloadForErrorLog(err),
|
|
19505
|
+
});
|
|
19506
|
+
try {
|
|
19507
|
+
res.destroy?.(err);
|
|
19508
|
+
} catch {
|
|
19509
|
+
/* best effort */
|
|
19510
|
+
}
|
|
19511
|
+
return;
|
|
19512
|
+
}
|
|
19513
|
+
jsonResponse(res, 500, err);
|
|
19514
|
+
}
|
|
19315
19515
|
};
|
|
19316
19516
|
|
|
19317
19517
|
try {
|
package/tools/syntax-check.mjs
CHANGED
|
@@ -54,7 +54,14 @@ function validateModuleSyntax(filePath) {
|
|
|
54
54
|
function validateBrowserModuleSyntax(filePath) {
|
|
55
55
|
const source = readFileSync(filePath, "utf8");
|
|
56
56
|
const mod = new vm.SourceTextModule(source, { identifier: filePath });
|
|
57
|
-
|
|
57
|
+
let hasTLA = false;
|
|
58
|
+
const tlaProp = mod.hasTopLevelAwait;
|
|
59
|
+
if (typeof tlaProp === "function") {
|
|
60
|
+
hasTLA = !!tlaProp.call(mod);
|
|
61
|
+
} else if (typeof tlaProp === "boolean") {
|
|
62
|
+
hasTLA = tlaProp;
|
|
63
|
+
}
|
|
64
|
+
if (hasTLA) {
|
|
58
65
|
throw new Error(
|
|
59
66
|
"Top-level await is not allowed in browser-served modules because embedded WebViews can fail with 'Unexpected reserved word'.",
|
|
60
67
|
);
|
package/ui/demo-defaults.js
CHANGED
|
@@ -17877,7 +17877,7 @@
|
|
|
17877
17877
|
"command": "node",
|
|
17878
17878
|
"args": [
|
|
17879
17879
|
"-e",
|
|
17880
|
-
"\n
|
|
17880
|
+
"\n const fs = require(\"node:fs\");\n const path = require(\"node:path\");\n const { pathToFileURL } = require(\"node:url\");\n const cwd = process.cwd();\n const mirrorMarker = (path.sep + \".bosun\" + path.sep + \"workspaces\" + path.sep).toLowerCase();\n let repoRoot = cwd;\n if (cwd.toLowerCase().includes(mirrorMarker)) {\n const sourceRepoRoot = path.resolve(cwd, \"..\", \"..\", \"..\", \"..\");\n if (fs.existsSync(path.join(sourceRepoRoot, \"kanban\", \"kanban-adapter.mjs\"))) repoRoot = sourceRepoRoot;\n }\n const kanbanModuleUrl = pathToFileURL(path.join(repoRoot, \"kanban\", \"kanban-adapter.mjs\")).href;\n import(kanbanModuleUrl)\n .then(k => k.listTasks(undefined, { status: \"todo\" }))\n .then(tasks => {\n const filtered = (tasks || []).filter((task) => {\n const repository = typeof task?.repository === \"string\" ? task.repository.trim() : \"\";\n const workspace = typeof task?.workspace === \"string\" ? task.workspace.trim() : \"\";\n return task && task.status === \"todo\" && !task.draft && repository.length > 0 && workspace.length > 0;\n });\n const batch = filtered.slice(0, parseInt(process.env.MAX_BATCH || \"5\"));\n console.log(JSON.stringify(batch.map(t => ({\n taskId: t.id,\n taskTitle: t.title || t.id,\n branch: t.branch || t.metadata?.branch || null,\n repository: t.repository || null,\n workspace: t.workspace || null,\n }))));\n })\n .catch(e => { console.error(e.message); process.exit(1); });\n "
|
|
17881
17881
|
],
|
|
17882
17882
|
"env": {
|
|
17883
17883
|
"MAX_BATCH": "{{maxBatchSize}}"
|
|
@@ -18231,7 +18231,7 @@
|
|
|
18231
18231
|
"command": "node",
|
|
18232
18232
|
"args": [
|
|
18233
18233
|
"-e",
|
|
18234
|
-
"\n
|
|
18234
|
+
"\n const fs = require(\"node:fs\");\n const path = require(\"node:path\");\n const { pathToFileURL } = require(\"node:url\");\n const cwd = process.cwd();\n const mirrorMarker = (path.sep + \".bosun\" + path.sep + \"workspaces\" + path.sep).toLowerCase();\n let repoRoot = cwd;\n if (cwd.toLowerCase().includes(mirrorMarker)) {\n const sourceRepoRoot = path.resolve(cwd, \"..\", \"..\", \"..\", \"..\");\n if (fs.existsSync(path.join(sourceRepoRoot, \"kanban\", \"kanban-adapter.mjs\"))) repoRoot = sourceRepoRoot;\n }\n const kanbanModuleUrl = pathToFileURL(path.join(repoRoot, \"kanban\", \"kanban-adapter.mjs\")).href;\n import(kanbanModuleUrl)\n .then(k => k.listTasks(undefined, { status: \"todo\" }))\n .then(tasks => {\n const filtered = (tasks || []).filter((task) => {\n const repository = typeof task?.repository === \"string\" ? task.repository.trim() : \"\";\n const workspace = typeof task?.workspace === \"string\" ? task.workspace.trim() : \"\";\n return task && task.status === \"todo\" && !task.draft && repository.length > 0 && workspace.length > 0;\n });\n const batch = filtered.slice(0, parseInt(process.env.MAX_BATCH || \"10\"));\n console.log(JSON.stringify(batch.map(t => ({\n taskId: t.id,\n taskTitle: t.title || t.id,\n status: t.status,\n branch: t.branch || t.metadata?.branch || null,\n scope: t.scope || t.metadata?.scope || null,\n repository: typeof t?.repository === \"string\" ? t.repository.trim() : null,\n workspace: typeof t?.workspace === \"string\" ? t.workspace.trim() : null,\n }))));\n })\n .catch(e => { console.error(e.message); process.exit(1); });\n "
|
|
18235
18235
|
],
|
|
18236
18236
|
"env": {
|
|
18237
18237
|
"MAX_BATCH": "{{maxBatchSize}}"
|
|
@@ -37687,7 +37687,7 @@
|
|
|
37687
37687
|
"command": "node",
|
|
37688
37688
|
"args": [
|
|
37689
37689
|
"-e",
|
|
37690
|
-
"\n
|
|
37690
|
+
"\n const fs = require(\"node:fs\");\n const path = require(\"node:path\");\n const { pathToFileURL } = require(\"node:url\");\n const cwd = process.cwd();\n const mirrorMarker = (path.sep + \".bosun\" + path.sep + \"workspaces\" + path.sep).toLowerCase();\n let repoRoot = cwd;\n if (cwd.toLowerCase().includes(mirrorMarker)) {\n const sourceRepoRoot = path.resolve(cwd, \"..\", \"..\", \"..\", \"..\");\n if (fs.existsSync(path.join(sourceRepoRoot, \"kanban\", \"kanban-adapter.mjs\"))) repoRoot = sourceRepoRoot;\n }\n const kanbanModuleUrl = pathToFileURL(path.join(repoRoot, \"kanban\", \"kanban-adapter.mjs\")).href;\n import(kanbanModuleUrl)\n .then(k => k.listTasks(undefined, { status: \"todo\" }))\n .then(tasks => {\n const filtered = (tasks || []).filter((task) => {\n const repository = typeof task?.repository === \"string\" ? task.repository.trim() : \"\";\n const workspace = typeof task?.workspace === \"string\" ? task.workspace.trim() : \"\";\n return task && task.status === \"todo\" && !task.draft && repository.length > 0 && workspace.length > 0;\n });\n const batch = filtered.slice(0, parseInt(process.env.MAX_BATCH || \"5\"));\n console.log(JSON.stringify(batch.map(t => ({\n taskId: t.id,\n taskTitle: t.title || t.id,\n branch: t.branch || t.metadata?.branch || null,\n repository: t.repository || null,\n workspace: t.workspace || null,\n }))));\n })\n .catch(e => { console.error(e.message); process.exit(1); });\n "
|
|
37691
37691
|
],
|
|
37692
37692
|
"env": {
|
|
37693
37693
|
"MAX_BATCH": "{{maxBatchSize}}"
|
|
@@ -38029,7 +38029,7 @@
|
|
|
38029
38029
|
"command": "node",
|
|
38030
38030
|
"args": [
|
|
38031
38031
|
"-e",
|
|
38032
|
-
"\n
|
|
38032
|
+
"\n const fs = require(\"node:fs\");\n const path = require(\"node:path\");\n const { pathToFileURL } = require(\"node:url\");\n const cwd = process.cwd();\n const mirrorMarker = (path.sep + \".bosun\" + path.sep + \"workspaces\" + path.sep).toLowerCase();\n let repoRoot = cwd;\n if (cwd.toLowerCase().includes(mirrorMarker)) {\n const sourceRepoRoot = path.resolve(cwd, \"..\", \"..\", \"..\", \"..\");\n if (fs.existsSync(path.join(sourceRepoRoot, \"kanban\", \"kanban-adapter.mjs\"))) repoRoot = sourceRepoRoot;\n }\n const kanbanModuleUrl = pathToFileURL(path.join(repoRoot, \"kanban\", \"kanban-adapter.mjs\")).href;\n import(kanbanModuleUrl)\n .then(k => k.listTasks(undefined, { status: \"todo\" }))\n .then(tasks => {\n const filtered = (tasks || []).filter((task) => {\n const repository = typeof task?.repository === \"string\" ? task.repository.trim() : \"\";\n const workspace = typeof task?.workspace === \"string\" ? task.workspace.trim() : \"\";\n return task && task.status === \"todo\" && !task.draft && repository.length > 0 && workspace.length > 0;\n });\n const batch = filtered.slice(0, parseInt(process.env.MAX_BATCH || \"10\"));\n console.log(JSON.stringify(batch.map(t => ({\n taskId: t.id,\n taskTitle: t.title || t.id,\n status: t.status,\n branch: t.branch || t.metadata?.branch || null,\n scope: t.scope || t.metadata?.scope || null,\n repository: typeof t?.repository === \"string\" ? t.repository.trim() : null,\n workspace: typeof t?.workspace === \"string\" ? t.workspace.trim() : null,\n }))));\n })\n .catch(e => { console.error(e.message); process.exit(1); });\n "
|
|
38033
38033
|
],
|
|
38034
38034
|
"env": {
|
|
38035
38035
|
"MAX_BATCH": "{{maxBatchSize}}"
|
package/ui/modules/state.js
CHANGED
|
@@ -102,11 +102,31 @@ function synthesizeTaskDescription(task) {
|
|
|
102
102
|
return `Implementation notes for "${title}". Include scope, key files, risks, and acceptance checks before dispatch.`;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
function normalizeTaskDiagnosticsForUi(diagnostics) {
|
|
106
|
+
if (!diagnostics || typeof diagnostics !== "object") return diagnostics;
|
|
107
|
+
const stableCause = diagnostics.stableCause && typeof diagnostics.stableCause === "object"
|
|
108
|
+
? {
|
|
109
|
+
...diagnostics.stableCause,
|
|
110
|
+
code: sanitizeTaskText(diagnostics.stableCause.code || ""),
|
|
111
|
+
title: sanitizeTaskText(diagnostics.stableCause.title || ""),
|
|
112
|
+
summary: sanitizeTaskText(diagnostics.stableCause.summary || ""),
|
|
113
|
+
}
|
|
114
|
+
: diagnostics.stableCause;
|
|
115
|
+
return {
|
|
116
|
+
...diagnostics,
|
|
117
|
+
stableCause,
|
|
118
|
+
lastError: sanitizeTaskText(diagnostics.lastError || "") || null,
|
|
119
|
+
errorPattern: sanitizeTaskText(diagnostics.errorPattern || "") || null,
|
|
120
|
+
blockedReason: sanitizeTaskText(diagnostics.blockedReason || "") || null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
105
124
|
function normalizeTaskForUi(task) {
|
|
106
125
|
if (!task || typeof task !== "object") return task;
|
|
107
126
|
const title = sanitizeTaskText(task.title || "");
|
|
108
127
|
const rawDescription = sanitizeTaskText(task.description || "");
|
|
109
128
|
const description = isPlaceholderTaskDescription(rawDescription) ? "" : rawDescription;
|
|
129
|
+
const diagnostics = normalizeTaskDiagnosticsForUi(task.diagnostics);
|
|
110
130
|
const meta = task.meta && typeof task.meta === "object"
|
|
111
131
|
? {
|
|
112
132
|
...task.meta,
|
|
@@ -117,12 +137,14 @@ function normalizeTaskForUi(task) {
|
|
|
117
137
|
? ""
|
|
118
138
|
: sanitizeTaskText(task.meta.description))
|
|
119
139
|
: task.meta.description,
|
|
140
|
+
diagnostics: normalizeTaskDiagnosticsForUi(task.meta.diagnostics),
|
|
120
141
|
}
|
|
121
142
|
: task.meta;
|
|
122
143
|
return {
|
|
123
144
|
...task,
|
|
124
145
|
title,
|
|
125
146
|
description: description || synthesizeTaskDescription({ ...task, title }),
|
|
147
|
+
diagnostics,
|
|
126
148
|
meta,
|
|
127
149
|
};
|
|
128
150
|
}
|
package/ui/tabs/tasks.js
CHANGED
|
@@ -2702,6 +2702,17 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
2702
2702
|
task?.assignees,
|
|
2703
2703
|
task?.meta,
|
|
2704
2704
|
]);
|
|
2705
|
+
const taskDiagnostics = task?.diagnostics || task?.meta?.diagnostics || null;
|
|
2706
|
+
const stableCause = taskDiagnostics?.stableCause || null;
|
|
2707
|
+
const apiRecovery = taskDiagnostics?.supervisor?.apiErrorRecovery || null;
|
|
2708
|
+
const hasDiagnostics = Boolean(
|
|
2709
|
+
stableCause ||
|
|
2710
|
+
taskDiagnostics?.lastError ||
|
|
2711
|
+
taskDiagnostics?.errorPattern ||
|
|
2712
|
+
taskDiagnostics?.blockedReason ||
|
|
2713
|
+
taskDiagnostics?.cooldownUntil ||
|
|
2714
|
+
apiRecovery,
|
|
2715
|
+
);
|
|
2705
2716
|
const canStartInfo = task?.canStart || task?.meta?.canStart || null;
|
|
2706
2717
|
const blockedContext = task?.blockedContext || task?.meta?.blockedContext || null;
|
|
2707
2718
|
const blockedBy = Array.isArray(blockedContext?.blockedBy)
|
|
@@ -3539,6 +3550,58 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
|
|
|
3539
3550
|
</div>
|
|
3540
3551
|
`}
|
|
3541
3552
|
|
|
3553
|
+
${hasDiagnostics && html`
|
|
3554
|
+
<div class="task-section">
|
|
3555
|
+
<div class="task-section-title">Diagnostics</div>
|
|
3556
|
+
<div class="task-section-body">
|
|
3557
|
+
${stableCause && html`
|
|
3558
|
+
<div class="task-blocked-banner" data-category=${stableCause.severity || "diagnostic"}>
|
|
3559
|
+
<div class="task-blocked-banner-title">${stableCause.title || "Task diagnostics available"}</div>
|
|
3560
|
+
<div class="task-blocked-banner-copy">${stableCause.summary || "Bosun recorded a stable failure cause for this task."}</div>
|
|
3561
|
+
${stableCause.code && html`
|
|
3562
|
+
<div class="task-blocked-banner-copy">Stable cause: ${stableCause.code}</div>
|
|
3563
|
+
`}
|
|
3564
|
+
</div>
|
|
3565
|
+
`}
|
|
3566
|
+
|
|
3567
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin-top:12px;">
|
|
3568
|
+
${taskDiagnostics?.errorPattern && html`
|
|
3569
|
+
<div class="task-comment-item">
|
|
3570
|
+
<div class="task-comment-meta">Error pattern</div>
|
|
3571
|
+
<div class="task-comment-body">${taskDiagnostics.errorPattern}</div>
|
|
3572
|
+
</div>
|
|
3573
|
+
`}
|
|
3574
|
+
${taskDiagnostics?.cooldownUntil && html`
|
|
3575
|
+
<div class="task-comment-item">
|
|
3576
|
+
<div class="task-comment-meta">Cooldown until</div>
|
|
3577
|
+
<div class="task-comment-body">${formatRelative(taskDiagnostics.cooldownUntil)}</div>
|
|
3578
|
+
</div>
|
|
3579
|
+
`}
|
|
3580
|
+
${apiRecovery && html`
|
|
3581
|
+
<div class="task-comment-item">
|
|
3582
|
+
<div class="task-comment-meta">Continue attempts</div>
|
|
3583
|
+
<div class="task-comment-body">${Number(apiRecovery.continueAttempts || 0).toLocaleString("en-US")}</div>
|
|
3584
|
+
</div>
|
|
3585
|
+
`}
|
|
3586
|
+
${taskDiagnostics?.blockedReason && html`
|
|
3587
|
+
<div class="task-comment-item">
|
|
3588
|
+
<div class="task-comment-meta">Blocked reason</div>
|
|
3589
|
+
<div class="task-comment-body">${taskDiagnostics.blockedReason}</div>
|
|
3590
|
+
</div>
|
|
3591
|
+
`}
|
|
3592
|
+
</div>
|
|
3593
|
+
|
|
3594
|
+
${taskDiagnostics?.lastError && html`
|
|
3595
|
+
<div class="task-comments-list" style=${{ marginTop: "12px" }}>
|
|
3596
|
+
<div class="task-comment-item">
|
|
3597
|
+
<div class="task-comment-meta">Last backend error</div>
|
|
3598
|
+
<div class="task-comment-body">${taskDiagnostics.lastError}</div>
|
|
3599
|
+
</div>
|
|
3600
|
+
</div>
|
|
3601
|
+
`}
|
|
3602
|
+
</div>
|
|
3603
|
+
</div>
|
|
3604
|
+
`}
|
|
3542
3605
|
${/* Description */ ""}
|
|
3543
3606
|
<div class="task-section">
|
|
3544
3607
|
<div class="task-section-title">Description</div>
|
|
@@ -7695,4 +7758,3 @@ function CreateTaskModalInline({ onClose, initialValues = null, sprintOptions =
|
|
|
7695
7758
|
|
|
7696
7759
|
|
|
7697
7760
|
|
|
7698
|
-
|
|
@@ -2977,11 +2977,19 @@ registerBuiltinNodeType("action.run_command", {
|
|
|
2977
2977
|
type: "object",
|
|
2978
2978
|
properties: {
|
|
2979
2979
|
command: { type: "string", description: "Shell command to run" },
|
|
2980
|
+
args: {
|
|
2981
|
+
description: "Optional argv passed to the command without shell interpolation",
|
|
2982
|
+
oneOf: [
|
|
2983
|
+
{ type: "array", items: { type: ["string", "number", "boolean"] } },
|
|
2984
|
+
{ type: "string" },
|
|
2985
|
+
],
|
|
2986
|
+
},
|
|
2980
2987
|
cwd: { type: "string", description: "Working directory" },
|
|
2981
2988
|
env: { type: "object", description: "Environment variables passed to the command (supports templates)", additionalProperties: true },
|
|
2982
2989
|
timeoutMs: { type: "number", default: 300000 },
|
|
2983
2990
|
shell: { type: "string", default: "auto", enum: ["auto", "bash", "pwsh", "cmd"] },
|
|
2984
2991
|
captureOutput: { type: "boolean", default: true },
|
|
2992
|
+
parseJson: { type: "boolean", default: false, description: "Parse JSON output automatically" },
|
|
2985
2993
|
failOnError: { type: "boolean", default: false, description: "Throw on non-zero exit status (enables workflow retries)" },
|
|
2986
2994
|
},
|
|
2987
2995
|
required: ["command"],
|
|
@@ -3005,28 +3013,63 @@ registerBuiltinNodeType("action.run_command", {
|
|
|
3005
3013
|
}
|
|
3006
3014
|
|
|
3007
3015
|
const timeout = node.config?.timeoutMs || 300000;
|
|
3016
|
+
const resolvedArgsConfig = resolveWorkflowNodeValue(node.config?.args ?? [], ctx);
|
|
3017
|
+
const commandArgs = Array.isArray(resolvedArgsConfig)
|
|
3018
|
+
? resolvedArgsConfig.map((value) => String(value))
|
|
3019
|
+
: typeof resolvedArgsConfig === "string" && resolvedArgsConfig.trim()
|
|
3020
|
+
? [resolvedArgsConfig]
|
|
3021
|
+
: [];
|
|
3022
|
+
const shouldParseJson = node.config?.parseJson === true;
|
|
3023
|
+
const parseOutput = (rawOutput) => {
|
|
3024
|
+
const trimmed = rawOutput?.trim?.() ?? "";
|
|
3025
|
+
if (!shouldParseJson || !trimmed) return trimmed;
|
|
3026
|
+
try {
|
|
3027
|
+
return JSON.parse(trimmed);
|
|
3028
|
+
} catch {
|
|
3029
|
+
const lines = String(trimmed)
|
|
3030
|
+
.split(/\r?\n/)
|
|
3031
|
+
.map((line) => line.trim())
|
|
3032
|
+
.filter(Boolean);
|
|
3033
|
+
const candidate = lines.length > 0 ? lines[lines.length - 1] : trimmed;
|
|
3034
|
+
try {
|
|
3035
|
+
return JSON.parse(candidate);
|
|
3036
|
+
} catch {
|
|
3037
|
+
return trimmed;
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
};
|
|
3041
|
+
const usedArgv = commandArgs.length > 0;
|
|
3008
3042
|
|
|
3009
3043
|
if (command !== resolvedCommand) {
|
|
3010
3044
|
ctx.log(node.id, `Normalized legacy command for portability: ${command}`);
|
|
3011
3045
|
}
|
|
3012
|
-
ctx.log(node.id, `Running: ${command}`);
|
|
3046
|
+
ctx.log(node.id, `Running: ${usedArgv ? `${command} ${commandArgs.join(" ")}`.trim() : command}`);
|
|
3013
3047
|
try {
|
|
3014
|
-
const output =
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3048
|
+
const output = usedArgv
|
|
3049
|
+
? execFileSync(command, commandArgs, {
|
|
3050
|
+
cwd,
|
|
3051
|
+
timeout,
|
|
3052
|
+
encoding: "utf8",
|
|
3053
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
3054
|
+
stdio: node.config?.captureOutput !== false ? "pipe" : "inherit",
|
|
3055
|
+
env: commandEnv,
|
|
3056
|
+
})
|
|
3057
|
+
: execSync(command, {
|
|
3058
|
+
cwd,
|
|
3059
|
+
timeout,
|
|
3060
|
+
encoding: "utf8",
|
|
3061
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
3062
|
+
stdio: node.config?.captureOutput !== false ? "pipe" : "inherit",
|
|
3063
|
+
env: commandEnv,
|
|
3064
|
+
});
|
|
3022
3065
|
ctx.log(node.id, `Command succeeded`);
|
|
3023
|
-
return { success: true, output: output
|
|
3066
|
+
return { success: true, output: parseOutput(output), exitCode: 0 };
|
|
3024
3067
|
} catch (err) {
|
|
3025
3068
|
const output = err.stdout?.toString() || "";
|
|
3026
3069
|
const stderr = err.stderr?.toString() || "";
|
|
3027
3070
|
const result = {
|
|
3028
3071
|
success: false,
|
|
3029
|
-
output,
|
|
3072
|
+
output: parseOutput(output),
|
|
3030
3073
|
stderr,
|
|
3031
3074
|
exitCode: err.status,
|
|
3032
3075
|
error: err.message,
|
|
@@ -66,7 +66,18 @@ export const TASK_BATCH_PROCESSOR_TEMPLATE = {
|
|
|
66
66
|
node("query-tasks", "action.run_command", "Query Task Backlog", {
|
|
67
67
|
command: "node",
|
|
68
68
|
args: ["-e", `
|
|
69
|
-
|
|
69
|
+
const fs = require("node:fs");
|
|
70
|
+
const path = require("node:path");
|
|
71
|
+
const { pathToFileURL } = require("node:url");
|
|
72
|
+
const cwd = process.cwd();
|
|
73
|
+
const mirrorMarker = (path.sep + ".bosun" + path.sep + "workspaces" + path.sep).toLowerCase();
|
|
74
|
+
let repoRoot = cwd;
|
|
75
|
+
if (cwd.toLowerCase().includes(mirrorMarker)) {
|
|
76
|
+
const sourceRepoRoot = path.resolve(cwd, "..", "..", "..", "..");
|
|
77
|
+
if (fs.existsSync(path.join(sourceRepoRoot, "kanban", "kanban-adapter.mjs"))) repoRoot = sourceRepoRoot;
|
|
78
|
+
}
|
|
79
|
+
const kanbanModuleUrl = pathToFileURL(path.join(repoRoot, "kanban", "kanban-adapter.mjs")).href;
|
|
80
|
+
import(kanbanModuleUrl)
|
|
70
81
|
.then(k => k.listTasks(undefined, { status: "todo" }))
|
|
71
82
|
.then(tasks => {
|
|
72
83
|
const filtered = (tasks || []).filter((task) => {
|
|
@@ -181,7 +192,18 @@ export const TASK_BATCH_PR_TEMPLATE = {
|
|
|
181
192
|
node("query-tasks", "action.run_command", "List Todo Tasks", {
|
|
182
193
|
command: "node",
|
|
183
194
|
args: ["-e", `
|
|
184
|
-
|
|
195
|
+
const fs = require("node:fs");
|
|
196
|
+
const path = require("node:path");
|
|
197
|
+
const { pathToFileURL } = require("node:url");
|
|
198
|
+
const cwd = process.cwd();
|
|
199
|
+
const mirrorMarker = (path.sep + ".bosun" + path.sep + "workspaces" + path.sep).toLowerCase();
|
|
200
|
+
let repoRoot = cwd;
|
|
201
|
+
if (cwd.toLowerCase().includes(mirrorMarker)) {
|
|
202
|
+
const sourceRepoRoot = path.resolve(cwd, "..", "..", "..", "..");
|
|
203
|
+
if (fs.existsSync(path.join(sourceRepoRoot, "kanban", "kanban-adapter.mjs"))) repoRoot = sourceRepoRoot;
|
|
204
|
+
}
|
|
205
|
+
const kanbanModuleUrl = pathToFileURL(path.join(repoRoot, "kanban", "kanban-adapter.mjs")).href;
|
|
206
|
+
import(kanbanModuleUrl)
|
|
185
207
|
.then(k => k.listTasks(undefined, { status: "todo" }))
|
|
186
208
|
.then(tasks => {
|
|
187
209
|
const filtered = (tasks || []).filter((task) => {
|