bosun 0.41.2 → 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.
Files changed (73) hide show
  1. package/.env.example +1 -1
  2. package/agent/agent-pool.mjs +9 -2
  3. package/agent/agent-prompt-catalog.mjs +971 -0
  4. package/agent/agent-prompts.mjs +2 -970
  5. package/agent/agent-supervisor.mjs +119 -6
  6. package/agent/autofix-git.mjs +33 -0
  7. package/agent/autofix-prompts.mjs +151 -0
  8. package/agent/autofix.mjs +11 -175
  9. package/agent/bosun-skills.mjs +3 -2
  10. package/bosun.config.example.json +17 -0
  11. package/bosun.schema.json +87 -188
  12. package/cli.mjs +34 -1
  13. package/config/config-doctor.mjs +5 -250
  14. package/config/config-file-names.mjs +5 -0
  15. package/config/config.mjs +89 -493
  16. package/config/executor-config.mjs +493 -0
  17. package/config/repo-root.mjs +1 -2
  18. package/config/workspace-health.mjs +242 -0
  19. package/git/git-safety.mjs +15 -0
  20. package/github/github-oauth-portal.mjs +46 -0
  21. package/infra/library-manager-utils.mjs +22 -0
  22. package/infra/library-manager-well-known-sources.mjs +578 -0
  23. package/infra/library-manager.mjs +512 -1030
  24. package/infra/monitor.mjs +35 -9
  25. package/infra/session-tracker.mjs +10 -7
  26. package/kanban/kanban-adapter.mjs +17 -1
  27. package/lib/codebase-audit-manifests.mjs +117 -0
  28. package/lib/codebase-audit.mjs +18 -115
  29. package/package.json +18 -3
  30. package/server/setup-web-server.mjs +58 -5
  31. package/server/ui-server.mjs +1394 -79
  32. package/shell/codex-config-file.mjs +178 -0
  33. package/shell/codex-config.mjs +538 -575
  34. package/task/task-cli.mjs +54 -3
  35. package/task/task-executor.mjs +143 -13
  36. package/task/task-store.mjs +409 -1
  37. package/telegram/telegram-bot.mjs +127 -0
  38. package/tools/apply-pr-suggestions.mjs +401 -0
  39. package/tools/syntax-check.mjs +28 -9
  40. package/ui/app.js +3 -14
  41. package/ui/components/kanban-board.js +227 -4
  42. package/ui/components/session-list.js +85 -5
  43. package/ui/demo-defaults.js +338 -84
  44. package/ui/demo.html +155 -0
  45. package/ui/modules/session-api.js +96 -0
  46. package/ui/modules/settings-schema.js +1 -2
  47. package/ui/modules/state.js +43 -3
  48. package/ui/setup.html +4 -5
  49. package/ui/styles/components.css +58 -4
  50. package/ui/tabs/agents.js +12 -15
  51. package/ui/tabs/control.js +1 -0
  52. package/ui/tabs/library.js +484 -22
  53. package/ui/tabs/manual-flows.js +105 -29
  54. package/ui/tabs/tasks.js +848 -141
  55. package/ui/tabs/telemetry.js +129 -11
  56. package/ui/tabs/workflow-canvas-utils.mjs +130 -0
  57. package/ui/tabs/workflows.js +293 -23
  58. package/voice/voice-tool-definitions.mjs +757 -0
  59. package/voice/voice-tools.mjs +34 -778
  60. package/workflow/manual-flow-audit.mjs +165 -0
  61. package/workflow/manual-flows.mjs +164 -259
  62. package/workflow/workflow-engine.mjs +147 -58
  63. package/workflow/workflow-nodes/definitions.mjs +1207 -0
  64. package/workflow/workflow-nodes/transforms.mjs +612 -0
  65. package/workflow/workflow-nodes.mjs +358 -63
  66. package/workflow/workflow-templates.mjs +313 -191
  67. package/workflow-templates/_helpers.mjs +154 -0
  68. package/workflow-templates/agents.mjs +61 -4
  69. package/workflow-templates/code-quality.mjs +7 -7
  70. package/workflow-templates/github.mjs +20 -10
  71. package/workflow-templates/task-batch.mjs +44 -11
  72. package/workflow-templates/task-lifecycle.mjs +31 -6
  73. package/workspace/worktree-manager.mjs +277 -3
@@ -208,6 +208,7 @@ const TASK_STORE_SPRINT_EXPORTS = Object.freeze({
208
208
  const TASK_STORE_DAG_EXPORTS = Object.freeze({
209
209
  sprint: ["getSprintDag", "getTaskDagForSprint", "buildSprintDag", "buildTaskDag"],
210
210
  global: ["getGlobalDagOfDags", "getDagOfDags", "buildGlobalDagOfDags"],
211
+ organize: ["organizeTaskDag"],
211
212
  });
212
213
  const TASK_STORE_GET_TASK_EXPORTS = ["getTaskById", "getTask"];
213
214
  const TASK_STORE_COMMENT_EXPORTS = ["getTaskComments", "listTaskComments"];
@@ -308,6 +309,16 @@ function addInternalTaskComment(taskId, comment = {}) {
308
309
  }
309
310
  }
310
311
 
312
+ function unblockInternalTask(taskId, options = {}) {
313
+ const fn = getTaskStoreApiSync()?.unblockTask;
314
+ if (typeof fn !== "function") return null;
315
+ try {
316
+ return fn(taskId, options);
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+
311
322
  const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
312
323
  const repoRoot = resolveRepoRoot();
313
324
  const uiRootPreferred = resolve(__dirname, "..", "ui");
@@ -1104,6 +1115,73 @@ let _wfInitPromise = null;
1104
1115
  let _wfInitDone = false;
1105
1116
  let _wfLoadedBase = null;
1106
1117
  let _wfTaskTraceHookRegistered = false;
1118
+ let _workflowTelegramDigestPromise = null;
1119
+ const workflowTelegramDedup = new Map();
1120
+
1121
+ async function getWorkflowTelegramDigest() {
1122
+ if (_workflowTelegramDigestPromise) {
1123
+ return _workflowTelegramDigestPromise;
1124
+ }
1125
+ _workflowTelegramDigestPromise = (async () => {
1126
+ try {
1127
+ const mod = await import("../telegram/telegram-bot.mjs");
1128
+ if (typeof mod.restoreLiveDigest === "function") {
1129
+ await mod.restoreLiveDigest().catch(() => {});
1130
+ }
1131
+ return mod;
1132
+ } catch (err) {
1133
+ console.warn("[workflows/telegram] live digest unavailable:", err?.message || err);
1134
+ return null;
1135
+ }
1136
+ })();
1137
+ return _workflowTelegramDigestPromise;
1138
+ }
1139
+
1140
+ async function sendWorkflowTelegramMessage(chatId, text, options = {}) {
1141
+ const telegramToken = process.env.TELEGRAM_BOT_TOKEN;
1142
+ const defaultChatId = String(process.env.TELEGRAM_CHAT_ID || "").trim();
1143
+ const target = String(chatId || defaultChatId || "").trim();
1144
+ if (!telegramToken || !target) return;
1145
+
1146
+ const message = String(text || "");
1147
+ const dedupKey = `${target}:${message.trim()}`;
1148
+ const now = Date.now();
1149
+ const lastSentAt = workflowTelegramDedup.get(dedupKey) || 0;
1150
+ if (dedupKey && now - lastSentAt < 5 * 60 * 1000) {
1151
+ return;
1152
+ }
1153
+ workflowTelegramDedup.set(dedupKey, now);
1154
+
1155
+ const parseMode = String(options?.parseMode || "").trim();
1156
+ if (!parseMode && defaultChatId && target === defaultChatId) {
1157
+ const digest = await getWorkflowTelegramDigest();
1158
+ if (typeof digest?.notify === "function") {
1159
+ await digest.notify(message, 4, {
1160
+ silent: Boolean(options?.silent),
1161
+ category: "workflow",
1162
+ });
1163
+ return;
1164
+ }
1165
+ }
1166
+
1167
+ try {
1168
+ await fetch(
1169
+ `https://api.telegram.org/bot${telegramToken}/sendMessage`,
1170
+ {
1171
+ method: "POST",
1172
+ headers: { "Content-Type": "application/json" },
1173
+ body: JSON.stringify({
1174
+ chat_id: target,
1175
+ text: message,
1176
+ parse_mode: parseMode || "HTML",
1177
+ disable_notification: Boolean(options?.silent),
1178
+ }),
1179
+ },
1180
+ );
1181
+ } catch (e) {
1182
+ console.warn("[workflows/telegram] sendMessage failed:", e.message);
1183
+ }
1184
+ }
1107
1185
 
1108
1186
  /**
1109
1187
  * Test-only: inject a mock workflow engine module and pre-seed the per-workspace
@@ -1251,6 +1329,9 @@ async function getWorkflowEngineModule() {
1251
1329
  _wfEngine = await import(new URL("../workflow/workflow-engine.mjs", base).href);
1252
1330
  _wfNodes = await import(new URL("../workflow/workflow-nodes.mjs", base).href);
1253
1331
  _wfTemplates = await import(new URL("../workflow/workflow-templates.mjs", base).href);
1332
+ if (typeof _wfNodes?.ensureWorkflowNodeTypesLoaded === "function") {
1333
+ await _wfNodes.ensureWorkflowNodeTypesLoaded({ repoRoot });
1334
+ }
1254
1335
  if (_wfLoadedBase !== base) {
1255
1336
  console.log(`[workflows] Loaded workflow modules from: ${base}`);
1256
1337
  _wfLoadedBase = base;
@@ -1276,26 +1357,11 @@ async function getWorkflowEngineModule() {
1276
1357
  const telegramService = telegramToken
1277
1358
  ? {
1278
1359
  async sendMessage(chatId, text, options = {}) {
1279
- const target = chatId || telegramChatId;
1280
- if (!target) return;
1281
- try {
1282
- const parseMode = String(options?.parseMode || "").trim() || "HTML";
1283
- await fetch(
1284
- `https://api.telegram.org/bot${telegramToken}/sendMessage`,
1285
- {
1286
- method: "POST",
1287
- headers: { "Content-Type": "application/json" },
1288
- body: JSON.stringify({
1289
- chat_id: target,
1290
- text: String(text || ""),
1291
- parse_mode: parseMode,
1292
- disable_notification: Boolean(options?.silent),
1293
- }),
1294
- }
1295
- );
1296
- } catch (e) {
1297
- console.warn("[workflows/telegram] sendMessage failed:", e.message);
1298
- }
1360
+ await sendWorkflowTelegramMessage(
1361
+ chatId || telegramChatId,
1362
+ text,
1363
+ options,
1364
+ );
1299
1365
  },
1300
1366
  }
1301
1367
  : null;
@@ -1541,15 +1607,67 @@ function handleTaskWorkflowTraceEvent(event = {}) {
1541
1607
 
1542
1608
  function mergeTaskWorkflowRuns(baseRuns = [], extraRuns = [], limit = 60) {
1543
1609
  const merged = [];
1544
- const seen = new Set();
1610
+ const indexByKey = new Map();
1611
+ const resolveLinkedSessionId = (entry) => {
1612
+ const candidates = [
1613
+ entry?.sessionId,
1614
+ entry?.threadId,
1615
+ entry?.agentSessionId,
1616
+ entry?.meta?.sessionId,
1617
+ entry?.meta?.threadId,
1618
+ entry?.data?.sessionId,
1619
+ entry?.data?.threadId,
1620
+ ];
1621
+ for (const value of candidates) {
1622
+ const normalized = String(value || "").trim();
1623
+ if (normalized) return normalized;
1624
+ }
1625
+ return null;
1626
+ };
1627
+ const resolveSessionId = (entry) => {
1628
+ const directSessionId = resolveLinkedSessionId(entry);
1629
+ if (directSessionId) return directSessionId;
1630
+ const primarySessionId = String(entry?.primarySessionId || "").trim();
1631
+ return primarySessionId || null;
1632
+ };
1633
+ const mergeEntries = (current, incoming) => {
1634
+ const currentMeta = current?.meta && typeof current.meta === "object" ? current.meta : {};
1635
+ const incomingMeta = incoming?.meta && typeof incoming.meta === "object" ? incoming.meta : {};
1636
+ const mergedMeta = { ...currentMeta, ...incomingMeta };
1637
+ const currentMetaSessionId = String(currentMeta.sessionId || "").trim();
1638
+ const currentMetaThreadId = String(currentMeta.threadId || "").trim();
1639
+ if (currentMetaSessionId) mergedMeta.sessionId = currentMetaSessionId;
1640
+ if (currentMetaThreadId) mergedMeta.threadId = currentMetaThreadId;
1641
+ return {
1642
+ ...current,
1643
+ ...incoming,
1644
+ runId: incoming.runId || current.runId || null,
1645
+ workflowId: incoming.workflowId || current.workflowId || null,
1646
+ workflowName: incoming.workflowName || current.workflowName || null,
1647
+ status: incoming.status || current.status || null,
1648
+ outcome: incoming.outcome || current.outcome || null,
1649
+ summary: incoming.summary || current.summary || null,
1650
+ startedAt: incoming.startedAt || current.startedAt || null,
1651
+ endedAt: incoming.endedAt || current.endedAt || null,
1652
+ duration: incoming.duration ?? current.duration ?? null,
1653
+ url: incoming.url || current.url || null,
1654
+ nodeId: incoming.nodeId || current.nodeId || null,
1655
+ source: incoming.source || current.source || "workflow",
1656
+ sessionId: resolveLinkedSessionId(incoming) || resolveLinkedSessionId(current) || null,
1657
+ primarySessionId:
1658
+ String(incoming.primarySessionId || "").trim()
1659
+ || String(current.primarySessionId || "").trim()
1660
+ || resolveLinkedSessionId(incoming)
1661
+ || resolveLinkedSessionId(current),
1662
+ meta: mergedMeta,
1663
+ };
1664
+ };
1545
1665
  const push = (entry) => {
1546
1666
  if (!entry || typeof entry !== "object") return;
1547
1667
  const runId = String(entry.runId || "").trim();
1548
1668
  const workflowId = String(entry.workflowId || "").trim();
1549
1669
  const dedupKey = runId ? `run:${runId}` : `wf:${workflowId}:${entry.startedAt || entry.endedAt || ""}`;
1550
- if (seen.has(dedupKey)) return;
1551
- seen.add(dedupKey);
1552
- merged.push({
1670
+ const normalized = {
1553
1671
  runId: runId || null,
1554
1672
  workflowId: workflowId || null,
1555
1673
  workflowName: entry.workflowName != null ? String(entry.workflowName) : null,
@@ -1559,8 +1677,21 @@ function mergeTaskWorkflowRuns(baseRuns = [], extraRuns = [], limit = 60) {
1559
1677
  startedAt: entry.startedAt || null,
1560
1678
  endedAt: entry.endedAt || null,
1561
1679
  duration: Number.isFinite(Number(entry.duration)) ? Number(entry.duration) : null,
1680
+ url: entry.url != null ? String(entry.url) : null,
1681
+ nodeId: entry.nodeId != null ? String(entry.nodeId) : null,
1562
1682
  source: entry.source ? String(entry.source) : "workflow",
1563
- });
1683
+ sessionId: resolveLinkedSessionId(entry),
1684
+ primarySessionId:
1685
+ String(entry.primarySessionId || "").trim() || resolveSessionId(entry),
1686
+ meta: entry.meta && typeof entry.meta === "object" ? { ...entry.meta } : {},
1687
+ };
1688
+ const existingIndex = indexByKey.get(dedupKey);
1689
+ if (existingIndex == null) {
1690
+ indexByKey.set(dedupKey, merged.length);
1691
+ merged.push(normalized);
1692
+ return;
1693
+ }
1694
+ merged[existingIndex] = mergeEntries(merged[existingIndex], normalized);
1564
1695
  };
1565
1696
 
1566
1697
  for (const run of Array.isArray(baseRuns) ? baseRuns : []) push(run);
@@ -1599,6 +1730,33 @@ async function collectWorkflowRunsForTask(taskId, reqUrl, limit = 40) {
1599
1730
  matches = traceEvents.some((event) => String(event?.taskId || "").trim() === normalizedTaskId);
1600
1731
  }
1601
1732
  if (!matches) continue;
1733
+ const primarySessionId = (() => {
1734
+ for (const value of [
1735
+ data.sessionId,
1736
+ data.threadId,
1737
+ data?.task?.sessionId,
1738
+ data?.task?.threadId,
1739
+ ]) {
1740
+ const normalized = String(value || "").trim();
1741
+ if (normalized) return normalized;
1742
+ }
1743
+ const traceEvents = typeof engine.getTaskTraceEvents === "function"
1744
+ ? engine.getTaskTraceEvents(summary.runId) || []
1745
+ : [];
1746
+ for (let index = traceEvents.length - 1; index >= 0; index -= 1) {
1747
+ const event = traceEvents[index];
1748
+ for (const value of [
1749
+ event?.sessionId,
1750
+ event?.threadId,
1751
+ event?.meta?.sessionId,
1752
+ event?.meta?.threadId,
1753
+ ]) {
1754
+ const normalized = String(value || "").trim();
1755
+ if (normalized) return normalized;
1756
+ }
1757
+ }
1758
+ return null;
1759
+ })();
1602
1760
  out.push({
1603
1761
  runId: detail.runId,
1604
1762
  workflowId: detail.workflowId,
@@ -1611,6 +1769,8 @@ async function collectWorkflowRunsForTask(taskId, reqUrl, limit = 40) {
1611
1769
  startedAt: detail.startedAt || null,
1612
1770
  endedAt: detail.endedAt || null,
1613
1771
  duration: detail.duration || null,
1772
+ sessionId: null,
1773
+ primarySessionId,
1614
1774
  source: "workflow",
1615
1775
  });
1616
1776
  if (out.length >= limit) break;
@@ -1620,6 +1780,230 @@ async function collectWorkflowRunsForTask(taskId, reqUrl, limit = 40) {
1620
1780
  return [];
1621
1781
  }
1622
1782
  }
1783
+
1784
+ function sanitizeTaskDiagnosticText(value, maxLength = 240) {
1785
+ const normalized = String(value || "")
1786
+ .replace(/\s+/g, " ")
1787
+ .trim();
1788
+ if (!normalized) return "";
1789
+ if (!Number.isFinite(maxLength) || maxLength <= 0 || normalized.length <= maxLength) {
1790
+ return normalized;
1791
+ }
1792
+ return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
1793
+ }
1794
+
1795
+ function collectTaskTimelineDiagnostics(task, limit = 8) {
1796
+ const timeline = Array.isArray(task?.timeline) ? task.timeline : [];
1797
+ const relevant = [];
1798
+ for (const entry of timeline) {
1799
+ const message = sanitizeTaskDiagnosticText(
1800
+ entry?.message || entry?.reason || entry?.error || "",
1801
+ 280,
1802
+ );
1803
+ const status = String(entry?.status || "").trim().toLowerCase();
1804
+ if (
1805
+ !message &&
1806
+ status !== "blocked"
1807
+ ) {
1808
+ continue;
1809
+ }
1810
+ const isRelevant =
1811
+ status === "blocked" ||
1812
+ /worktree failed/i.test(message) ||
1813
+ /pre-pr validation failed/i.test(message) ||
1814
+ /claim was stolen/i.test(message) ||
1815
+ /blocked/i.test(message);
1816
+ if (!isRelevant) continue;
1817
+ relevant.push({
1818
+ source: String(entry?.source || entry?.type || "timeline").trim() || "timeline",
1819
+ message: message || `Task entered ${status || "blocked"} state`,
1820
+ timestamp: entry?.timestamp || entry?.createdAt || entry?.updatedAt || null,
1821
+ status: status || null,
1822
+ kind: "timeline",
1823
+ });
1824
+ }
1825
+ return relevant.slice(-Math.max(1, limit));
1826
+ }
1827
+
1828
+ function collectTaskLogDiagnostics(task, workspaceDir = "", limit = 8) {
1829
+ const taskId = String(task?.id || task?.taskId || "").trim();
1830
+ if (!taskId) {
1831
+ return {
1832
+ counts: {
1833
+ prePrValidationFailed: 0,
1834
+ worktreeFailed: 0,
1835
+ blockedTransitions: 0,
1836
+ createPrFailed: 0,
1837
+ },
1838
+ entries: [],
1839
+ };
1840
+ }
1841
+
1842
+ const taskBranch = String(task?.branch || task?.branchName || "").trim();
1843
+ const needles = [taskId, taskBranch].filter((value) => value && value.length >= 8);
1844
+ const logPaths = [];
1845
+ const pushLogPath = (candidate) => {
1846
+ if (!candidate || !existsSync(candidate) || logPaths.includes(candidate)) return;
1847
+ logPaths.push(candidate);
1848
+ };
1849
+ if (workspaceDir) {
1850
+ pushLogPath(resolve(workspaceDir, ".bosun", "logs", "monitor-error.log"));
1851
+ pushLogPath(resolve(workspaceDir, ".bosun", "logs", "monitor.log"));
1852
+ }
1853
+ pushLogPath(resolve(repoRoot, ".bosun", "logs", "monitor-error.log"));
1854
+ pushLogPath(resolve(repoRoot, ".bosun", "logs", "monitor.log"));
1855
+
1856
+ const counts = {
1857
+ prePrValidationFailed: 0,
1858
+ worktreeFailed: 0,
1859
+ blockedTransitions: 0,
1860
+ createPrFailed: 0,
1861
+ };
1862
+ const entries = [];
1863
+
1864
+ for (const logPath of logPaths) {
1865
+ let raw = "";
1866
+ try {
1867
+ raw = readFileSync(logPath, "utf8");
1868
+ } catch {
1869
+ continue;
1870
+ }
1871
+ const logName = /monitor-error\.log$/i.test(logPath) ? "monitor-error.log" : "monitor.log";
1872
+ for (const line of raw.split(/\r?\n/)) {
1873
+ if (!line) continue;
1874
+ if (!needles.some((needle) => line.includes(needle))) continue;
1875
+ const text = sanitizeTaskDiagnosticText(line, 320);
1876
+ let matched = false;
1877
+ if (/pre-PR validation failed/i.test(text)) {
1878
+ counts.prePrValidationFailed += 1;
1879
+ matched = true;
1880
+ }
1881
+ if (/Worktree failed for/i.test(text) || /Worktree acquisition failed/i.test(text)) {
1882
+ counts.worktreeFailed += 1;
1883
+ matched = true;
1884
+ }
1885
+ if (/-> blocked/i.test(text) || /status: .*blocked/i.test(text)) {
1886
+ counts.blockedTransitions += 1;
1887
+ matched = true;
1888
+ }
1889
+ if (/create-pr FAILED/i.test(text)) {
1890
+ counts.createPrFailed += 1;
1891
+ matched = true;
1892
+ }
1893
+ if (!matched) continue;
1894
+ entries.push({
1895
+ source: logName,
1896
+ message: text,
1897
+ kind: "log",
1898
+ });
1899
+ if (entries.length > limit) entries.shift();
1900
+ }
1901
+ }
1902
+
1903
+ return { counts, entries };
1904
+ }
1905
+
1906
+ function buildTaskBlockedContext(task, options = {}) {
1907
+ const currentTask = task && typeof task === "object" ? task : {};
1908
+ const canStart = options.canStart && typeof options.canStart === "object"
1909
+ ? options.canStart
1910
+ : null;
1911
+ const normalizedStatus = normalizeTaskStatusKey(currentTask?.status);
1912
+ const explicitReason = sanitizeTaskDiagnosticText(
1913
+ currentTask?.blockedReason
1914
+ || currentTask?.meta?.worktreeFailure?.blockedReason
1915
+ || currentTask?.meta?.autoRecovery?.error
1916
+ || currentTask?.meta?.worktreeFailure?.error
1917
+ || "",
1918
+ 280,
1919
+ );
1920
+ const workflowRuns = Array.isArray(options.workflowRuns)
1921
+ ? options.workflowRuns
1922
+ : Array.isArray(currentTask?.workflowRuns)
1923
+ ? currentTask.workflowRuns
1924
+ : [];
1925
+ const timelineEvidence = collectTaskTimelineDiagnostics(currentTask, 6);
1926
+ const logDiagnostics = collectTaskLogDiagnostics(
1927
+ currentTask,
1928
+ normalizeCandidatePath(options.workspaceDir),
1929
+ 6,
1930
+ );
1931
+ const hasPlannerCorruption = /planner payload corrupted/i.test(explicitReason);
1932
+ const hasWorktreeFailure =
1933
+ Boolean(currentTask?.meta?.worktreeFailure) ||
1934
+ logDiagnostics.counts.worktreeFailed > 0 ||
1935
+ timelineEvidence.some((entry) => /worktree failed/i.test(String(entry?.message || "")));
1936
+ const isDependencyBlocked = canStart?.canStart === false && String(canStart?.reason || "") === "dependency_blocked";
1937
+
1938
+ let category = "";
1939
+ let headline = "";
1940
+ let summary = "";
1941
+ let recommendation = "";
1942
+
1943
+ if (hasPlannerCorruption) {
1944
+ category = "planner_payload_corruption";
1945
+ headline = "Planner payload corruption quarantined this task.";
1946
+ summary = explicitReason;
1947
+ recommendation = "Do not requeue this task as-is. Recreate it from the fixed planner path or repair its payload first.";
1948
+ } else if (hasWorktreeFailure) {
1949
+ category = "worktree_failure";
1950
+ headline = "Task Lifecycle blocked this task after worktree acquisition failed.";
1951
+ summary = explicitReason
1952
+ || "Bosun could not acquire or refresh a clean managed worktree for this task.";
1953
+ recommendation = "If the worktree guard fix is now deployed, move the task back to todo to retry it on a fresh lifecycle run.";
1954
+ } else if (isDependencyBlocked) {
1955
+ category = "dependency_blocked";
1956
+ headline = "This task cannot start because one or more dependencies are not done yet.";
1957
+ summary = "Bosun will not dispatch this task until every blocking dependency below is resolved.";
1958
+ recommendation = "Complete or unblock the listed dependencies, then dispatch this task again.";
1959
+ } else if (normalizedStatus === "blocked") {
1960
+ category = "blocked";
1961
+ headline = "This task is blocked.";
1962
+ summary = explicitReason || "Bosun marked this task as blocked, but the original blocked reason was not persisted.";
1963
+ recommendation = "Review the recent workflow evidence below. After the underlying issue is fixed, move the task back to todo to clear the block and retry it.";
1964
+ } else if (canStart?.canStart === false) {
1965
+ category = "start_guard_blocked";
1966
+ headline = "This task is currently not startable.";
1967
+ summary = sanitizeTaskDiagnosticText(canStart?.reason || "Bosun start guards rejected dispatch for this task.");
1968
+ recommendation = "Resolve the blocking condition below before dispatching the task.";
1969
+ } else {
1970
+ return null;
1971
+ }
1972
+
1973
+ return {
1974
+ status: normalizedStatus,
1975
+ category,
1976
+ headline,
1977
+ summary,
1978
+ recommendation,
1979
+ reason: explicitReason || sanitizeTaskDiagnosticText(canStart?.reason || ""),
1980
+ workflowRunCount: workflowRuns.length,
1981
+ prePrValidationFailureCount: logDiagnostics.counts.prePrValidationFailed,
1982
+ worktreeFailureCount: logDiagnostics.counts.worktreeFailed,
1983
+ blockedTransitionCount: logDiagnostics.counts.blockedTransitions,
1984
+ createPrFailureCount: logDiagnostics.counts.createPrFailed,
1985
+ blockedBy: Array.isArray(canStart?.blockedBy) ? canStart.blockedBy : [],
1986
+ blockingTaskIds: Array.isArray(canStart?.blockingTaskIds) ? canStart.blockingTaskIds : [],
1987
+ timelineEvidence,
1988
+ logEvidence: logDiagnostics.entries,
1989
+ };
1990
+ }
1991
+
1992
+ function buildTaskMetaPatch(previousMeta, metadataPatchMeta, options = {}) {
1993
+ const clearBlockedState = options.clearBlockedState === true;
1994
+ const nextMeta = previousMeta && typeof previousMeta === "object"
1995
+ ? { ...previousMeta }
1996
+ : {};
1997
+ if (clearBlockedState) {
1998
+ delete nextMeta.autoRecovery;
1999
+ delete nextMeta.blockedReason;
2000
+ }
2001
+ if (metadataPatchMeta && typeof metadataPatchMeta === "object") {
2002
+ Object.assign(nextMeta, metadataPatchMeta);
2003
+ }
2004
+ return nextMeta;
2005
+ }
2006
+
1623
2007
  function maybeBootstrapWorkspaceWorkflowTemplates(engine, workspaceKey, workspaceLabel) {
1624
2008
  if (!engine || !_wfTemplates) return;
1625
2009
  if (_wfRecommendedInstalledByWorkspace.has(workspaceKey)) return;
@@ -2450,31 +2834,159 @@ function normalizeTriggerTemplateId(template = {}) {
2450
2834
  .toLowerCase();
2451
2835
  }
2452
2836
 
2837
+ function sanitizeTriggerTemplateInput(template = {}) {
2838
+ if (!template || typeof template !== "object" || Array.isArray(template)) {
2839
+ return {};
2840
+ }
2841
+ const sanitized = {};
2842
+ for (const key of [
2843
+ "id",
2844
+ "name",
2845
+ "description",
2846
+ "enabled",
2847
+ "action",
2848
+ "minIntervalMinutes",
2849
+ "trigger",
2850
+ "config",
2851
+ ]) {
2852
+ if (Object.prototype.hasOwnProperty.call(template, key)) {
2853
+ sanitized[key] = template[key];
2854
+ }
2855
+ }
2856
+ return sanitized;
2857
+ }
2858
+
2453
2859
  function normalizeTriggerTemplate(template = {}) {
2454
- const id = normalizeTriggerTemplateId(template);
2860
+ const source = sanitizeTriggerTemplateInput(template);
2861
+ const id = normalizeTriggerTemplateId(source);
2455
2862
  if (!id) return null;
2456
2863
  return {
2457
- ...template,
2458
2864
  id,
2459
- name: String(template?.name || id).trim() || id,
2460
- enabled: template?.enabled === true,
2461
- action: String(template?.action || "create-task").trim(),
2865
+ name: String(source?.name || id).trim() || id,
2866
+ description: String(source?.description || "").trim(),
2867
+ enabled: source?.enabled === true,
2868
+ action: String(source?.action || "create-task").trim(),
2462
2869
  minIntervalMinutes:
2463
- Number.isFinite(Number(template?.minIntervalMinutes)) &&
2464
- Number(template?.minIntervalMinutes) > 0
2465
- ? Number(template.minIntervalMinutes)
2870
+ Number.isFinite(Number(source?.minIntervalMinutes)) &&
2871
+ Number(source?.minIntervalMinutes) > 0
2872
+ ? Number(source.minIntervalMinutes)
2466
2873
  : undefined,
2467
2874
  trigger:
2468
- template?.trigger && typeof template.trigger === "object"
2469
- ? template.trigger
2875
+ source?.trigger && typeof source.trigger === "object"
2876
+ ? source.trigger
2470
2877
  : { anyOf: [] },
2471
2878
  config:
2472
- template?.config && typeof template.config === "object"
2473
- ? template.config
2879
+ source?.config && typeof source.config === "object"
2880
+ ? source.config
2474
2881
  : {},
2475
2882
  };
2476
2883
  }
2477
2884
 
2885
+ function buildTaskStateExportPayload(tasks = [], backend = "unknown") {
2886
+ return {
2887
+ schemaVersion: 1,
2888
+ kind: "bosun-task-state-export",
2889
+ exportedAt: new Date().toISOString(),
2890
+ backend,
2891
+ tasks: Array.isArray(tasks) ? tasks : [],
2892
+ };
2893
+ }
2894
+
2895
+ function extractImportedTaskList(body = null) {
2896
+ if (Array.isArray(body)) return body;
2897
+ if (body && typeof body === "object") {
2898
+ if (Array.isArray(body.tasks)) return body.tasks;
2899
+ if (Array.isArray(body.backlog)) return body.backlog;
2900
+ if (body.data && typeof body.data === "object" && Array.isArray(body.data.tasks)) {
2901
+ return body.data.tasks;
2902
+ }
2903
+ }
2904
+ return null;
2905
+ }
2906
+
2907
+ async function importInternalTaskStateSnapshot(body = {}) {
2908
+ const taskStore = await ensureTaskStoreApi();
2909
+ const addTaskFn = typeof taskStore?.addTask === "function" ? taskStore.addTask : null;
2910
+ const updateTaskFn = typeof taskStore?.updateTask === "function" ? taskStore.updateTask : null;
2911
+ if (!addTaskFn || !updateTaskFn) {
2912
+ throw new Error("Internal task store import is unavailable");
2913
+ }
2914
+
2915
+ const tasks = extractImportedTaskList(body);
2916
+ if (!Array.isArray(tasks)) {
2917
+ throw new Error("JSON must contain an array of tasks (top-level or under 'tasks' key)");
2918
+ }
2919
+
2920
+ const mode = String(body?.mode || "merge").trim().toLowerCase();
2921
+ if (!["merge", "upsert"].includes(mode)) {
2922
+ throw new Error("Only merge/upsert import mode is supported");
2923
+ }
2924
+
2925
+ const existingById = new Map(
2926
+ getAllInternalTasks()
2927
+ .filter((task) => task && task.id)
2928
+ .map((task) => [String(task.id), task]),
2929
+ );
2930
+ const summary = {
2931
+ total: tasks.length,
2932
+ created: 0,
2933
+ updated: 0,
2934
+ failed: 0,
2935
+ };
2936
+ const results = [];
2937
+
2938
+ for (const entry of tasks) {
2939
+ const task = entry && typeof entry === "object" && !Array.isArray(entry) ? entry : null;
2940
+ const taskId = String(task?.id || "").trim();
2941
+ if (!task || !taskId) {
2942
+ summary.failed += 1;
2943
+ results.push({ id: taskId || null, status: "failed", error: "task.id is required" });
2944
+ continue;
2945
+ }
2946
+ if (!String(task.title || "").trim()) {
2947
+ summary.failed += 1;
2948
+ results.push({ id: taskId, status: "failed", error: "task.title is required" });
2949
+ continue;
2950
+ }
2951
+
2952
+ try {
2953
+ if (existingById.has(taskId)) {
2954
+ updateTaskFn(taskId, task);
2955
+ summary.updated += 1;
2956
+ results.push({ id: taskId, status: "updated" });
2957
+ } else {
2958
+ addTaskFn(task);
2959
+ existingById.set(taskId, task);
2960
+ summary.created += 1;
2961
+ results.push({ id: taskId, status: "created" });
2962
+ }
2963
+ } catch (err) {
2964
+ summary.failed += 1;
2965
+ results.push({
2966
+ id: taskId,
2967
+ status: "failed",
2968
+ error: err?.message || "import failed",
2969
+ });
2970
+ }
2971
+ }
2972
+
2973
+ if (summary.created > 0 || summary.updated > 0) {
2974
+ const waitForWritesFn = typeof taskStore?.waitForStoreWrites === "function"
2975
+ ? taskStore.waitForStoreWrites
2976
+ : null;
2977
+ if (waitForWritesFn) {
2978
+ await waitForWritesFn();
2979
+ }
2980
+ }
2981
+
2982
+ return {
2983
+ backend: "internal",
2984
+ mode,
2985
+ summary,
2986
+ results,
2987
+ };
2988
+ }
2989
+
2478
2990
  function readConfigDocument() {
2479
2991
  const configPath = resolveConfigPath();
2480
2992
  let configData = { $schema: "./bosun.schema.json" };
@@ -2925,6 +3437,7 @@ async function applySharedStateToTasks(tasks) {
2925
3437
  function mapTaskStatusToBoardColumn(status) {
2926
3438
  const normalized = String(status || "").trim().toLowerCase();
2927
3439
  if (normalized === "draft") return "draft";
3440
+ if (["blocked", "error", "failed"].includes(normalized)) return "blocked";
2928
3441
  if (["inprogress", "in-progress", "working", "active", "assigned", "running"].includes(normalized)) return "inProgress";
2929
3442
  if (["inreview", "in-review", "review", "pr-open", "pr-review"].includes(normalized)) return "inReview";
2930
3443
  if (["done", "completed", "closed", "merged", "cancelled"].includes(normalized)) return "done";
@@ -3017,6 +3530,87 @@ function resolveActiveWorkspaceExecutionContext() {
3017
3530
  };
3018
3531
  }
3019
3532
 
3533
+ function resolveDefaultRepositoryForWorkspaceContext(workspaceContext = {}) {
3534
+ const configDir = resolveUiConfigDir();
3535
+ if (!configDir) return "";
3536
+ const listed = listManagedWorkspaces(configDir, { repoRoot });
3537
+ const workspaceId = String(workspaceContext?.workspaceId || "").trim().toLowerCase();
3538
+ const workspace =
3539
+ (workspaceId
3540
+ ? listed.find((entry) => String(entry?.id || "").trim().toLowerCase() === workspaceId)
3541
+ : null) ||
3542
+ getActiveManagedWorkspace(configDir) ||
3543
+ listed[0] ||
3544
+ null;
3545
+ if (!workspace) return "";
3546
+ return String(
3547
+ workspace?.activeRepo ||
3548
+ workspace?.repos?.find((repo) => repo?.primary)?.name ||
3549
+ workspace?.repos?.[0]?.name ||
3550
+ "",
3551
+ ).trim();
3552
+ }
3553
+
3554
+ async function resolveDefaultKanbanProjectId(adapter, requestedProjectId = "") {
3555
+ const explicitProjectId = String(requestedProjectId || "").trim();
3556
+ if (explicitProjectId) return explicitProjectId;
3557
+ if (!adapter || typeof adapter.listProjects !== "function") return "";
3558
+ try {
3559
+ const projects = await adapter.listProjects();
3560
+ return String(projects?.[0]?.id || projects?.[0]?.project_id || "").trim();
3561
+ } catch {
3562
+ return "";
3563
+ }
3564
+ }
3565
+
3566
+ function createManualFlowTaskManager(workspaceContext = {}, opts = {}) {
3567
+ return {
3568
+ async createTask(spec = {}) {
3569
+ const title = String(spec?.title || "").trim();
3570
+ if (!title) throw new Error("title is required");
3571
+
3572
+ const adapter = getKanbanAdapter();
3573
+ const projectId = await resolveDefaultKanbanProjectId(
3574
+ adapter,
3575
+ opts?.projectId || spec?.projectId || spec?.project || "",
3576
+ );
3577
+ const labels = normalizeTagsInput(spec?.labels || spec?.tags);
3578
+ const workspace = String(
3579
+ spec?.workspace || opts?.workspaceId || workspaceContext?.workspaceId || "",
3580
+ ).trim();
3581
+ const repository = String(
3582
+ spec?.repository ||
3583
+ spec?.meta?.repository ||
3584
+ opts?.repository ||
3585
+ resolveDefaultRepositoryForWorkspaceContext(workspaceContext),
3586
+ ).trim();
3587
+ const repositories = Array.isArray(spec?.repositories)
3588
+ ? spec.repositories.filter((value) => typeof value === "string" && value.trim())
3589
+ : [];
3590
+ const taskPayload = {
3591
+ title,
3592
+ description: String(spec?.description || ""),
3593
+ status: String(spec?.status || "todo").trim() || "todo",
3594
+ priority: spec?.priority || undefined,
3595
+ ...(workspace ? { workspace } : {}),
3596
+ ...(repository ? { repository } : {}),
3597
+ ...(repositories.length ? { repositories } : {}),
3598
+ ...(labels.length ? { labels, tags: labels } : {}),
3599
+ meta: {
3600
+ ...(workspace ? { workspace } : {}),
3601
+ ...(repository ? { repository } : {}),
3602
+ ...(repositories.length ? { repositories } : {}),
3603
+ ...(labels.length ? { tags: labels } : {}),
3604
+ manualFlowTemplateId: String(opts?.templateId || "").trim() || undefined,
3605
+ ...(spec?.meta && typeof spec.meta === "object" ? spec.meta : {}),
3606
+ },
3607
+ };
3608
+ const createdRaw = await adapter.createTask(projectId, taskPayload);
3609
+ return withTaskMetadataTopLevel(createdRaw);
3610
+ },
3611
+ };
3612
+ }
3613
+
3020
3614
  function resolveWorkspaceContextById(workspaceId = "") {
3021
3615
  const requestedId = String(workspaceId || "").trim().toLowerCase();
3022
3616
  if (!requestedId) return resolveActiveWorkspaceExecutionContext();
@@ -6371,6 +6965,53 @@ function normalizeJsonResponsePayload(payload) {
6371
6965
  return scrubStackTraces(payload);
6372
6966
  }
6373
6967
 
6968
+ function makeJsonSafe(value, options = {}) {
6969
+ const depth = Number.isFinite(options.depth) ? options.depth : 0;
6970
+ const maxDepth = Number.isFinite(options.maxDepth) ? options.maxDepth : 5;
6971
+ const seen = options.seen instanceof WeakSet ? options.seen : new WeakSet();
6972
+
6973
+ if (value == null) return value;
6974
+ if (typeof value === "string" || typeof value === "boolean") return value;
6975
+ if (typeof value === "number") return Number.isFinite(value) ? value : String(value);
6976
+ if (typeof value === "bigint") return String(value);
6977
+ if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") {
6978
+ return undefined;
6979
+ }
6980
+ if (value instanceof Date) {
6981
+ return Number.isFinite(value.getTime()) ? value.toISOString() : String(value);
6982
+ }
6983
+ if (value instanceof Error) {
6984
+ const errorValue = {
6985
+ name: String(value.name || "Error"),
6986
+ message: String(value.message || ""),
6987
+ };
6988
+ if (value.code != null) errorValue.code = String(value.code);
6989
+ return errorValue;
6990
+ }
6991
+ if (depth >= maxDepth) return "[Truncated]";
6992
+ if (typeof value !== "object") return String(value);
6993
+ if (seen.has(value)) return "[Circular]";
6994
+
6995
+ seen.add(value);
6996
+ try {
6997
+ if (Array.isArray(value)) {
6998
+ return value.map((entry) => {
6999
+ const normalized = makeJsonSafe(entry, { depth: depth + 1, maxDepth, seen });
7000
+ return normalized === undefined ? null : normalized;
7001
+ });
7002
+ }
7003
+
7004
+ const out = {};
7005
+ for (const [key, entry] of Object.entries(value)) {
7006
+ const normalized = makeJsonSafe(entry, { depth: depth + 1, maxDepth, seen });
7007
+ if (normalized !== undefined) out[key] = normalized;
7008
+ }
7009
+ return out;
7010
+ } finally {
7011
+ seen.delete(value);
7012
+ }
7013
+ }
7014
+
6374
7015
  function extractSafeErrorMessage(payload) {
6375
7016
  if (payload == null) return "Internal server error";
6376
7017
  if (payload instanceof Error) {
@@ -6391,13 +7032,58 @@ function extractSafeErrorMessage(payload) {
6391
7032
  return "Internal server error";
6392
7033
  }
6393
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
+
6394
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
+ }
6395
7080
  const normalizedPayload = normalizeJsonResponsePayload(payload);
6396
7081
  const safePayload =
6397
7082
  statusCode >= 500
6398
7083
  ? {
6399
7084
  ok: false,
6400
7085
  error: extractSafeErrorMessage(normalizedPayload),
7086
+ diagnosticId,
6401
7087
  }
6402
7088
  : normalizedPayload;
6403
7089
  const body = JSON.stringify(safePayload, null, 2);
@@ -6505,7 +7191,7 @@ function normalizeCanStartResult(result, { override = false } = {}) {
6505
7191
  missingDependencyTaskIds,
6506
7192
  blockingSprintIds,
6507
7193
  blockingEpicIds,
6508
- raw,
7194
+ raw: makeJsonSafe(raw),
6509
7195
  };
6510
7196
  }
6511
7197
 
@@ -6658,6 +7344,15 @@ async function getGlobalDagData() {
6658
7344
  };
6659
7345
  }
6660
7346
 
7347
+ async function organizeDagData(options = {}) {
7348
+ const organizeResult = await callTaskStoreFunction(TASK_STORE_DAG_EXPORTS.organize, [options]);
7349
+ if (!organizeResult.found) return null;
7350
+ return {
7351
+ source: `task-store.${organizeResult.found}`,
7352
+ data: organizeResult.value,
7353
+ };
7354
+ }
7355
+
6661
7356
  function normalizeTaskComments(comments = []) {
6662
7357
  if (!Array.isArray(comments)) return [];
6663
7358
  return comments
@@ -7240,6 +7935,129 @@ function withTaskRuntimeSnapshot(task) {
7240
7935
  };
7241
7936
  }
7242
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
+
7243
8061
  async function maybeStartTaskFromLifecycleAction({
7244
8062
  taskId,
7245
8063
  updatedTask,
@@ -9056,12 +9874,12 @@ function runGit(args, timeoutMs = 10000) {
9056
9874
  return String(res.stdout || "").trim();
9057
9875
  }
9058
9876
 
9059
- async function readJsonBody(req) {
9877
+ async function readJsonBody(req, maxBytes = 1_000_000) {
9060
9878
  return new Promise((resolveBody, rejectBody) => {
9061
9879
  let data = "";
9062
9880
  req.on("data", (chunk) => {
9063
9881
  data += chunk;
9064
- if (data.length > 1_000_000) {
9882
+ if (data.length > maxBytes) {
9065
9883
  rejectBody(new Error("payload too large"));
9066
9884
  req.destroy();
9067
9885
  }
@@ -9279,6 +10097,14 @@ function buildTaskMetadataPatch(input = {}) {
9279
10097
  }
9280
10098
  }
9281
10099
 
10100
+ if (hasOwn(input, "blockedReason")) {
10101
+ const blockedReason = normalizeOptionalStringInput(input?.blockedReason);
10102
+ if (blockedReason) {
10103
+ topLevel.blockedReason = blockedReason;
10104
+ meta.blockedReason = blockedReason;
10105
+ }
10106
+ }
10107
+
9282
10108
  return { topLevel, meta };
9283
10109
  }
9284
10110
 
@@ -9464,13 +10290,105 @@ async function readJsonlTail(filePath, maxLines = 2000) {
9464
10290
  .filter(Boolean);
9465
10291
  }
9466
10292
 
10293
+ function getEntryTimestamp(entry) {
10294
+ const numericCandidates = [
10295
+ entry?.endedAt,
10296
+ entry?.startedAt,
10297
+ ];
10298
+ for (const candidate of numericCandidates) {
10299
+ const parsed = Number(candidate);
10300
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
10301
+ }
10302
+
10303
+ const isoCandidates = [
10304
+ entry?.timestamp,
10305
+ entry?.recordedAt,
10306
+ entry?.updatedAt,
10307
+ entry?.createdAt,
10308
+ ];
10309
+ for (const candidate of isoCandidates) {
10310
+ const parsed = Date.parse(candidate || "");
10311
+ if (Number.isFinite(parsed)) return parsed;
10312
+ }
10313
+ return Number.NaN;
10314
+ }
10315
+
10316
+ function getEntryDayKey(entry, fallbackTs = Number.NaN) {
10317
+ const isoCandidates = [
10318
+ entry?.timestamp,
10319
+ entry?.recordedAt,
10320
+ entry?.updatedAt,
10321
+ entry?.createdAt,
10322
+ ];
10323
+ for (const candidate of isoCandidates) {
10324
+ const value = String(candidate || "").trim();
10325
+ if (value.length >= 10) return value.slice(0, 10);
10326
+ }
10327
+ const ts = Number.isFinite(fallbackTs) ? fallbackTs : getEntryTimestamp(entry);
10328
+ if (!Number.isFinite(ts)) return "";
10329
+ return new Date(ts).toISOString().slice(0, 10);
10330
+ }
10331
+
9467
10332
  function withinDays(entry, days) {
9468
10333
  if (!days) return true;
9469
- const ts = Date.parse(entry?.timestamp || "");
10334
+ const ts = getEntryTimestamp(entry);
9470
10335
  if (!Number.isFinite(ts)) return true;
9471
10336
  return ts >= Date.now() - days * 24 * 60 * 60 * 1000;
9472
10337
  }
9473
10338
 
10339
+ async function readCompletedSessionEntries(maxLines = 100_000) {
10340
+ const sessionLogPath = resolve(repoRoot, ".cache", "session-accumulator.jsonl");
10341
+ const entries = await readJsonlTail(sessionLogPath, maxLines);
10342
+ return {
10343
+ sessionLogPath,
10344
+ entries: entries.filter((entry) => String(entry?.type || "completed_session") === "completed_session"),
10345
+ };
10346
+ }
10347
+
10348
+ function roundMetric(value, precision = 6) {
10349
+ const numeric = Number(value);
10350
+ if (!Number.isFinite(numeric)) return 0;
10351
+ return Number(numeric.toFixed(precision));
10352
+ }
10353
+
10354
+ const SHREDDING_ESTIMATED_CHARS_PER_TOKEN = 4;
10355
+
10356
+ function estimateTokensFromChars(chars) {
10357
+ const numeric = Number(chars);
10358
+ if (!Number.isFinite(numeric) || numeric <= 0) return 0;
10359
+ return Math.max(0, Math.round(numeric / SHREDDING_ESTIMATED_CHARS_PER_TOKEN));
10360
+ }
10361
+
10362
+ function summarizeObservedSessionCostModel(entries = []) {
10363
+ let totalCostUsd = 0;
10364
+ let totalTokens = 0;
10365
+ let totalInputTokens = 0;
10366
+ let pricedSessions = 0;
10367
+ for (const entry of entries) {
10368
+ const costUsd = numberOrZero(entry?.costUsd);
10369
+ const tokenCount = numberOrZero(entry?.tokenCount);
10370
+ const inputTokens = numberOrZero(entry?.inputTokens);
10371
+ if (costUsd <= 0 || tokenCount <= 0) continue;
10372
+ totalCostUsd += costUsd;
10373
+ totalTokens += tokenCount;
10374
+ totalInputTokens += inputTokens;
10375
+ pricedSessions += 1;
10376
+ }
10377
+ const blendedCostPerToken = totalCostUsd > 0 && totalTokens > 0
10378
+ ? totalCostUsd / totalTokens
10379
+ : null;
10380
+ return {
10381
+ pricedSessions,
10382
+ totalCostUsd: roundMetric(totalCostUsd),
10383
+ totalTokens,
10384
+ totalInputTokens,
10385
+ blendedCostPerToken,
10386
+ blendedCostPerMillionTokensUsd: blendedCostPerToken != null
10387
+ ? roundMetric(blendedCostPerToken * 1_000_000, 4)
10388
+ : null,
10389
+ };
10390
+ }
10391
+
9474
10392
  function summarizeTelemetry(metrics, days) {
9475
10393
  const filtered = metrics.filter((m) => withinDays(m, days));
9476
10394
  if (filtered.length === 0) return null;
@@ -9562,7 +10480,10 @@ function isEffectiveShreddingEvent(event) {
9562
10480
  async function buildUsageAnalytics(days) {
9563
10481
  const logDir = resolveAgentWorkLogDir();
9564
10482
  const streamPath = resolve(logDir, "agent-work-stream.jsonl");
9565
- const events = await readJsonlTail(streamPath, 100_000);
10483
+ const [{ entries: completedSessions }, events] = await Promise.all([
10484
+ readCompletedSessionEntries(100_000),
10485
+ readJsonlTail(streamPath, 100_000),
10486
+ ]);
9566
10487
 
9567
10488
  const cutoff = days ? Date.now() - days * 24 * 60 * 60 * 1000 : 0;
9568
10489
 
@@ -9588,22 +10509,50 @@ async function buildUsageAnalytics(days) {
9588
10509
 
9589
10510
  const allDates = new Set();
9590
10511
 
10512
+ const sessionWindow = completedSessions.filter((session) => {
10513
+ const ts = getEntryTimestamp(session);
10514
+ return !cutoff || (Number.isFinite(ts) && ts >= cutoff);
10515
+ });
10516
+
10517
+ if (sessionWindow.length > 0) {
10518
+ for (const session of sessionWindow) {
10519
+ const ts = getEntryTimestamp(session);
10520
+ if (!Number.isFinite(ts)) continue;
10521
+ if (ts < oldestTs) oldestTs = ts;
10522
+ if (ts > newestTs) newestTs = ts;
10523
+ const day = getEntryDayKey(session, ts);
10524
+ if (day) allDates.add(day);
10525
+
10526
+ agentRuns += 1;
10527
+ const exec = String(session.executor || session.model || "unknown").trim() || "unknown";
10528
+ agents.set(exec, (agents.get(exec) || 0) + 1);
10529
+ if (day) {
10530
+ (dailyAgents[day] = dailyAgents[day] || {})[exec] =
10531
+ (dailyAgents[day][exec] || 0) + 1;
10532
+ }
10533
+ }
10534
+ }
10535
+
10536
+ let streamSessionStarts = 0;
9591
10537
  for (const e of events) {
9592
- const ts = Date.parse(e.timestamp || "");
10538
+ const ts = getEntryTimestamp(e);
9593
10539
  if (!Number.isFinite(ts)) continue;
9594
10540
  if (cutoff && ts < cutoff) continue;
9595
10541
  if (ts < oldestTs) oldestTs = ts;
9596
10542
  if (ts > newestTs) newestTs = ts;
9597
- const day = (e.timestamp || "").slice(0, 10);
10543
+ const day = getEntryDayKey(e, ts);
9598
10544
  if (day) allDates.add(day);
9599
10545
 
9600
10546
  if (e.event_type === "session_start") {
9601
- agentRuns++;
9602
- const exec = e.executor || "unknown";
9603
- agents.set(exec, (agents.get(exec) || 0) + 1);
9604
- if (day) {
9605
- (dailyAgents[day] = dailyAgents[day] || {})[exec] =
9606
- (dailyAgents[day][exec] || 0) + 1;
10547
+ streamSessionStarts += 1;
10548
+ if (sessionWindow.length === 0) {
10549
+ agentRuns++;
10550
+ const exec = e.executor || "unknown";
10551
+ agents.set(exec, (agents.get(exec) || 0) + 1);
10552
+ if (day) {
10553
+ (dailyAgents[day] = dailyAgents[day] || {})[exec] =
10554
+ (dailyAgents[day][exec] || 0) + 1;
10555
+ }
9607
10556
  }
9608
10557
  } else if (e.event_type === "skill_invoke") {
9609
10558
  skillInvocations++;
@@ -9669,6 +10618,11 @@ async function buildUsageAnalytics(days) {
9669
10618
  topSkills,
9670
10619
  topMcpTools,
9671
10620
  trend,
10621
+ diagnostics: {
10622
+ agentRunSource: sessionWindow.length > 0 ? "completed_sessions" : "session_start_events",
10623
+ completedSessions: sessionWindow.length,
10624
+ sessionStarts: streamSessionStarts,
10625
+ },
9672
10626
  };
9673
10627
  }
9674
10628
 
@@ -10391,6 +11345,44 @@ async function handleApi(req, res, url) {
10391
11345
  return;
10392
11346
  }
10393
11347
 
11348
+ if (path === "/api/tasks/export") {
11349
+ try {
11350
+ const adapter = getKanbanAdapter();
11351
+ const tasks = await listAllTasksForApi(adapter);
11352
+ jsonResponse(res, 200, {
11353
+ ok: true,
11354
+ data: buildTaskStateExportPayload(tasks, getKanbanBackendName()),
11355
+ });
11356
+ } catch (err) {
11357
+ jsonResponse(res, 500, { ok: false, error: err.message });
11358
+ }
11359
+ return;
11360
+ }
11361
+
11362
+ if (path === "/api/tasks/import" && req.method === "POST") {
11363
+ try {
11364
+ const backend = getKanbanBackendName();
11365
+ if (backend !== "internal") {
11366
+ jsonResponse(res, 400, {
11367
+ ok: false,
11368
+ error: "Task state import is only supported for the internal backend.",
11369
+ });
11370
+ return;
11371
+ }
11372
+ const body = await readJsonBody(req, 10_000_000);
11373
+ const imported = await importInternalTaskStateSnapshot(body || {});
11374
+ jsonResponse(res, 200, { ok: true, data: imported });
11375
+ broadcastUiEvent(["tasks", "overview"], "invalidate", {
11376
+ reason: "task-state-imported",
11377
+ created: imported.summary?.created || 0,
11378
+ updated: imported.summary?.updated || 0,
11379
+ });
11380
+ } catch (err) {
11381
+ jsonResponse(res, 400, { ok: false, error: err.message });
11382
+ }
11383
+ return;
11384
+ }
11385
+
10394
11386
  if (path === "/api/tasks") {
10395
11387
  const status = url.searchParams.get("status") || "";
10396
11388
  const projectId = url.searchParams.get("project") || "";
@@ -10477,6 +11469,7 @@ async function handleApi(req, res, url) {
10477
11469
  const statusCounts = {
10478
11470
  draft: 0,
10479
11471
  backlog: 0,
11472
+ blocked: 0,
10480
11473
  inProgress: 0,
10481
11474
  inReview: 0,
10482
11475
  done: 0,
@@ -10510,6 +11503,11 @@ async function handleApi(req, res, url) {
10510
11503
  try {
10511
11504
  const taskId =
10512
11505
  url.searchParams.get("taskId") || url.searchParams.get("id") || "";
11506
+ const includeDagParam = String(url.searchParams.get("includeDag") || "").trim().toLowerCase();
11507
+ const includeWorkflowRunsParam = String(url.searchParams.get("includeWorkflowRuns") || "").trim().toLowerCase();
11508
+ const includeDag = !["0", "false", "no"].includes(includeDagParam);
11509
+ const includeWorkflowRuns = !["0", "false", "no"].includes(includeWorkflowRunsParam);
11510
+ const workspaceContext = resolveWorkspaceContextFromRequest(url, { allowAll: false });
10513
11511
  if (!taskId) {
10514
11512
  jsonResponse(res, 400, { ok: false, error: "taskId required" });
10515
11513
  return;
@@ -10519,25 +11517,55 @@ async function handleApi(req, res, url) {
10519
11517
  const enriched = await applySharedStateToTasks(task ? [task] : []);
10520
11518
  let detailTask = enriched[0] || null;
10521
11519
  if (detailTask) {
10522
- const workflowRuns = await collectWorkflowRunsForTask(detailTask.id, url, 40);
10523
- const mergedWorkflowRuns = mergeTaskWorkflowRuns(detailTask.workflowRuns, workflowRuns, 80);
11520
+ const workflowRuns = includeWorkflowRuns
11521
+ ? await collectWorkflowRunsForTask(detailTask.id, url, 40)
11522
+ : [];
11523
+ const mergedWorkflowRuns = includeWorkflowRuns
11524
+ ? mergeTaskWorkflowRuns(detailTask.workflowRuns, workflowRuns, 80)
11525
+ : Array.isArray(detailTask.workflowRuns)
11526
+ ? detailTask.workflowRuns
11527
+ : [];
10524
11528
  detailTask.workflowRuns = mergedWorkflowRuns;
11529
+ const canStart = await evaluateTaskCanStart({
11530
+ taskId: detailTask.id,
11531
+ task: detailTask,
11532
+ reqUrl: url,
11533
+ adapter,
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;
10525
11541
 
10526
11542
  const sprintId = resolveTaskSprintId(detailTask);
10527
- const sprintDag = sprintId ? await getSprintDagData(sprintId) : null;
10528
- const globalDag = await getGlobalDagData();
11543
+ const sprintDag = includeDag && sprintId ? await getSprintDagData(sprintId) : null;
11544
+ const globalDag = includeDag ? await getGlobalDagData() : null;
11545
+ const blockedContext = buildTaskBlockedContext(detailTask, {
11546
+ canStart,
11547
+ workflowRuns: mergedWorkflowRuns,
11548
+ workspaceDir: workspaceContext?.workspaceDir || repoRoot,
11549
+ });
11550
+ const diagnostics = buildTaskDiagnostics(detailTask, supervisorDiagnostics);
10529
11551
 
10530
11552
  detailTask.meta = {
10531
11553
  ...(detailTask.meta || {}),
10532
11554
  workflowRuns: mergedWorkflowRuns,
10533
11555
  historyCount: Array.isArray(detailTask.statusHistory) ? detailTask.statusHistory.length : 0,
10534
11556
  timelineCount: Array.isArray(detailTask.timeline) ? detailTask.timeline.length : 0,
11557
+ canStart,
11558
+ blockedContext,
11559
+ ...(diagnostics ? { diagnostics } : {}),
10535
11560
  ...(sprintId ? { sprintId } : {}),
10536
11561
  ...(sprintDag ? { sprintDag: sprintDag.data } : {}),
10537
11562
  ...(globalDag ? { dagOfDags: globalDag.data } : {}),
10538
11563
  };
10539
11564
  if (sprintDag) detailTask.sprintDag = sprintDag.data;
10540
11565
  if (globalDag) detailTask.dagOfDags = globalDag.data;
11566
+ detailTask.canStart = canStart;
11567
+ detailTask.blockedContext = blockedContext;
11568
+ if (diagnostics) detailTask.diagnostics = diagnostics;
10541
11569
  detailTask = withTaskRuntimeSnapshot(detailTask);
10542
11570
  }
10543
11571
  jsonResponse(res, 200, { ok: true, data: detailTask });
@@ -11488,6 +12516,41 @@ async function handleApi(req, res, url) {
11488
12516
  }
11489
12517
  return;
11490
12518
  }
12519
+
12520
+ if (path === "/api/tasks/dag/organize" && req.method === "POST") {
12521
+ try {
12522
+ const body = await readJsonBody(req);
12523
+ const sprintId = String(body?.sprintId || body?.sprint || "").trim();
12524
+ const organizeOptions = {
12525
+ ...(sprintId ? { sprintId } : {}),
12526
+ ...(body?.applyDependencySuggestions != null
12527
+ ? { applyDependencySuggestions: Boolean(body.applyDependencySuggestions) }
12528
+ : {}),
12529
+ ...(body?.syncEpicDependencies != null
12530
+ ? { syncEpicDependencies: Boolean(body.syncEpicDependencies) }
12531
+ : {}),
12532
+ };
12533
+ const organized = await organizeDagData(organizeOptions);
12534
+ if (!organized) {
12535
+ jsonResponse(res, 501, { ok: false, error: "DAG organize API is unavailable." });
12536
+ return;
12537
+ }
12538
+ jsonResponse(res, 200, {
12539
+ ok: true,
12540
+ sprintId: sprintId || null,
12541
+ source: organized.source,
12542
+ data: organized.data,
12543
+ suggestions: Array.isArray(organized.data?.suggestions) ? organized.data.suggestions : [],
12544
+ });
12545
+ broadcastUiEvent(["tasks", "overview"], "invalidate", {
12546
+ reason: "dag-organized",
12547
+ sprintId: sprintId || null,
12548
+ });
12549
+ } catch (err) {
12550
+ jsonResponse(res, 500, { ok: false, error: err.message });
12551
+ }
12552
+ return;
12553
+ }
11491
12554
  if (path === "/api/tasks/attachments/upload" && req.method === "POST") {
11492
12555
  try {
11493
12556
  const { fields, files } = await readMultipartForm(req);
@@ -11825,11 +12888,20 @@ async function handleApi(req, res, url) {
11825
12888
  const tagsProvided = hasOwn(body, "tags");
11826
12889
  const tags = tagsProvided ? normalizeTagsInput(body?.tags) : undefined;
11827
12890
  const draftProvided = hasOwn(body, "draft");
12891
+ const blockedReasonProvided = hasOwn(body, "blockedReason");
12892
+ const blockedReason = blockedReasonProvided
12893
+ ? String(body?.blockedReason || "").trim() || null
12894
+ : undefined;
11828
12895
  const baseBranchProvided = hasOwn(body, "baseBranch") || hasOwn(body, "base_branch");
11829
12896
  const baseBranch = baseBranchProvided
11830
12897
  ? normalizeBranchInput(body?.baseBranch ?? body?.base_branch)
11831
12898
  : undefined;
11832
12899
  const metadataPatch = buildTaskMetadataPatch(body || {});
12900
+ const requestedStatus = normalizeTaskStatusKey(body?.status);
12901
+ const clearsBlockedState = requestedStatus === "todo";
12902
+ const nextMeta = (Object.keys(metadataPatch.meta).length > 0 || clearsBlockedState)
12903
+ ? buildTaskMetaPatch(previousTask?.meta, metadataPatch.meta, { clearBlockedState: clearsBlockedState })
12904
+ : null;
11833
12905
  const patch = {
11834
12906
  status: body?.status,
11835
12907
  title: body?.title,
@@ -11840,16 +12912,13 @@ async function handleApi(req, res, url) {
11840
12912
  repositories: Array.isArray(body?.repositories) ? body.repositories : undefined,
11841
12913
  ...(tagsProvided ? { tags } : {}),
11842
12914
  ...(draftProvided ? { draft: Boolean(body?.draft) } : {}),
12915
+ ...(clearsBlockedState
12916
+ ? { cooldownUntil: null, blockedReason: null }
12917
+ : (blockedReasonProvided ? { blockedReason } : {})),
12918
+ ...(clearsBlockedState ? { replaceMeta: true } : {}),
11843
12919
  ...(baseBranchProvided ? { baseBranch } : {}),
11844
12920
  ...metadataPatch.topLevel,
11845
- ...(Object.keys(metadataPatch.meta).length > 0
11846
- ? {
11847
- meta: {
11848
- ...(previousTask?.meta && typeof previousTask.meta === "object" ? previousTask.meta : {}),
11849
- ...metadataPatch.meta,
11850
- },
11851
- }
11852
- : {}),
12921
+ ...(nextMeta ? { meta: nextMeta } : {}),
11853
12922
  };
11854
12923
  if (!hasTaskPatchValues(patch) && !baseBranchProvided && !draftProvided && !tagsProvided) {
11855
12924
  jsonResponse(res, 400, {
@@ -11960,11 +13029,20 @@ async function handleApi(req, res, url) {
11960
13029
  const tagsProvided = hasOwn(body, "tags");
11961
13030
  const tags = tagsProvided ? normalizeTagsInput(body?.tags) : undefined;
11962
13031
  const draftProvided = hasOwn(body, "draft");
13032
+ const blockedReasonProvided = hasOwn(body, "blockedReason");
13033
+ const blockedReason = blockedReasonProvided
13034
+ ? String(body?.blockedReason || "").trim() || null
13035
+ : undefined;
11963
13036
  const baseBranchProvided = hasOwn(body, "baseBranch") || hasOwn(body, "base_branch");
11964
13037
  const baseBranch = baseBranchProvided
11965
13038
  ? normalizeBranchInput(body?.baseBranch ?? body?.base_branch)
11966
13039
  : undefined;
11967
13040
  const metadataPatch = buildTaskMetadataPatch(body || {});
13041
+ const requestedStatus = normalizeTaskStatusKey(body?.status);
13042
+ const clearsBlockedState = requestedStatus === "todo";
13043
+ const nextMeta = (Object.keys(metadataPatch.meta).length > 0 || clearsBlockedState)
13044
+ ? buildTaskMetaPatch(previousTask?.meta, metadataPatch.meta, { clearBlockedState: clearsBlockedState })
13045
+ : null;
11968
13046
  const patch = {
11969
13047
  title: body?.title,
11970
13048
  description: body?.description,
@@ -11975,16 +13053,13 @@ async function handleApi(req, res, url) {
11975
13053
  repositories: Array.isArray(body?.repositories) ? body.repositories : undefined,
11976
13054
  ...(tagsProvided ? { tags } : {}),
11977
13055
  ...(draftProvided ? { draft: Boolean(body?.draft) } : {}),
13056
+ ...(clearsBlockedState
13057
+ ? { cooldownUntil: null, blockedReason: null }
13058
+ : (blockedReasonProvided ? { blockedReason } : {})),
13059
+ ...(clearsBlockedState ? { replaceMeta: true } : {}),
11978
13060
  ...(baseBranchProvided ? { baseBranch } : {}),
11979
13061
  ...metadataPatch.topLevel,
11980
- ...(Object.keys(metadataPatch.meta).length > 0
11981
- ? {
11982
- meta: {
11983
- ...(previousTask?.meta && typeof previousTask.meta === "object" ? previousTask.meta : {}),
11984
- ...metadataPatch.meta,
11985
- },
11986
- }
11987
- : {}),
13062
+ ...(nextMeta ? { meta: nextMeta } : {}),
11988
13063
  };
11989
13064
  if (!hasTaskPatchValues(patch) && !baseBranchProvided && !draftProvided && !tagsProvided) {
11990
13065
  jsonResponse(res, 400, {
@@ -12157,6 +13232,10 @@ async function handleApi(req, res, url) {
12157
13232
  const adapter = getKanbanAdapter();
12158
13233
  const tags = normalizeTagsInput(body?.tags);
12159
13234
  const wantsDraft = Boolean(body?.draft) || body?.status === "draft";
13235
+ const blockedReasonProvided = hasOwn(body, "blockedReason");
13236
+ const blockedReason = blockedReasonProvided
13237
+ ? String(body?.blockedReason || "").trim() || null
13238
+ : undefined;
12160
13239
  const baseBranch = normalizeBranchInput(body?.baseBranch ?? body?.base_branch);
12161
13240
  const activeWorkspace = getActiveManagedWorkspace(resolveUiConfigDir());
12162
13241
  const defaultRepository =
@@ -12175,6 +13254,7 @@ async function handleApi(req, res, url) {
12175
13254
  description: body?.description || "",
12176
13255
  status: body?.status || (wantsDraft ? "draft" : "todo"),
12177
13256
  priority: body?.priority || undefined,
13257
+ ...(blockedReasonProvided ? { blockedReason } : {}),
12178
13258
  ...(workspace ? { workspace } : {}),
12179
13259
  ...(repository ? { repository } : {}),
12180
13260
  ...(repositories.length ? { repositories } : {}),
@@ -13704,7 +14784,10 @@ async function handleApi(req, res, url) {
13704
14784
  resolveAgentWorkLogDir(),
13705
14785
  "shredding-stats.jsonl",
13706
14786
  );
13707
- const raw = await readJsonlTail(shreddingPath, 10_000);
14787
+ const [{ entries: completedSessions }, raw] = await Promise.all([
14788
+ readCompletedSessionEntries(100_000),
14789
+ readJsonlTail(shreddingPath, 10_000),
14790
+ ]);
13708
14791
  const inWindow = raw.filter((e) => withinDays(e, days));
13709
14792
  let excludedSynthetic = 0;
13710
14793
  let excludedNoop = 0;
@@ -13729,7 +14812,14 @@ async function handleApi(req, res, url) {
13729
14812
  let totalOriginalChars = 0;
13730
14813
  let totalCompressedChars = 0;
13731
14814
  let totalSavedChars = 0;
14815
+ let totalOriginalTokensEstimated = 0;
14816
+ let totalCompressedTokensEstimated = 0;
14817
+ let totalSavedTokensEstimated = 0;
13732
14818
  const dailySaved = {};
14819
+ const dailyOriginal = {};
14820
+ const dailyCompressed = {};
14821
+ const dailySavedTokensEstimated = {};
14822
+ const dailyCostSavedUsd = {};
13733
14823
  const dailyCounts = {};
13734
14824
  const agentCounts = {};
13735
14825
  const stageCounts = {};
@@ -13740,17 +14830,37 @@ async function handleApi(req, res, url) {
13740
14830
  let liveOriginalChars = 0;
13741
14831
  let liveCompressedChars = 0;
13742
14832
  let liveSavedChars = 0;
14833
+ let liveSavedTokensEstimated = 0;
14834
+ const sessionCostModel = summarizeObservedSessionCostModel(
14835
+ completedSessions.filter((entry) => withinDays(entry, days)),
14836
+ );
14837
+ const blendedCostPerToken = sessionCostModel.blendedCostPerToken;
13743
14838
 
13744
14839
  for (const e of events) {
13745
14840
  const originalChars = numberOrZero(e.originalChars);
13746
14841
  const compressedChars = numberOrZero(e.compressedChars);
13747
14842
  const savedChars = numberOrZero(e.savedChars);
14843
+ const originalTokensEstimated = estimateTokensFromChars(originalChars);
14844
+ const compressedTokensEstimated = estimateTokensFromChars(compressedChars);
14845
+ const savedTokensEstimated = estimateTokensFromChars(savedChars);
14846
+ const estimatedCostSavedUsd = blendedCostPerToken != null
14847
+ ? roundMetric(savedTokensEstimated * blendedCostPerToken)
14848
+ : null;
13748
14849
  totalOriginalChars += originalChars;
13749
14850
  totalCompressedChars += compressedChars;
13750
14851
  totalSavedChars += savedChars;
13751
- const day = (e.timestamp || "").slice(0, 10);
14852
+ totalOriginalTokensEstimated += originalTokensEstimated;
14853
+ totalCompressedTokensEstimated += compressedTokensEstimated;
14854
+ totalSavedTokensEstimated += savedTokensEstimated;
14855
+ const day = getEntryDayKey(e);
13752
14856
  if (day) {
14857
+ dailyOriginal[day] = (dailyOriginal[day] || 0) + originalChars;
14858
+ dailyCompressed[day] = (dailyCompressed[day] || 0) + compressedChars;
13753
14859
  dailySaved[day] = (dailySaved[day] || 0) + savedChars;
14860
+ dailySavedTokensEstimated[day] = (dailySavedTokensEstimated[day] || 0) + savedTokensEstimated;
14861
+ if (estimatedCostSavedUsd != null) {
14862
+ dailyCostSavedUsd[day] = roundMetric((dailyCostSavedUsd[day] || 0) + estimatedCostSavedUsd);
14863
+ }
13754
14864
  dailyCounts[day] = (dailyCounts[day] || 0) + 1;
13755
14865
  }
13756
14866
  const agent = normalizeShreddingAgentType(e.agentType);
@@ -13764,6 +14874,7 @@ async function handleApi(req, res, url) {
13764
14874
  liveOriginalChars += originalChars;
13765
14875
  liveCompressedChars += compressedChars;
13766
14876
  liveSavedChars += savedChars;
14877
+ liveSavedTokensEstimated += savedTokensEstimated;
13767
14878
  const compactionFamily = String(e.compactionFamily || "unknown").trim().toLowerCase() || "unknown";
13768
14879
  const commandFamily = String(e.commandFamily || "unknown").trim().toLowerCase() || "unknown";
13769
14880
  compactionFamilyCounts[compactionFamily] = (compactionFamilyCounts[compactionFamily] || 0) + 1;
@@ -13798,12 +14909,27 @@ async function handleApi(req, res, url) {
13798
14909
  savedPct: numberOrZero(e.savedPct),
13799
14910
  originalChars: numberOrZero(e.originalChars),
13800
14911
  compressedChars: numberOrZero(e.compressedChars),
14912
+ estimatedSavedTokens: estimateTokensFromChars(numberOrZero(e.savedChars)),
14913
+ estimatedCostSavedUsd: blendedCostPerToken != null
14914
+ ? roundMetric(estimateTokensFromChars(numberOrZero(e.savedChars)) * blendedCostPerToken)
14915
+ : null,
13801
14916
  agentType: normalizeShreddingAgentType(e.agentType),
13802
14917
  attemptId: e.attemptId || null,
13803
14918
  stage: String(e.stage || "session_total").trim().toLowerCase() || "session_total",
13804
14919
  compactionFamily: String(e.compactionFamily || "").trim().toLowerCase() || null,
13805
14920
  commandFamily: String(e.commandFamily || "").trim().toLowerCase() || null,
13806
14921
  }));
14922
+ const dailyReductionPct = {};
14923
+ for (const day of Object.keys(dailyOriginal)) {
14924
+ const originalChars = numberOrZero(dailyOriginal[day]);
14925
+ const savedChars = numberOrZero(dailySaved[day]);
14926
+ dailyReductionPct[day] = originalChars > 0
14927
+ ? Math.round((savedChars / originalChars) * 100)
14928
+ : 0;
14929
+ }
14930
+ const totalEstimatedCostSavedUsd = blendedCostPerToken != null
14931
+ ? roundMetric(totalSavedTokensEstimated * blendedCostPerToken)
14932
+ : null;
13807
14933
 
13808
14934
  jsonResponse(res, 200, {
13809
14935
  ok: true,
@@ -13814,17 +14940,35 @@ async function handleApi(req, res, url) {
13814
14940
  totalSavedChars,
13815
14941
  avgSavedPct,
13816
14942
  sortedDates,
14943
+ dailyOriginal,
14944
+ dailyCompressed,
13817
14945
  dailySaved,
14946
+ dailySavedTokensEstimated,
14947
+ dailyCostSavedUsd,
14948
+ dailyReductionPct,
13818
14949
  dailyCounts,
13819
14950
  topAgents,
13820
14951
  stageCounts,
13821
14952
  topCompactionFamilies,
13822
14953
  topCommandFamilies,
14954
+ totals: {
14955
+ originalTokensEstimated: totalOriginalTokensEstimated,
14956
+ compressedTokensEstimated: totalCompressedTokensEstimated,
14957
+ savedTokensEstimated: totalSavedTokensEstimated,
14958
+ estimatedCostSavedUsd: totalEstimatedCostSavedUsd,
14959
+ },
14960
+ estimation: {
14961
+ charsPerToken: SHREDDING_ESTIMATED_CHARS_PER_TOKEN,
14962
+ costModel: blendedCostPerToken != null ? "observed_blended_session_cost" : "unavailable",
14963
+ blendedCostPerMillionTokensUsd: sessionCostModel.blendedCostPerMillionTokensUsd,
14964
+ pricedSessions: sessionCostModel.pricedSessions,
14965
+ },
13823
14966
  liveCompaction: {
13824
14967
  totalEvents: liveTotalEvents,
13825
14968
  totalOriginalChars: liveOriginalChars,
13826
14969
  totalCompressedChars: liveCompressedChars,
13827
14970
  totalSavedChars: liveSavedChars,
14971
+ savedTokensEstimated: liveSavedTokensEstimated,
13828
14972
  avgSavedPct: liveAvgSavedPct,
13829
14973
  },
13830
14974
  recentEvents,
@@ -13835,6 +14979,7 @@ async function handleApi(req, res, url) {
13835
14979
  unknownAttribution,
13836
14980
  includeSynthetic,
13837
14981
  includeNoop,
14982
+ pricedSessions: sessionCostModel.pricedSessions,
13838
14983
  },
13839
14984
  },
13840
14985
  });
@@ -14575,6 +15720,9 @@ async function handleApi(req, res, url) {
14575
15720
  templateName: template.name,
14576
15721
  workflowId,
14577
15722
  variables: { ...template.variables, ...userVars },
15723
+ workspaceId,
15724
+ repository: executeInput.repository || executeInput._targetRepo || null,
15725
+ targetRepo: executeInput._targetRepo || executeInput.repository || null,
14578
15726
  dispatchedAt,
14579
15727
  });
14580
15728
  return;
@@ -14587,6 +15735,9 @@ async function handleApi(req, res, url) {
14587
15735
  templateId,
14588
15736
  templateName: template.name,
14589
15737
  workflowId,
15738
+ workspaceId,
15739
+ repository: executeInput.repository || executeInput._targetRepo || null,
15740
+ targetRepo: executeInput._targetRepo || executeInput.repository || null,
14590
15741
  result,
14591
15742
  });
14592
15743
  } catch (err) {
@@ -14646,6 +15797,31 @@ async function handleApi(req, res, url) {
14646
15797
  return;
14647
15798
  }
14648
15799
 
15800
+ if (path === "/api/workflows/reflow-template-layouts" && req.method === "POST") {
15801
+ try {
15802
+ const wfCtx = await getWorkflowRequestContext(url);
15803
+ if (!wfCtx.ok) {
15804
+ jsonResponse(res, wfCtx.status, { ok: false, error: wfCtx.error });
15805
+ return;
15806
+ }
15807
+ if (typeof _wfTemplates?.relayoutInstalledTemplateWorkflows !== "function") {
15808
+ jsonResponse(res, 503, { ok: false, error: "Template relayout service unavailable" });
15809
+ return;
15810
+ }
15811
+ const body = await readJsonBody(req).catch(() => ({}));
15812
+ const result = _wfTemplates.relayoutInstalledTemplateWorkflows(wfCtx.engine, {
15813
+ workflowIds: body?.workflowIds || body?.workflowId,
15814
+ });
15815
+ const workflows = result.updatedWorkflowIds
15816
+ .map((workflowId) => wfCtx.engine.get(workflowId))
15817
+ .filter(Boolean);
15818
+ jsonResponse(res, 200, { ok: true, result, workflows });
15819
+ } catch (err) {
15820
+ jsonResponse(res, 500, { ok: false, error: err.message });
15821
+ }
15822
+ return;
15823
+ }
15824
+
14649
15825
  if (path === "/api/workflows/template-updates") {
14650
15826
  try {
14651
15827
  const wfCtx = await getWorkflowRequestContext(url);
@@ -14953,6 +16129,23 @@ async function handleApi(req, res, url) {
14953
16129
  return;
14954
16130
  }
14955
16131
 
16132
+ if (action === "reflow-layout" && req.method === "POST") {
16133
+ if (typeof _wfTemplates?.relayoutInstalledTemplateWorkflows !== "function") {
16134
+ jsonResponse(res, 503, { ok: false, error: "Template relayout service unavailable" });
16135
+ return;
16136
+ }
16137
+ const result = _wfTemplates.relayoutInstalledTemplateWorkflows(engine, {
16138
+ workflowId,
16139
+ });
16140
+ const workflow = engine.get(workflowId);
16141
+ if (!workflow) {
16142
+ jsonResponse(res, 404, { ok: false, error: "Workflow not found after relayout" });
16143
+ return;
16144
+ }
16145
+ jsonResponse(res, 200, { ok: true, workflow, result });
16146
+ return;
16147
+ }
16148
+
14956
16149
  if (action === "runs") {
14957
16150
  const rawOffset = Number(url.searchParams.get("offset"));
14958
16151
  const rawLimit = Number(url.searchParams.get("limit"));
@@ -15074,7 +16267,7 @@ async function handleApi(req, res, url) {
15074
16267
  if (path === "/api/manual-flows/execute" && req.method === "POST") {
15075
16268
  try {
15076
16269
  const body = await readJsonBody(req);
15077
- const { templateId, formValues } = body || {};
16270
+ const { templateId, formValues, executionContext } = body || {};
15078
16271
  if (!templateId) {
15079
16272
  jsonResponse(res, 400, { ok: false, error: "templateId is required" });
15080
16273
  return;
@@ -15082,7 +16275,40 @@ async function handleApi(req, res, url) {
15082
16275
  const mf = await import("../workflow/manual-flows.mjs");
15083
16276
  const ctx = resolveActiveWorkspaceExecutionContext();
15084
16277
  const wfCtx = await getWorkflowRequestContext(url);
15085
- const flowContext = wfCtx?.ok ? { engine: wfCtx.engine } : {};
16278
+ const repository = String(
16279
+ executionContext?.repository ||
16280
+ executionContext?.targetRepo ||
16281
+ formValues?._targetRepo ||
16282
+ resolveDefaultRepositoryForWorkspaceContext(ctx),
16283
+ ).trim();
16284
+ const workspaceId = String(
16285
+ executionContext?.workspaceId ||
16286
+ executionContext?.workspace ||
16287
+ ctx.workspaceId ||
16288
+ "",
16289
+ ).trim();
16290
+ const projectId = String(
16291
+ executionContext?.projectId ||
16292
+ executionContext?.project ||
16293
+ body?.project ||
16294
+ "",
16295
+ ).trim();
16296
+ const flowContext = {
16297
+ ...(wfCtx?.ok ? { engine: wfCtx.engine } : {}),
16298
+ taskManager: createManualFlowTaskManager(ctx, {
16299
+ repository,
16300
+ workspaceId,
16301
+ projectId,
16302
+ templateId,
16303
+ }),
16304
+ runMetadata: {
16305
+ repository,
16306
+ workspaceId,
16307
+ workspaceDir: ctx.workspaceDir,
16308
+ projectId,
16309
+ triggerSource: "manual-ui",
16310
+ },
16311
+ };
15086
16312
  const run = await mf.executeFlow(templateId, formValues || {}, ctx.workspaceDir, flowContext);
15087
16313
  jsonResponse(res, 200, { ok: true, run });
15088
16314
  } catch (err) {
@@ -15606,12 +16832,26 @@ async function handleApi(req, res, url) {
15606
16832
  jsonResponse(res, 404, { ok: false, error: "Task not found." });
15607
16833
  return;
15608
16834
  }
15609
- if (typeof adapter.updateTask === "function") {
15610
- await adapter.updateTask(taskId, { status: "todo" });
15611
- } else if (typeof adapter.updateTaskStatus === "function") {
15612
- await adapter.updateTaskStatus(taskId, "todo");
16835
+ let nextTask = unblockInternalTask(taskId, {
16836
+ status: "todo",
16837
+ source: "manual-retry",
16838
+ });
16839
+ if (!nextTask) {
16840
+ if (typeof adapter.updateTask === "function") {
16841
+ await adapter.updateTask(taskId, {
16842
+ status: "todo",
16843
+ cooldownUntil: null,
16844
+ blockedReason: null,
16845
+ meta: task?.meta && typeof task.meta === "object"
16846
+ ? Object.fromEntries(Object.entries(task.meta).filter(([key]) => key !== "autoRecovery"))
16847
+ : task?.meta,
16848
+ });
16849
+ } else if (typeof adapter.updateTaskStatus === "function") {
16850
+ await adapter.updateTaskStatus(taskId, "todo");
16851
+ }
16852
+ nextTask = await adapter.getTask(taskId);
15613
16853
  }
15614
- executor.executeTask(task).catch((error) => {
16854
+ executor.executeTask(nextTask || { ...task, status: "todo" }).catch((error) => {
15615
16855
  console.warn(
15616
16856
  `[telegram-ui] failed to retry task ${taskId}: ${error.message}`,
15617
16857
  );
@@ -15636,6 +16876,58 @@ async function handleApi(req, res, url) {
15636
16876
  return;
15637
16877
  }
15638
16878
 
16879
+ if (path === "/api/tasks/unblock") {
16880
+ if (req.method !== "POST") {
16881
+ res.setHeader("Allow", "POST");
16882
+ jsonResponse(res, 405, { ok: false, error: "Method Not Allowed" });
16883
+ return;
16884
+ }
16885
+ try {
16886
+ const body = await readJsonBody(req);
16887
+ const taskId = body?.taskId || body?.id;
16888
+ const targetStatus = String(body?.status || "todo").trim().toLowerCase() || "todo";
16889
+ if (!taskId) {
16890
+ jsonResponse(res, 400, { ok: false, error: "taskId is required" });
16891
+ return;
16892
+ }
16893
+ const adapter = getKanbanAdapter();
16894
+ const task = await adapter.getTask(taskId);
16895
+ if (!task) {
16896
+ jsonResponse(res, 404, { ok: false, error: "Task not found." });
16897
+ return;
16898
+ }
16899
+ let updatedTask = unblockInternalTask(taskId, {
16900
+ status: targetStatus,
16901
+ source: "api.tasks.unblock",
16902
+ });
16903
+ if (!updatedTask) {
16904
+ const nextMeta = task?.meta && typeof task.meta === "object"
16905
+ ? Object.fromEntries(Object.entries(task.meta).filter(([key]) => key !== "autoRecovery"))
16906
+ : task?.meta;
16907
+ if (typeof adapter.updateTask === "function") {
16908
+ await adapter.updateTask(taskId, {
16909
+ status: targetStatus,
16910
+ cooldownUntil: null,
16911
+ blockedReason: null,
16912
+ meta: nextMeta,
16913
+ });
16914
+ } else if (typeof adapter.updateTaskStatus === "function") {
16915
+ await adapter.updateTaskStatus(taskId, targetStatus);
16916
+ }
16917
+ updatedTask = await adapter.getTask(taskId);
16918
+ }
16919
+ jsonResponse(res, 200, { ok: true, taskId, data: updatedTask || null });
16920
+ broadcastUiEvent(
16921
+ ["tasks", "overview", "executor", "agents"],
16922
+ "invalidate",
16923
+ { reason: "task-unblocked", taskId },
16924
+ );
16925
+ } catch (err) {
16926
+ jsonResponse(res, 500, { ok: false, error: err.message });
16927
+ }
16928
+ return;
16929
+ }
16930
+
15639
16931
  // ── GET /api/retry-queue ───────────────────────────────────────────
15640
16932
  if (path === "/api/retry-queue" && req.method === "GET") {
15641
16933
  try {
@@ -18054,8 +19346,16 @@ export async function startTelegramUiServer(options = {}) {
18054
19346
  req.url || "/",
18055
19347
  `http://${req.headers.host || "localhost"}`,
18056
19348
  );
19349
+ res.__bosunRequestContext = {
19350
+ diagnosticId: ensureResponseDiagnosticId(res),
19351
+ method: String(req?.method || "GET").toUpperCase(),
19352
+ path: url.pathname,
19353
+ query: url.search || "",
19354
+ };
18057
19355
  const webhookPath = getGitHubWebhookPath();
18058
19356
 
19357
+ try {
19358
+
18059
19359
  // Token exchange: ?token=<hex> → set session cookie and redirect to clean URL
18060
19360
  const qToken = url.searchParams.get("token");
18061
19361
  if (qToken && sessionToken) {
@@ -18197,6 +19497,21 @@ export async function startTelegramUiServer(options = {}) {
18197
19497
  }
18198
19498
  }
18199
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
+ }
18200
19515
  };
18201
19516
 
18202
19517
  try {