bosun 0.43.0 → 0.43.1

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/infra/monitor.mjs CHANGED
@@ -3188,6 +3188,18 @@ function isFlowReviewGateEnabled() {
3188
3188
  return !isFalsyFlag(flowRequireReviewDefault);
3189
3189
  }
3190
3190
 
3191
+ function isMonitorMonitorEnabled() {
3192
+ if (isMonitorTestRuntime) return false;
3193
+
3194
+ const explicit =
3195
+ process.env.DEVMODE_MONITOR_MONITOR_ENABLED ??
3196
+ process.env.DEVMODE_AUTO_CODE_FIX_ENABLED;
3197
+ if (explicit !== undefined && String(explicit).trim() !== "") {
3198
+ return !isFalsyFlag(explicit);
3199
+ }
3200
+
3201
+ return false;
3202
+ }
3191
3203
 
3192
3204
  function isSelfRestartWatcherEnabled() {
3193
3205
  const devMode = isDevMode();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.43.0",
3
+ "version": "0.43.1",
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 GitHub/Jira APIs, 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",
@@ -550,12 +550,12 @@
550
550
  "@toast-ui/editor": "^3.2.2",
551
551
  "@whiskeysockets/baileys": "^7.0.0-rc.9",
552
552
  "ajv": "^8.18.0",
553
- "dompurify": "^3.4.1",
553
+ "dompurify": "^3.4.2",
554
554
  "es-module-shims": "^2.8.0",
555
555
  "express": "^5.1.0",
556
- "express-rate-limit": "^8.0.0",
556
+ "express-rate-limit": "^8.5.1",
557
557
  "figures": "^6.1.0",
558
- "hono": "^4.12.14",
558
+ "hono": "^4.12.18",
559
559
  "htm": "3.1.1",
560
560
  "ink": "^5.0.0",
561
561
  "ink-text-input": "^6.0.0",
@@ -2892,6 +2892,29 @@ function sanitizeTaskDiagnosticText(value, maxLength = 240) {
2892
2892
  return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
2893
2893
  }
2894
2894
 
2895
+ const LOW_SIGNAL_BLOCKED_REASONS = new Set([
2896
+ "ok",
2897
+ "okay",
2898
+ "success",
2899
+ "succeeded",
2900
+ "passed",
2901
+ "pass",
2902
+ "true",
2903
+ "done",
2904
+ "completed",
2905
+ "complete",
2906
+ "none",
2907
+ "null",
2908
+ "undefined",
2909
+ ]);
2910
+
2911
+ function normalizeBlockedReasonForDisplay(value, maxLength = 280) {
2912
+ const text = sanitizeTaskDiagnosticText(value, maxLength);
2913
+ if (!text) return "";
2914
+ if (LOW_SIGNAL_BLOCKED_REASONS.has(text.toLowerCase())) return "";
2915
+ return text;
2916
+ }
2917
+
2895
2918
  const TASK_LOG_DIAGNOSTICS_TAIL_BYTES = 256 * 1024;
2896
2919
  const TASK_LOG_DIAGNOSTICS_CACHE_MS = 5000;
2897
2920
 
@@ -2983,7 +3006,7 @@ function collectTaskWorkflowRunDiagnostics(task, limit = 8) {
2983
3006
  || worktreeFailure?.failureKind
2984
3007
  || "",
2985
3008
  ).trim();
2986
- const errorText = sanitizeTaskDiagnosticText(
3009
+ const errorText = normalizeBlockedReasonForDisplay(
2987
3010
  worktreeFailure?.error
2988
3011
  || meta?.error
2989
3012
  || meta?.blockedReason
@@ -2991,10 +3014,21 @@ function collectTaskWorkflowRunDiagnostics(task, limit = 8) {
2991
3014
  || "",
2992
3015
  320,
2993
3016
  );
2994
- const blockedReason = sanitizeTaskDiagnosticText(
2995
- worktreeFailure?.blockedReason || "",
3017
+ const blockedReason = normalizeBlockedReasonForDisplay(
3018
+ worktreeFailure?.blockedReason
3019
+ || meta?.blockedReason
3020
+ || run?.blockedReason
3021
+ || "",
2996
3022
  280,
2997
3023
  );
3024
+ const status = String(run?.status || "").trim().toLowerCase();
3025
+ const diagnosticText = [
3026
+ failureKind,
3027
+ errorText,
3028
+ blockedReason,
3029
+ run?.summary,
3030
+ run?.message,
3031
+ ].filter(Boolean).join("\n");
2998
3032
  const isWorktreeFailure =
2999
3033
  failureKind === "branch_refresh_conflict"
3000
3034
  || failureKind === "worktree_acquisition_failed"
@@ -3002,17 +3036,29 @@ function collectTaskWorkflowRunDiagnostics(task, limit = 8) {
3002
3036
  || /worktree/i.test(errorText)
3003
3037
  || /refresh conflict/i.test(blockedReason)
3004
3038
  || /managed worktree/i.test(errorText);
3005
- if (!isWorktreeFailure) continue;
3039
+ const isWorkflowFailure =
3040
+ isWorktreeFailure
3041
+ || ["failed", "blocked", "error", "cancelled", "canceled"].includes(status)
3042
+ || /pre-?pr validation failed/i.test(diagnosticText)
3043
+ || /prepush/i.test(diagnosticText)
3044
+ || /failed to push/i.test(diagnosticText)
3045
+ || /push failed/i.test(diagnosticText)
3046
+ || /create-pr/i.test(diagnosticText)
3047
+ || /retry-pr-ok/i.test(diagnosticText)
3048
+ || /vitest-pool/i.test(diagnosticText)
3049
+ || /Worker exited unexpectedly/i.test(diagnosticText);
3050
+ if (!isWorkflowFailure) continue;
3006
3051
  relevant.push({
3007
3052
  source: "workflow-run",
3008
3053
  runId: String(run?.runId || "").trim() || null,
3009
3054
  workflowId: String(run?.workflowId || "").trim() || null,
3010
3055
  workflowName: String(run?.workflowName || "").trim() || null,
3011
- message: errorText || blockedReason || "Workflow run recorded a worktree failure.",
3056
+ message: errorText || blockedReason || `Workflow run ${status || "failed"}.`,
3012
3057
  reason: blockedReason || null,
3013
3058
  failureKind: failureKind || null,
3014
3059
  timestamp: run?.endedAt || run?.startedAt || null,
3015
3060
  kind: "workflow-run",
3061
+ diagnosticCategory: isWorktreeFailure ? "worktree_failure" : "workflow_failure",
3016
3062
  retryable: worktreeFailure?.retryable ?? null,
3017
3063
  retryAt: worktreeFailure?.retryAt || null,
3018
3064
  recordedAt: worktreeFailure?.recordedAt || null,
@@ -3037,10 +3083,14 @@ async function collectTaskLogDiagnostics(task, workspaceDir = "", limit = 8) {
3037
3083
  if (!candidate || !existsSync(candidate) || logPaths.includes(candidate)) return;
3038
3084
  logPaths.push(candidate);
3039
3085
  };
3086
+ pushLogPath(resolve(__dirname, "..", ".bosun", "logs", "monitor-error.log"));
3087
+ pushLogPath(resolve(__dirname, "..", ".bosun", "logs", "monitor.log"));
3040
3088
  if (workspaceDir) {
3041
3089
  pushLogPath(resolve(workspaceDir, ".bosun", "logs", "monitor-error.log"));
3042
3090
  pushLogPath(resolve(workspaceDir, ".bosun", "logs", "monitor.log"));
3043
3091
  }
3092
+ pushLogPath(resolve(process.cwd(), ".bosun", "logs", "monitor-error.log"));
3093
+ pushLogPath(resolve(process.cwd(), ".bosun", "logs", "monitor.log"));
3044
3094
  pushLogPath(resolve(repoRoot, ".bosun", "logs", "monitor-error.log"));
3045
3095
  pushLogPath(resolve(repoRoot, ".bosun", "logs", "monitor.log"));
3046
3096
 
@@ -3117,7 +3167,7 @@ async function buildTaskBlockedContext(task, options = {}) {
3117
3167
  ? options.canStart
3118
3168
  : null;
3119
3169
  const normalizedStatus = normalizeTaskStatusKey(currentTask?.status);
3120
- const explicitReason = sanitizeTaskDiagnosticText(
3170
+ const explicitReason = normalizeBlockedReasonForDisplay(
3121
3171
  currentTask?.blockedReason
3122
3172
  || currentTask?.meta?.worktreeFailure?.blockedReason
3123
3173
  || currentTask?.meta?.autoRecovery?.error
@@ -3133,7 +3183,7 @@ async function buildTaskBlockedContext(task, options = {}) {
3133
3183
  headline: "This task cannot start because one or more dependencies are not done yet.",
3134
3184
  summary: "Bosun will not dispatch this task until every blocking dependency below is resolved.",
3135
3185
  recommendation: "Complete or unblock the listed dependencies, then dispatch this task again.",
3136
- reason: explicitReason || sanitizeTaskDiagnosticText(canStart?.reason || ""),
3186
+ reason: explicitReason || normalizeBlockedReasonForDisplay(canStart?.reason || ""),
3137
3187
  workflowRunCount: 0,
3138
3188
  prePrValidationFailureCount: 0,
3139
3189
  worktreeFailureCount: 0,
@@ -3163,6 +3213,7 @@ async function buildTaskBlockedContext(task, options = {}) {
3163
3213
  6,
3164
3214
  );
3165
3215
  const hasPlannerCorruption = /planner payload corrupted/i.test(explicitReason);
3216
+ const latestWorkflowEvidence = workflowRunEvidence[workflowRunEvidence.length - 1] || null;
3166
3217
  const repairArtifacts =
3167
3218
  currentTask?.meta?.worktreeFailure?.repairArtifacts
3168
3219
  && typeof currentTask.meta.worktreeFailure.repairArtifacts === "object"
@@ -3170,7 +3221,7 @@ async function buildTaskBlockedContext(task, options = {}) {
3170
3221
  : null;
3171
3222
  const hasWorktreeFailure =
3172
3223
  Boolean(currentTask?.meta?.worktreeFailure) ||
3173
- workflowRunEvidence.length > 0 ||
3224
+ workflowRunEvidence.some((entry) => entry?.diagnosticCategory === "worktree_failure") ||
3174
3225
  logDiagnostics.counts.worktreeFailed > 0 ||
3175
3226
  timelineEvidence.some((entry) => /worktree failed/i.test(String(entry?.message || "")));
3176
3227
 
@@ -3200,12 +3251,16 @@ async function buildTaskBlockedContext(task, options = {}) {
3200
3251
  } else if (normalizedStatus === "blocked") {
3201
3252
  category = "blocked";
3202
3253
  headline = "This task is blocked.";
3203
- summary = explicitReason || "Bosun marked this task as blocked, but the original blocked reason was not persisted.";
3254
+ summary = explicitReason
3255
+ || (latestWorkflowEvidence?.message
3256
+ ? `Latest workflow failure: ${latestWorkflowEvidence.message}`
3257
+ : "Bosun marked this task as blocked, but the original blocked reason was not persisted.");
3204
3258
  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.";
3205
3259
  } else if (canStart?.canStart === false) {
3206
3260
  category = "start_guard_blocked";
3207
3261
  headline = "This task is currently not startable.";
3208
- summary = sanitizeTaskDiagnosticText(canStart?.reason || "Bosun start guards rejected dispatch for this task.");
3262
+ summary = normalizeBlockedReasonForDisplay(canStart?.reason || "")
3263
+ || "Bosun start guards rejected dispatch for this task.";
3209
3264
  recommendation = "Resolve the blocking condition below before dispatching the task.";
3210
3265
  } else {
3211
3266
  return null;
@@ -3217,10 +3272,13 @@ async function buildTaskBlockedContext(task, options = {}) {
3217
3272
  headline,
3218
3273
  summary,
3219
3274
  recommendation,
3220
- reason: explicitReason || sanitizeTaskDiagnosticText(canStart?.reason || ""),
3275
+ reason: explicitReason || normalizeBlockedReasonForDisplay(canStart?.reason || ""),
3221
3276
  workflowRunCount: workflowRuns.length,
3222
3277
  prePrValidationFailureCount: logDiagnostics.counts.prePrValidationFailed,
3223
- worktreeFailureCount: Math.max(logDiagnostics.counts.worktreeFailed, workflowRunEvidence.length),
3278
+ worktreeFailureCount: Math.max(
3279
+ logDiagnostics.counts.worktreeFailed,
3280
+ workflowRunEvidence.filter((entry) => entry?.diagnosticCategory === "worktree_failure").length,
3281
+ ),
3224
3282
  blockedTransitionCount: logDiagnostics.counts.blockedTransitions,
3225
3283
  createPrFailureCount: logDiagnostics.counts.createPrFailed,
3226
3284
  blockedBy: Array.isArray(canStart?.blockedBy) ? canStart.blockedBy : [],
@@ -3232,6 +3290,10 @@ async function buildTaskBlockedContext(task, options = {}) {
3232
3290
  };
3233
3291
  }
3234
3292
 
3293
+ export async function _testBuildTaskBlockedContext(task, options = {}) {
3294
+ return buildTaskBlockedContext(task, options);
3295
+ }
3296
+
3235
3297
  function buildTaskMetaPatch(previousMeta, metadataPatchMeta, options = {}) {
3236
3298
  const clearBlockedState = options.clearBlockedState === true;
3237
3299
  const nextMeta = previousMeta && typeof previousMeta === "object"
@@ -5254,10 +5316,103 @@ function extractImportedTaskList(body = null) {
5254
5316
  return null;
5255
5317
  }
5256
5318
 
5319
+ function isPlainObject(value) {
5320
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
5321
+ }
5322
+
5323
+ function mergeImportedTaskCollection(baseEntries = [], incomingEntries = [], keyBuilder) {
5324
+ const merged = [];
5325
+ const indexByKey = new Map();
5326
+ const appendEntry = (entry) => {
5327
+ if (!entry || typeof entry !== "object") return;
5328
+ const key = typeof keyBuilder === "function" ? String(keyBuilder(entry) || "").trim() : "";
5329
+ if (key && indexByKey.has(key)) {
5330
+ merged[indexByKey.get(key)] = { ...merged[indexByKey.get(key)], ...entry };
5331
+ return;
5332
+ }
5333
+ merged.push(entry);
5334
+ if (key) indexByKey.set(key, merged.length - 1);
5335
+ };
5336
+ for (const entry of Array.isArray(baseEntries) ? baseEntries : []) appendEntry(entry);
5337
+ for (const entry of Array.isArray(incomingEntries) ? incomingEntries : []) appendEntry(entry);
5338
+ return merged;
5339
+ }
5340
+
5341
+ function mergeImportedTaskTimeline(baseEntries = [], incomingEntries = []) {
5342
+ return mergeImportedTaskCollection(baseEntries, incomingEntries, (entry) => (
5343
+ entry?.id
5344
+ || [
5345
+ entry?.type,
5346
+ entry?.source,
5347
+ entry?.at,
5348
+ entry?.status,
5349
+ entry?.fromStatus,
5350
+ entry?.toStatus,
5351
+ entry?.message,
5352
+ ].map((value) => String(value || "").trim()).join("|")
5353
+ ));
5354
+ }
5355
+
5356
+ function mergeImportedTaskStatusHistory(baseEntries = [], incomingEntries = []) {
5357
+ return mergeImportedTaskCollection(baseEntries, incomingEntries, (entry) => [
5358
+ entry?.status,
5359
+ entry?.timestamp,
5360
+ entry?.source,
5361
+ ].map((value) => String(value || "").trim()).join("|"));
5362
+ }
5363
+
5364
+ function mergeImportedTaskMeta(currentMeta = {}, incomingMeta = {}) {
5365
+ const base = isPlainObject(currentMeta) ? currentMeta : {};
5366
+ const incoming = isPlainObject(incomingMeta) ? incomingMeta : {};
5367
+ const merged = { ...base };
5368
+ for (const [key, value] of Object.entries(incoming)) {
5369
+ if (isPlainObject(value) && isPlainObject(base[key])) {
5370
+ merged[key] = mergeImportedTaskMeta(base[key], value);
5371
+ } else {
5372
+ merged[key] = value;
5373
+ }
5374
+ }
5375
+ return merged;
5376
+ }
5377
+
5378
+ function buildImportedTaskPatch(currentTask = {}, importedTask = {}, mode = "merge") {
5379
+ const patch = importedTask && typeof importedTask === "object" ? { ...importedTask } : {};
5380
+ if (mode !== "merge") return patch;
5381
+
5382
+ if (Object.prototype.hasOwnProperty.call(importedTask, "workflowRuns")
5383
+ && Array.isArray(importedTask.workflowRuns)) {
5384
+ patch.workflowRuns = mergeTaskWorkflowRuns(
5385
+ Array.isArray(currentTask?.workflowRuns) ? currentTask.workflowRuns : [],
5386
+ importedTask.workflowRuns,
5387
+ 200,
5388
+ );
5389
+ }
5390
+ if (Object.prototype.hasOwnProperty.call(importedTask, "timeline")
5391
+ && Array.isArray(importedTask.timeline)) {
5392
+ patch.timeline = mergeImportedTaskTimeline(
5393
+ Array.isArray(currentTask?.timeline) ? currentTask.timeline : [],
5394
+ importedTask.timeline,
5395
+ );
5396
+ }
5397
+ if (Object.prototype.hasOwnProperty.call(importedTask, "statusHistory")
5398
+ && Array.isArray(importedTask.statusHistory)) {
5399
+ patch.statusHistory = mergeImportedTaskStatusHistory(
5400
+ Array.isArray(currentTask?.statusHistory) ? currentTask.statusHistory : [],
5401
+ importedTask.statusHistory,
5402
+ );
5403
+ }
5404
+ if (Object.prototype.hasOwnProperty.call(importedTask, "meta")
5405
+ && isPlainObject(importedTask.meta)) {
5406
+ patch.meta = mergeImportedTaskMeta(currentTask?.meta, importedTask.meta);
5407
+ }
5408
+ return patch;
5409
+ }
5410
+
5257
5411
  async function importInternalTaskStateSnapshot(body = {}) {
5258
5412
  const taskStore = await ensureTaskStoreApi();
5259
5413
  const addTaskFn = typeof taskStore?.addTask === "function" ? taskStore.addTask : null;
5260
5414
  const updateTaskFn = typeof taskStore?.updateTask === "function" ? taskStore.updateTask : null;
5415
+ const getTaskFn = typeof taskStore?.getTask === "function" ? taskStore.getTask : null;
5261
5416
  if (!addTaskFn || !updateTaskFn) {
5262
5417
  throw new Error("Internal task store import is unavailable");
5263
5418
  }
@@ -5301,12 +5456,19 @@ async function importInternalTaskStateSnapshot(body = {}) {
5301
5456
 
5302
5457
  try {
5303
5458
  if (existingById.has(taskId)) {
5304
- updateTaskFn(taskId, task);
5459
+ const currentTask = existingById.get(taskId) || {};
5460
+ const patch = buildImportedTaskPatch(currentTask, task, mode);
5461
+ updateTaskFn(taskId, patch);
5462
+ if (getTaskFn) {
5463
+ existingById.set(taskId, getTaskFn(taskId) || { ...currentTask, ...patch });
5464
+ } else {
5465
+ existingById.set(taskId, { ...currentTask, ...patch });
5466
+ }
5305
5467
  summary.updated += 1;
5306
5468
  results.push({ id: taskId, status: "updated" });
5307
5469
  } else {
5308
5470
  addTaskFn(task);
5309
- existingById.set(taskId, task);
5471
+ existingById.set(taskId, getTaskFn ? (getTaskFn(taskId) || task) : task);
5310
5472
  summary.created += 1;
5311
5473
  results.push({ id: taskId, status: "created" });
5312
5474
  }
@@ -19798,6 +19960,156 @@ async function handleApi(req, res, url) {
19798
19960
  return;
19799
19961
  }
19800
19962
 
19963
+ if (path === "/api/executor/dispatch" && req.method === "POST") {
19964
+ try {
19965
+ const body = await readJsonBody(req);
19966
+ const prompt = String(body?.prompt || "").trim();
19967
+ let taskId = String(body?.taskId || body?.id || "").trim();
19968
+ const sdk = typeof body?.sdk === "string" ? body.sdk.trim() : "";
19969
+ const model = typeof body?.model === "string" ? body.model.trim() : "";
19970
+ const executor = uiDeps.getInternalExecutor?.();
19971
+ if (!executor) {
19972
+ jsonResponse(res, 400, {
19973
+ ok: false,
19974
+ error: "Internal executor not enabled. Set EXECUTOR_MODE=internal.",
19975
+ });
19976
+ return;
19977
+ }
19978
+ if (!taskId && !prompt) {
19979
+ jsonResponse(res, 400, { ok: false, error: "taskId or prompt is required" });
19980
+ return;
19981
+ }
19982
+
19983
+ const adapter = getKanbanAdapter();
19984
+ let task = taskId && typeof adapter.getTask === "function"
19985
+ ? await adapter.getTask(taskId)
19986
+ : null;
19987
+ if (taskId && !task) {
19988
+ jsonResponse(res, 404, { ok: false, error: "Task not found." });
19989
+ return;
19990
+ }
19991
+
19992
+ let createdFromPrompt = false;
19993
+ if (!taskId) {
19994
+ const activeWorkspace = getActiveManagedWorkspace(resolveUiConfigDir());
19995
+ const defaultRepository =
19996
+ activeWorkspace?.activeRepo ||
19997
+ activeWorkspace?.repos?.find((repo) => repo.primary)?.name ||
19998
+ activeWorkspace?.repos?.[0]?.name ||
19999
+ "";
20000
+ const titleLine = prompt.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "Fleet prompt dispatch";
20001
+ const taskData = {
20002
+ title: titleLine.length > 96 ? `${titleLine.slice(0, 93)}...` : titleLine,
20003
+ description: prompt,
20004
+ status: "todo",
20005
+ ...(activeWorkspace?.id ? { workspace: activeWorkspace.id } : {}),
20006
+ ...(defaultRepository ? { repository: defaultRepository } : {}),
20007
+ meta: {
20008
+ ...(activeWorkspace?.id ? { workspace: activeWorkspace.id } : {}),
20009
+ ...(defaultRepository ? { repository: defaultRepository } : {}),
20010
+ source: "fleet-dispatch",
20011
+ },
20012
+ };
20013
+ const createdRaw = await adapter.createTask("", taskData);
20014
+ task = withTaskMetadataTopLevel(createdRaw);
20015
+ taskId = String(task?.id || "").trim();
20016
+ createdFromPrompt = true;
20017
+ }
20018
+
20019
+ const canStart = await evaluateTaskCanStart({
20020
+ taskId,
20021
+ task,
20022
+ adapter,
20023
+ forceStart: body?.force === true || body?.forceStart === true,
20024
+ manualOverride: body?.manualOverride === true || body?.overrideStartGuard === true,
20025
+ });
20026
+ if (!canStart.canStart) {
20027
+ jsonResponse(res, 409, {
20028
+ ok: false,
20029
+ taskId,
20030
+ error: "Task cannot be dispatched",
20031
+ canStart,
20032
+ data: task ? withTaskRuntimeSnapshot(task) : null,
20033
+ });
20034
+ return;
20035
+ }
20036
+
20037
+ const status = executor.getStatus?.() || {};
20038
+ const activeSlots = Array.isArray(status?.slots) ? status.slots : [];
20039
+ const maxParallel = Number(status?.maxParallel || 0);
20040
+ const activeCount = Number(status?.activeSlots || activeSlots.length || 0);
20041
+ const hasFreeSlot = maxParallel <= 0 || activeCount < maxParallel;
20042
+ if (!hasFreeSlot) {
20043
+ const queuedTask = await persistTaskExecutionMeta(adapter, taskId, {
20044
+ queued: true,
20045
+ queueState: "queued",
20046
+ requestedAt: new Date().toISOString(),
20047
+ });
20048
+ jsonResponse(res, 202, {
20049
+ ok: true,
20050
+ taskId,
20051
+ queued: true,
20052
+ started: false,
20053
+ createdFromPrompt,
20054
+ reason: "No free slots",
20055
+ canStart,
20056
+ data: withTaskRuntimeSnapshot(queuedTask || task),
20057
+ });
20058
+ broadcastUiEvent(["tasks", "overview", "executor", "agents"], "invalidate", {
20059
+ reason: "task-queued",
20060
+ taskId,
20061
+ });
20062
+ return;
20063
+ }
20064
+
20065
+ let startedTask = await persistTaskStatusForExecution(adapter, taskId, "inprogress", "api.executor.dispatch") || task;
20066
+ startedTask = await persistTaskExecutionMeta(adapter, taskId, {
20067
+ queued: false,
20068
+ queueState: null,
20069
+ }) || startedTask;
20070
+ applyInternalLifecycleTransition(taskId, "start", {
20071
+ source: "api.executor.dispatch",
20072
+ actor: "ui",
20073
+ force: body?.force === true || body?.forceStart === true || body?.manualOverride === true || body?.overrideStartGuard === true,
20074
+ reason: createdFromPrompt ? "fleet prompt dispatch" : "fleet task dispatch",
20075
+ });
20076
+
20077
+ traceHttpServerAction(req, {
20078
+ route: "/api/executor/dispatch",
20079
+ taskId,
20080
+ }, () => executor.executeTask(startedTask, {
20081
+ ...(sdk ? { sdk } : {}),
20082
+ ...(model ? { model } : {}),
20083
+ force: body?.force === true || body?.forceStart === true || body?.manualOverride === true || body?.overrideStartGuard === true,
20084
+ })).catch((error) => {
20085
+ console.warn(`[telegram-ui] fleet dispatch failed for ${taskId}: ${error.message}`);
20086
+ void persistTaskStatusForExecution(
20087
+ adapter,
20088
+ taskId,
20089
+ resolveFallbackStatusAfterFailedDispatch(task?.status, { started: false }),
20090
+ "api.executor.dispatch.failed",
20091
+ );
20092
+ });
20093
+
20094
+ jsonResponse(res, 200, {
20095
+ ok: true,
20096
+ taskId,
20097
+ queued: false,
20098
+ started: true,
20099
+ createdFromPrompt,
20100
+ canStart,
20101
+ data: withTaskRuntimeSnapshot(startedTask),
20102
+ });
20103
+ broadcastUiEvent(["tasks", "overview", "executor", "agents"], "invalidate", {
20104
+ reason: "task-dispatched",
20105
+ taskId,
20106
+ });
20107
+ } catch (err) {
20108
+ jsonResponse(res, 500, { ok: false, error: err.message });
20109
+ }
20110
+ return;
20111
+ }
20112
+
19801
20113
  if (path === "/api/executor") {
19802
20114
  const executor = uiDeps.getInternalExecutor?.();
19803
20115
  const mode = uiDeps.getExecutorMode?.() || "internal";
@@ -393,6 +393,8 @@ export function buildKanbanColumnItems(tasks = [], hierarchyView = null, hierarc
393
393
  const groups = new Map();
394
394
  const taskById = hierarchyModel?.taskById || new Map();
395
395
  const epicGroupById = new Map((hierarchyView?.epicGroups || []).map((entry) => [String(entry.id || ""), entry]));
396
+ const nodeStateById = hierarchyView?.nodeStateById || new Map();
397
+ const hasSearch = nodeStateById.size > 0;
396
398
  rows.sort((left, right) => getHierarchySortOrder(hierarchyModel, left?.id) - getHierarchySortOrder(hierarchyModel, right?.id));
397
399
 
398
400
  const ensureGroup = (key, create) => {
@@ -402,7 +404,8 @@ export function buildKanbanColumnItems(tasks = [], hierarchyView = null, hierarc
402
404
 
403
405
  for (const task of rows) {
404
406
  const taskId = String(task?.id || "");
405
- const node = hierarchyView?.nodeStateById?.get?.(taskId) || null;
407
+ if (hierarchyView?.visibleTaskIds?.size && !hierarchyView.visibleTaskIds.has(taskId)) continue;
408
+ const node = nodeStateById.get?.(taskId) || null;
406
409
  const parentId = String(node?.meta?.parentTaskId || "");
407
410
  if (parentId && hierarchyView?.nodeStateById?.get?.(parentId)?.isParentNode) {
408
411
  const parentNode = hierarchyView.nodeStateById.get(parentId);
@@ -416,10 +419,14 @@ export function buildKanbanColumnItems(tasks = [], hierarchyView = null, hierarc
416
419
  children: [],
417
420
  }));
418
421
  if (parentTask?.id) coveredHeaderTaskIds.add(String(parentTask.id));
422
+ const isDirectMatch = node?.searchMatchState === "direct" || node?.searchMatchState === "self";
419
423
  if (taskId === parentId) {
420
424
  coveredHeaderTaskIds.add(taskId);
421
425
  continue;
422
426
  }
427
+ if (hasSearch && parentNode && !isDirectMatch && !parentNode.visibleChildIds?.includes(taskId)) {
428
+ continue;
429
+ }
423
430
  groupedTaskIds.add(taskId);
424
431
  group.children.push(task);
425
432
  continue;
@@ -430,20 +437,26 @@ export function buildKanbanColumnItems(tasks = [], hierarchyView = null, hierarc
430
437
  const taskType = getTaskHierarchyTaskType(task, "task");
431
438
  if (epicGroup && epicGroup.visibleTaskCount > 1 && taskType !== "epic") {
432
439
  const anchorTask = epicGroup.anchorTaskId ? taskById.get(epicGroup.anchorTaskId) : null;
440
+ const anchorTaskId = String(anchorTask?.id || epicGroup.anchorTaskId || "");
441
+ const anchorNode = anchorTaskId ? nodeStateById.get?.(anchorTaskId) || null : null;
433
442
  const group = ensureGroup(`epic:${epicId}`, () => ({
434
443
  key: `epic:${epicId}`,
435
444
  kind: "epic",
436
445
  parentTask: anchorTask,
437
- parentNode: anchorTask ? hierarchyView?.nodeStateById?.get?.(String(anchorTask.id)) || null : null,
446
+ parentNode: anchorNode,
438
447
  epicGroup,
439
448
  title: epicGroup.label || epicId,
440
449
  children: [],
441
450
  }));
442
451
  if (anchorTask?.id) coveredHeaderTaskIds.add(String(anchorTask.id));
443
- if (anchorTask?.id && taskId === String(anchorTask.id)) {
452
+ if (anchorTaskId && taskId === anchorTaskId) {
444
453
  coveredHeaderTaskIds.add(taskId);
445
454
  continue;
446
455
  }
456
+ const isDirectMatch = node?.searchMatchState === "direct" || node?.searchMatchState === "self";
457
+ if (hasSearch && !isDirectMatch && !epicGroup.visibleTaskIds?.includes(taskId)) {
458
+ continue;
459
+ }
447
460
  groupedTaskIds.add(taskId);
448
461
  group.children.push(task);
449
462
  }
@@ -465,6 +478,7 @@ export function buildKanbanColumnItems(tasks = [], hierarchyView = null, hierarc
465
478
 
466
479
  for (const task of rows) {
467
480
  const taskId = String(task?.id || "");
481
+ if (hierarchyView?.visibleTaskIds?.size && !hierarchyView.visibleTaskIds.has(taskId)) continue;
468
482
  if (groupedTaskIds.has(taskId) || coveredHeaderTaskIds.has(taskId)) continue;
469
483
  items.push({
470
484
  kind: "task",
@@ -477,6 +491,46 @@ export function buildKanbanColumnItems(tasks = [], hierarchyView = null, hierarc
477
491
  return items;
478
492
  }
479
493
 
494
+ export function groupTasksByStatus(tasks = [], options = {}) {
495
+ const rows = Array.isArray(tasks) ? tasks.filter((task) => task && typeof task === "object") : [];
496
+ const statuses = Array.isArray(options?.statuses) && options.statuses.length > 0
497
+ ? options.statuses.map((status) => String(status || "").trim()).filter(Boolean)
498
+ : ["todo", "inprogress", "inreview", "done", "blocked", "draft"];
499
+ const result = Object.create(null);
500
+ for (const status of statuses) result[status] = [];
501
+
502
+ if (options?.hierarchyMode) {
503
+ const hierarchyModel = buildTaskHierarchyModel(rows);
504
+ const hierarchyView = deriveTaskHierarchyView(hierarchyModel);
505
+ for (const status of statuses) {
506
+ const statusRows = rows.filter((task) => String(task?.status || "").trim() === status);
507
+ const grouped = buildKanbanColumnItems(statusRows, hierarchyView, hierarchyModel);
508
+ result[status] = grouped.map((entry) => {
509
+ if (entry?.kind === "group") {
510
+ const parentTask = entry.group?.parentTask || null;
511
+ const childTasks = Array.isArray(entry.group?.children) ? entry.group.children.filter(Boolean) : [];
512
+ if (parentTask && childTasks.length > 0) {
513
+ return {
514
+ ...parentTask,
515
+ childTasks,
516
+ };
517
+ }
518
+ }
519
+ return entry?.task || entry;
520
+ }).filter(Boolean);
521
+ }
522
+ return result;
523
+ }
524
+
525
+ for (const task of rows) {
526
+ const normalizedStatus = String(task?.status || "").trim();
527
+ if (!normalizedStatus) continue;
528
+ if (!Array.isArray(result[normalizedStatus])) result[normalizedStatus] = [];
529
+ result[normalizedStatus].push(task);
530
+ }
531
+ return result;
532
+ }
533
+
480
534
  function getTaskTypeBadgeLabel(taskType) {
481
535
  const normalized = String(taskType || "task").toLowerCase();
482
536
  if (normalized === "epic") return "EPIC";
@@ -1689,5 +1743,3 @@ export function KanbanBoard({ onOpenTask, hasMoreTasks = false, loadingMoreTasks
1689
1743
  </${Box}>
1690
1744
  `;
1691
1745
  }
1692
-
1693
-
@@ -7288,7 +7288,7 @@
7288
7288
  "type": "condition.expression",
7289
7289
  "label": "PR Created?",
7290
7290
  "config": {
7291
- "expression": "Boolean($ctx.getNodeOutput($edge.source)?.prNumber || $ctx.getNodeOutput($edge.source)?.prUrl)"
7291
+ "expression": "Boolean($ctx.getNodeOutput('create-pr')?.prNumber || $ctx.getNodeOutput('create-pr')?.prUrl)"
7292
7292
  },
7293
7293
  "position": {
7294
7294
  "x": 400,
@@ -7531,7 +7531,7 @@
7531
7531
  "type": "condition.expression",
7532
7532
  "label": "PR Created?",
7533
7533
  "config": {
7534
- "expression": "Boolean($ctx.getNodeOutput($edge.source)?.prNumber || $ctx.getNodeOutput($edge.source)?.prUrl)"
7534
+ "expression": "Boolean($ctx.getNodeOutput('create-pr-retry')?.prNumber || $ctx.getNodeOutput('create-pr-retry')?.prUrl)"
7535
7535
  },
7536
7536
  "position": {
7537
7537
  "x": 400,
@@ -7759,7 +7759,7 @@
7759
7759
  "type": "condition.expression",
7760
7760
  "label": "PR Created?",
7761
7761
  "config": {
7762
- "expression": "Boolean($ctx.getNodeOutput($edge.source)?.prNumber || $ctx.getNodeOutput($edge.source)?.prUrl)"
7762
+ "expression": "Boolean($ctx.getNodeOutput('create-pr-retry2')?.prNumber || $ctx.getNodeOutput('create-pr-retry2')?.prUrl)"
7763
7763
  },
7764
7764
  "position": {
7765
7765
  "x": 400,
@@ -9950,7 +9950,7 @@
9950
9950
  "config": {
9951
9951
  "plannerNodeId": "run-planner",
9952
9952
  "maxTasks": "{{taskCount}}",
9953
- "status": "draft",
9953
+ "status": "todo",
9954
9954
  "dedup": true,
9955
9955
  "failOnZero": true,
9956
9956
  "minCreated": 1
@@ -10575,7 +10575,7 @@
10575
10575
  "type": "action.run_agent",
10576
10576
  "label": "Build Follow-up Tasks JSON",
10577
10577
  "config": {
10578
- "prompt": "Convert FOLLOW_UP_ACTION lines below into a single JSON object with shape { \"tasks\": [...] }.\n\nSource:\n{{evaluate-fitness.output}}\n\nStructured context:\n{{fitnessSummaryJson}}\n\nRules:\n- Generate at most {{maxFollowupTasks}} tasks\n- Include fields: title, description, implementation_steps, acceptance_criteria, verification, priority, tags, base_branch, impact, confidence, risk, estimated_effort, repo_areas, why_now, kill_criteria\n- Use trend deltas from the summary artifact to justify urgency and avoid parse errors\n- Keep tasks implementation-ready and avoid duplicates\n- Return only JSON",
10578
+ "prompt": "Convert FOLLOW_UP_ACTION lines below into a single JSON object with shape { \"tasks\": [...] }.\n\nSource:\n{{evaluate-fitness.output}}\n\nStructured context:\n{{fitnessSummaryJson}}\n\nRules:\n- Generate at most {{maxFollowupTasks}} tasks\n- Include fields: task_key, title, description, implementation_steps, files, tests, api_contracts, acceptance_criteria, verification, priority, tags, base_branch, impact, confidence, risk, estimated_effort, repo_areas, why_now, kill_criteria\n- Use sprint_id, epic_id, parent_task_key, and depends_on_task_keys when the work belongs in an existing sprint/epic or has DAG ordering\n- Use trend deltas from the summary artifact to justify urgency and avoid parse errors\n- Keep tasks implementation-ready and avoid duplicates\n- Return only JSON",
10579
10579
  "sdk": "auto",
10580
10580
  "timeoutMs": 300000
10581
10581
  },
@@ -22958,7 +22958,7 @@
22958
22958
  "enabled": true,
22959
22959
  "trigger": "trigger.task_available",
22960
22960
  "variables": {
22961
- "maxConcurrent": 3,
22961
+ "maxConcurrent": 5,
22962
22962
  "pollStatus": "todo",
22963
22963
  "maxBatchSize": 10,
22964
22964
  "subWorkflow": "template-task-lifecycle",
@@ -32953,7 +32953,7 @@
32953
32953
  "type": "condition.expression",
32954
32954
  "label": "PR Created?",
32955
32955
  "config": {
32956
- "expression": "Boolean($ctx.getNodeOutput($edge.source)?.prNumber || $ctx.getNodeOutput($edge.source)?.prUrl)"
32956
+ "expression": "Boolean($ctx.getNodeOutput('create-pr')?.prNumber || $ctx.getNodeOutput('create-pr')?.prUrl)"
32957
32957
  },
32958
32958
  "position": {
32959
32959
  "x": 400,
@@ -33196,7 +33196,7 @@
33196
33196
  "type": "condition.expression",
33197
33197
  "label": "PR Created?",
33198
33198
  "config": {
33199
- "expression": "Boolean($ctx.getNodeOutput($edge.source)?.prNumber || $ctx.getNodeOutput($edge.source)?.prUrl)"
33199
+ "expression": "Boolean($ctx.getNodeOutput('create-pr-retry')?.prNumber || $ctx.getNodeOutput('create-pr-retry')?.prUrl)"
33200
33200
  },
33201
33201
  "position": {
33202
33202
  "x": 400,
@@ -33424,7 +33424,7 @@
33424
33424
  "type": "condition.expression",
33425
33425
  "label": "PR Created?",
33426
33426
  "config": {
33427
- "expression": "Boolean($ctx.getNodeOutput($edge.source)?.prNumber || $ctx.getNodeOutput($edge.source)?.prUrl)"
33427
+ "expression": "Boolean($ctx.getNodeOutput('create-pr-retry2')?.prNumber || $ctx.getNodeOutput('create-pr-retry2')?.prUrl)"
33428
33428
  },
33429
33429
  "position": {
33430
33430
  "x": 400,
@@ -35486,7 +35486,7 @@
35486
35486
  "config": {
35487
35487
  "plannerNodeId": "run-planner",
35488
35488
  "maxTasks": "{{taskCount}}",
35489
- "status": "draft",
35489
+ "status": "todo",
35490
35490
  "dedup": true,
35491
35491
  "failOnZero": true,
35492
35492
  "minCreated": 1
@@ -36069,7 +36069,7 @@
36069
36069
  "type": "action.run_agent",
36070
36070
  "label": "Build Follow-up Tasks JSON",
36071
36071
  "config": {
36072
- "prompt": "Convert FOLLOW_UP_ACTION lines below into a single JSON object with shape { \"tasks\": [...] }.\n\nSource:\n{{evaluate-fitness.output}}\n\nStructured context:\n{{fitnessSummaryJson}}\n\nRules:\n- Generate at most {{maxFollowupTasks}} tasks\n- Include fields: title, description, implementation_steps, acceptance_criteria, verification, priority, tags, base_branch, impact, confidence, risk, estimated_effort, repo_areas, why_now, kill_criteria\n- Use trend deltas from the summary artifact to justify urgency and avoid parse errors\n- Keep tasks implementation-ready and avoid duplicates\n- Return only JSON",
36072
+ "prompt": "Convert FOLLOW_UP_ACTION lines below into a single JSON object with shape { \"tasks\": [...] }.\n\nSource:\n{{evaluate-fitness.output}}\n\nStructured context:\n{{fitnessSummaryJson}}\n\nRules:\n- Generate at most {{maxFollowupTasks}} tasks\n- Include fields: task_key, title, description, implementation_steps, files, tests, api_contracts, acceptance_criteria, verification, priority, tags, base_branch, impact, confidence, risk, estimated_effort, repo_areas, why_now, kill_criteria\n- Use sprint_id, epic_id, parent_task_key, and depends_on_task_keys when the work belongs in an existing sprint/epic or has DAG ordering\n- Use trend deltas from the summary artifact to justify urgency and avoid parse errors\n- Keep tasks implementation-ready and avoid duplicates\n- Return only JSON",
36073
36073
  "sdk": "auto",
36074
36074
  "timeoutMs": 300000
36075
36075
  },
@@ -47874,7 +47874,7 @@
47874
47874
  "nodeCount": 9,
47875
47875
  "trigger": "trigger.task_available",
47876
47876
  "variables": {
47877
- "maxConcurrent": 3,
47877
+ "maxConcurrent": 5,
47878
47878
  "pollStatus": "todo",
47879
47879
  "maxBatchSize": 10,
47880
47880
  "subWorkflow": "template-task-lifecycle",
@@ -144,7 +144,7 @@ export function buildTaskHierarchyModel(tasks = [], options = {}) {
144
144
  taskType,
145
145
  epicId,
146
146
  parentTaskId,
147
- collapseKey: getTaskHierarchyCollapseKey("task", id),
147
+ collapseKey: getTaskHierarchyCollapseKey(taskType, id),
148
148
  epicCollapseKey: epicId ? getTaskHierarchyCollapseKey("epic", epicId) : "",
149
149
  });
150
150
  }
@@ -278,7 +278,7 @@ export function deriveTaskHierarchyView(model, options = {}) {
278
278
  }, 0);
279
279
  const descendantMatch = visibleChildIds.length > 0;
280
280
  const visible = directMatch || descendantMatch;
281
- const searchMatchState = directMatch ? "self" : descendantMatch ? "descendant" : "none";
281
+ const searchMatchState = directMatch ? "direct" : descendantMatch ? "descendant" : "none";
282
282
  const state = {
283
283
  id,
284
284
  task,
package/ui/tabs/agents.js CHANGED
@@ -3445,10 +3445,10 @@ function FleetSessionsPanel({ slots, sessions = [], taskFallbackEntries = [], on
3445
3445
  onClick=${() => setDetailTab("turns")}
3446
3446
  >${iconText(":repeat: Turns")}<//>
3447
3447
  </div>
3448
- <div class="fleet-session-body" style=${{ flex: "1 1 auto", minHeight: 0, overflowY: "auto", overflowX: "hidden", paddingRight: "4px" }}>
3448
+ <div class="fleet-session-body" style=${{ flex: "1 1 auto", minHeight: 0, overflow: "hidden", paddingRight: "4px" }}>
3449
3449
  ${detailTab === "stream"
3450
3450
  ? streamSessionId
3451
- ? html`<${ChatView} sessionId=${streamSessionId} readOnly=${true} />`
3451
+ ? html`<${ChatView} sessionId=${streamSessionId} readOnly=${true} embedded=${true} />`
3452
3452
  : html`
3453
3453
  <div class="chat-view chat-empty-state">
3454
3454
  <div class="session-empty-icon">${resolveIcon(":chat:")}</div>
package/ui/tabs/tasks.js CHANGED
@@ -242,7 +242,8 @@ function normalizeTagInput(input) {
242
242
  const seen = new Set();
243
243
  const tags = [];
244
244
  for (const value of values) {
245
- const normalized = String(value || "")
245
+ const raw = typeof value === "string" ? value : value?.name || value?.label || value?.value || "";
246
+ const normalized = String(raw || "")
246
247
  .trim()
247
248
  .toLowerCase();
248
249
  if (!normalized || seen.has(normalized) || SYSTEM_TAGS.has(normalized)) continue;
@@ -441,6 +442,8 @@ export function normalizeSubtaskRow(entry, fallbackParentTaskId = "") {
441
442
  status: toText(entry.status || entry.state),
442
443
  assignee: toText(entry.assignee || entry.owner),
443
444
  taskType: normalizeTaskTypeValue(entry?.taskType || entry?.type || entry?.kind || entry?.meta?.taskType || entry?.meta?.type, "subtask"),
445
+ collapseKey: toText(entry.collapseKey || entry?.meta?.collapseKey),
446
+ epicCollapseKey: toText(entry.epicCollapseKey || entry?.meta?.epicCollapseKey),
444
447
  storyPoints: toText(entry.storyPoints || entry.points),
445
448
  epicId: toText(entry.epicId || entry.epic || entry.epic_id || entry?.meta?.epicId),
446
449
  dueDate: normalizeTaskDueDateInput(entry),
@@ -484,7 +487,9 @@ function mergeSubtaskLists(...lists) {
484
487
  }
485
488
 
486
489
  export function buildTaskHierarchyPath(task, hierarchyModel = null) {
487
- const taskById = hierarchyModel?.taskById || new Map();
490
+ const taskById = hierarchyModel instanceof Map
491
+ ? hierarchyModel
492
+ : (hierarchyModel?.taskById || new Map());
488
493
  const path = [];
489
494
  const seen = new Set();
490
495
  let cursor = task;
@@ -5157,13 +5157,14 @@ export class WorkflowEngine extends EventEmitter {
5157
5157
  }
5158
5158
  const createTasksGuard = this._resolvePendingCreateTasksGuard(originalRun, def);
5159
5159
  const isInterruptedResume = retryOpts._resumeInterrupted === true;
5160
+ const operatorAction = String(retryOpts.operatorAction || "").trim().toLowerCase();
5160
5161
  const isResumeLikeRetry =
5161
5162
  (mode === "from_failed" && !isInterruptedResume)
5162
5163
  || mode === "replan_from_failed"
5163
5164
  || mode === "replan_subgraph";
5164
5165
  const blocksManualRestart = mode === "from_scratch" && createTasksGuard?.safeResume;
5165
5166
  if (createTasksGuard && (isResumeLikeRetry || blocksManualRestart)) {
5166
- if (isInterruptedResume) {
5167
+ if (isInterruptedResume || operatorAction === "resume") {
5167
5168
  if (!createTasksGuard.safeResume) {
5168
5169
  throw new Error(`${TAG} ${createTasksGuard.resumeBlockedMessage}`);
5169
5170
  }
@@ -5672,8 +5673,10 @@ export class WorkflowEngine extends EventEmitter {
5672
5673
  if (!this._loaded) this.load();
5673
5674
 
5674
5675
  const triggered = [];
5676
+ const triggeredWorkflowIds = new Set();
5675
5677
  for (const [id, def] of this._workflows) {
5676
5678
  if (def.enabled === false) continue;
5679
+ if (triggeredWorkflowIds.has(id)) continue;
5677
5680
 
5678
5681
  // Find trigger nodes
5679
5682
  const triggerNodes = (def.nodes || []).filter((n) =>
@@ -5733,6 +5736,8 @@ export class WorkflowEngine extends EventEmitter {
5733
5736
  });
5734
5737
  if (shouldFire?.triggered) {
5735
5738
  triggered.push({ workflowId: id, triggeredBy: tNode.id, eventData });
5739
+ triggeredWorkflowIds.add(id);
5740
+ break;
5736
5741
  }
5737
5742
  } catch {
5738
5743
  // Trigger evaluation errors are non-fatal
@@ -7109,7 +7114,6 @@ export class WorkflowEngine extends EventEmitter {
7109
7114
  };
7110
7115
  }
7111
7116
 
7112
-
7113
7117
  /**
7114
7118
  * Get task-linked workflow trace events for a run.
7115
7119
  * Returns [] when run is unknown or has no task trace data.
@@ -9937,6 +9941,14 @@ export class WorkflowEngine extends EventEmitter {
9937
9941
  }
9938
9942
  }
9939
9943
 
9944
+ const indexedRunCountsByTaskId = new Map();
9945
+ for (const run of allRuns) {
9946
+ const ident = identityCache.get(run.runId) ?? getIdentity(run.runId);
9947
+ const taskId = this._resolveRunTaskIdentity(run, ident)?.taskId || "";
9948
+ if (!taskId) continue;
9949
+ indexedRunCountsByTaskId.set(taskId, (indexedRunCountsByTaskId.get(taskId) || 0) + 1);
9950
+ }
9951
+
9940
9952
  // Mark older duplicate runs as not-resumable before entering the loop.
9941
9953
  // This must consider the full paused+resumable set so historical siblings
9942
9954
  // outside the current startup cohort are still retired when a newer run wins.
@@ -10082,7 +10094,7 @@ export class WorkflowEngine extends EventEmitter {
10082
10094
  : (watchdogDecision || this._chooseRetryModeFromDetail(detail, {
10083
10095
  fallbackMode: "from_scratch",
10084
10096
  }));
10085
- const detailedRun = this.getRunDetail(run.runId) || run;
10097
+ const detailedRun = this.getRunDetail(run.runId, { decorate: false }) || { ...run, detail };
10086
10098
  const createTasksGuard = this._resolvePendingCreateTasksGuard(detailedRun, def);
10087
10099
  if (createTasksGuard && !createTasksGuard.safeResume) {
10088
10100
  console.warn(`${TAG} Skipping run ${run.runId}: ${createTasksGuard.resumeBlockedMessage}`);
@@ -5169,7 +5169,7 @@ registerNodeType("action.run_agent", {
5169
5169
  for (let idx = 1; idx <= configuredCandidateCount; idx += 1) {
5170
5170
  const candidateBranch =
5171
5171
  `${safeBranchPart(currentBranch)}-cand-${idx}-${batchToken}`.slice(0, 120);
5172
- execSync(`git checkout -B "${candidateBranch}" "${baselineHead}"`, {
5172
+ execFileSync("git", ["checkout", "-B", candidateBranch, baselineHead], {
5173
5173
  cwd,
5174
5174
  stdio: ["ignore", "pipe", "pipe"],
5175
5175
  encoding: "utf8",
@@ -5252,7 +5252,7 @@ registerNodeType("action.run_agent", {
5252
5252
  }
5253
5253
 
5254
5254
  const selectedHead = selected.hasCommit ? selected.head : baselineHead;
5255
- execSync(`git checkout -B "${currentBranch}" "${selectedHead}"`, {
5255
+ execFileSync("git", ["checkout", "-B", currentBranch, selectedHead], {
5256
5256
  cwd,
5257
5257
  stdio: ["ignore", "pipe", "pipe"],
5258
5258
  encoding: "utf8",
@@ -5261,7 +5261,7 @@ registerNodeType("action.run_agent", {
5261
5261
  for (const candidate of candidateRuns) {
5262
5262
  if (!candidate?.branch) continue;
5263
5263
  try {
5264
- execSync(`git branch -D "${candidate.branch}"`, {
5264
+ execFileSync("git", ["branch", "-D", candidate.branch], {
5265
5265
  cwd,
5266
5266
  stdio: ["ignore", "pipe", "pipe"],
5267
5267
  encoding: "utf8",
@@ -7438,6 +7438,16 @@ registerNodeType("action.materialize_planner_tasks", {
7438
7438
  }
7439
7439
  if (Array.isArray(task.tags) && task.tags.length > 0) payload.tags = task.tags;
7440
7440
  if (task.baseBranch) payload.baseBranch = task.baseBranch;
7441
+ if (task.sprintId) {
7442
+ payload.sprintId = task.sprintId;
7443
+ }
7444
+ if (task.epicId) {
7445
+ payload.epicId = task.epicId;
7446
+ }
7447
+ if (Array.isArray(task.dependencyTaskIds) && task.dependencyTaskIds.length > 0) {
7448
+ payload.dependencyTaskIds = task.dependencyTaskIds;
7449
+ payload.dependsOn = task.dependencyTaskIds;
7450
+ }
7441
7451
  if (task.draft || String(status || "").trim().toLowerCase() === "draft") {
7442
7452
  payload.draft = true;
7443
7453
  }
@@ -7472,16 +7482,38 @@ registerNodeType("action.materialize_planner_tasks", {
7472
7482
  run_id: String(ctx?.runId || ctx?.data?._runId || "").trim() || null,
7473
7483
  acceptance_criteria: task.acceptanceCriteria,
7474
7484
  verification: task.verification,
7485
+ implementation_steps: Array.isArray(task.implementationSteps) ? task.implementationSteps : [],
7486
+ files: Array.isArray(task.files) ? task.files : [],
7487
+ tests: Array.isArray(task.tests) ? task.tests : [],
7488
+ api_contracts: Array.isArray(task.apiContracts) ? task.apiContracts : [],
7475
7489
  task_key: task.taskKey || null,
7476
7490
  parent_task_key: task.parentTaskKey || null,
7477
7491
  parent_task_id: task.parentTaskId || null,
7478
7492
  depends_on_task_keys: Array.isArray(task.dependencyTaskKeys) ? task.dependencyTaskKeys : [],
7479
7493
  depends_on_task_ids: Array.isArray(task.dependencyTaskIds) ? task.dependencyTaskIds : [],
7494
+ sprint_id: task.sprintId || null,
7495
+ epic_id: task.epicId || null,
7480
7496
  decomposition_kind: task.decompositionKind || null,
7481
7497
  spawn_when: task.spawnWhen || null,
7482
7498
  merge_back_policy: task.mergeBackPolicy || null,
7483
7499
  dedupe_key: plannerDedupeKey,
7484
7500
  };
7501
+ const plannerWorkflowRunId = String(ctx?.runId || ctx?.data?.runId || ctx?.data?._runId || "").trim() || null;
7502
+ existingMeta.plannerProvenance = {
7503
+ plannerRunId: plannerWorkflowRunId,
7504
+ workflowRunId: plannerWorkflowRunId,
7505
+ plannerNodeId,
7506
+ sourceNodeId: node.id,
7507
+ sourceNodeType: "action.materialize_planner_tasks",
7508
+ sourceLabels: ["workflow", "planner", "materialize_planner_tasks"],
7509
+ generatedAt:
7510
+ typeof existingMeta.plannerProvenance?.generatedAt === "string"
7511
+ ? existingMeta.plannerProvenance.generatedAt
7512
+ : new Date().toISOString(),
7513
+ dedupeKey: plannerDedupeKey,
7514
+ materializationFingerprint,
7515
+ taskIndex: task.index,
7516
+ };
7485
7517
  payload.meta = existingMeta;
7486
7518
  const existingTaskByProvenance = Array.isArray(existingRows)
7487
7519
  ? existingRows.find((candidate) => {
@@ -369,14 +369,22 @@ export const CALIBRATED_MAX_RISK_WITHOUT_HUMAN = "medium";
369
369
  export const STRICT_TASK_PLANNER_TASK_COUNT = 8;
370
370
  const TASK_PLANNER_REQUIRED_TOP_LEVEL_KEYS = ["tasks"];
371
371
  const TASK_PLANNER_REQUIRED_TASK_FIELDS = [
372
+ "task_key",
372
373
  "title",
373
374
  "description",
375
+ "implementation_steps",
376
+ "files",
377
+ "tests",
378
+ "api_contracts",
374
379
  "acceptance_criteria",
375
380
  "verification",
376
381
  "repo_areas",
377
382
  "impact",
378
383
  "confidence",
379
384
  "risk",
385
+ "estimated_effort",
386
+ "why_now",
387
+ "kill_criteria",
380
388
  ];
381
389
  const TASK_PLANNER_ALLOWED_ESTIMATED_EFFORT = ["small", "medium", "large"];
382
390
  const PLANNER_SCORE_MODE_RATIO = "ratio";
@@ -557,10 +565,18 @@ export function normalizePlannerTaskForCreation(task, index, options = {}) {
557
565
  const strict = options?.strict === true;
558
566
 
559
567
  const normalizeStringList = (value) => {
560
- if (!Array.isArray(value)) return [];
561
- return value
562
- .map((entry) => String(entry || "").trim())
563
- .filter(Boolean);
568
+ if (Array.isArray(value)) {
569
+ return value
570
+ .map((entry) => String(entry || "").trim())
571
+ .filter(Boolean);
572
+ }
573
+ if (typeof value === "string") {
574
+ return value
575
+ .split(/\r?\n|,/)
576
+ .map((entry) => entry.replace(/^[-*]\s+/, "").trim())
577
+ .filter(Boolean);
578
+ }
579
+ return [];
564
580
  };
565
581
  const normalizeRepoAreas = (value) => {
566
582
  const list = normalizeStringList(value);
@@ -590,12 +606,24 @@ export function normalizePlannerTaskForCreation(task, index, options = {}) {
590
606
  const lines = [];
591
607
  const description = String(task.description || "").trim();
592
608
  if (description) lines.push(description);
609
+ const implementationSteps = strict
610
+ ? validateStrictPlannerRequiredField("implementation_steps", task.implementation_steps || task.implementationSteps, { type: "array" })
611
+ : normalizeStringList(task.implementation_steps || task.implementationSteps);
593
612
  const acceptanceCriteria = strict
594
- ? validateStrictPlannerRequiredField("acceptance_criteria", task.acceptance_criteria, { type: "array" })
595
- : normalizeStringList(task.acceptance_criteria);
613
+ ? validateStrictPlannerRequiredField("acceptance_criteria", task.acceptance_criteria || task.acceptanceCriteria, { type: "array" })
614
+ : normalizeStringList(task.acceptance_criteria || task.acceptanceCriteria);
596
615
  const verification = strict
597
616
  ? validateStrictPlannerRequiredField("verification", task.verification, { type: "array" })
598
617
  : normalizeStringList(task.verification);
618
+ const files = strict
619
+ ? validateStrictPlannerRequiredField("files", task.files || task.file_paths || task.filePaths || task.paths, { type: "array" })
620
+ : normalizeStringList(task.files || task.file_paths || task.filePaths || task.paths);
621
+ const tests = strict
622
+ ? validateStrictPlannerRequiredField("tests", task.tests || task.test_commands || task.testCommands, { type: "array" })
623
+ : normalizeStringList(task.tests || task.test_commands || task.testCommands);
624
+ const apiContracts = strict
625
+ ? validateStrictPlannerRequiredField("api_contracts", task.api_contracts || task.apiContracts, { type: "array" })
626
+ : normalizeStringList(task.api_contracts || task.apiContracts);
599
627
  const repoAreas = normalizeRepoAreas(
600
628
  strict
601
629
  ? validateStrictPlannerRequiredField("repo_areas", task.repo_areas || task.repoAreas, { type: "array" })
@@ -671,13 +699,18 @@ export function normalizePlannerTaskForCreation(task, index, options = {}) {
671
699
  for (const item of items) lines.push(`- ${item}`);
672
700
  };
673
701
 
674
- appendList("Implementation Steps", task.implementation_steps);
702
+ appendList("Implementation Steps", implementationSteps);
703
+ appendList("Files", files);
704
+ appendList("Tests", tests);
705
+ appendList("API Contracts", apiContracts);
675
706
  appendList("Acceptance Criteria", acceptanceCriteria);
676
707
  appendList("Verification", verification);
677
708
 
678
709
  const baseBranch = String(task.base_branch || "").trim();
679
710
  const workspace = String(task.workspace || "").trim();
680
711
  const repository = String(task.repository || task.repo || "").trim();
712
+ const sprintId = String(task.sprint_id || task.sprintId || task.sprint || "").trim();
713
+ const epicId = String(task.epic_id || task.epicId || task.epic || "").trim();
681
714
  const repositories = Array.isArray(task.repositories)
682
715
  ? task.repositories.map((entry) => String(entry || "").trim()).filter(Boolean)
683
716
  : [];
@@ -707,6 +740,10 @@ export function normalizePlannerTaskForCreation(task, index, options = {}) {
707
740
  requestedStatus: requestedStatus || null,
708
741
  acceptanceCriteria,
709
742
  verification,
743
+ implementationSteps,
744
+ files,
745
+ tests,
746
+ apiContracts,
710
747
  repoAreas,
711
748
  impact,
712
749
  confidence,
@@ -719,6 +756,8 @@ export function normalizePlannerTaskForCreation(task, index, options = {}) {
719
756
  parentTaskId,
720
757
  dependencyTaskKeys,
721
758
  dependencyTaskIds,
759
+ sprintId: sprintId || null,
760
+ epicId: epicId || null,
722
761
  decompositionKind,
723
762
  spawnWhen,
724
763
  mergeBackPolicy,
@@ -797,7 +836,7 @@ export function validateStrictTaskPlannerPayload(payload, options = {}) {
797
836
  return { ok: false, error: `${taskLabel}.description must be a non-empty string`, code: "invalid_field", taskIndex: i, field: "description" };
798
837
  }
799
838
 
800
- const listFields = ["acceptance_criteria", "verification", "repo_areas"];
839
+ const listFields = ["implementation_steps", "files", "tests", "api_contracts", "acceptance_criteria", "verification", "repo_areas", "kill_criteria"];
801
840
  for (const field of listFields) {
802
841
  const value = task[field];
803
842
  if (!Array.isArray(value) || value.length === 0 || value.some((entry) => String(entry || "").trim() === "")) {
@@ -1731,6 +1770,9 @@ registerNodeType("agent.run_planner", {
1731
1770
  ].filter(Boolean).join("\n\n")}\n\n`
1732
1771
  : "\n") +
1733
1772
  `Your response MUST be a single fenced JSON block with shape { "tasks": [...] }.\n` +
1773
+ `Each task object MUST include: task_key, title, description, implementation_steps, files, tests, api_contracts, acceptance_criteria, verification, repo_areas, impact, confidence, risk, estimated_effort, why_now, and kill_criteria.\n` +
1774
+ `Use sprint_id and epic_id when the planned work belongs to a sprint or epic. Use parent_task_key and depends_on_task_keys to encode DAG/dependency order between generated tasks.\n` +
1775
+ `Descriptions must be ticket-ready: describe the current problem, exact scope, implementation approach, and user-visible outcome in enough detail for an executor to work without rediscovering intent.\n` +
1734
1776
  `Do NOT include status updates, analysis notes, tool commentary, questions, or prose outside the JSON block.\n` +
1735
1777
  `The downstream system will parse your output as JSON — any extra text will cause task creation to fail.`;
1736
1778
  const promptText = basePrompt
@@ -503,9 +503,27 @@ export const BACKEND_AGENT_TEMPLATE = (() => {
503
503
  const mainValidation = embedSubWorkflow(VALIDATION_GATE_SUB, "main-");
504
504
  const retryValidation = embedSubWorkflow(VALIDATION_GATE_SUB, "retry-");
505
505
  const retry2Validation = embedSubWorkflow(VALIDATION_GATE_SUB, "retry2-");
506
- const mainPrHandoff = embedSubWorkflow(PR_CHECK_HANDOFF_SUB, "main-");
507
- const retryPrHandoff = embedSubWorkflow(PR_CHECK_HANDOFF_SUB, "retry-");
508
- const retry2PrHandoff = embedSubWorkflow(PR_CHECK_HANDOFF_SUB, "retry2-");
506
+ const mainPrHandoff = embedSubWorkflow(PR_CHECK_HANDOFF_SUB, "main-", {
507
+ nodeOverrides: {
508
+ "pr-ok": {
509
+ expression: "Boolean($ctx.getNodeOutput('create-pr')?.prNumber || $ctx.getNodeOutput('create-pr')?.prUrl)",
510
+ },
511
+ },
512
+ });
513
+ const retryPrHandoff = embedSubWorkflow(PR_CHECK_HANDOFF_SUB, "retry-", {
514
+ nodeOverrides: {
515
+ "pr-ok": {
516
+ expression: "Boolean($ctx.getNodeOutput('create-pr-retry')?.prNumber || $ctx.getNodeOutput('create-pr-retry')?.prUrl)",
517
+ },
518
+ },
519
+ });
520
+ const retry2PrHandoff = embedSubWorkflow(PR_CHECK_HANDOFF_SUB, "retry2-", {
521
+ nodeOverrides: {
522
+ "pr-ok": {
523
+ expression: "Boolean($ctx.getNodeOutput('create-pr-retry2')?.prNumber || $ctx.getNodeOutput('create-pr-retry2')?.prUrl)",
524
+ },
525
+ },
526
+ });
509
527
 
510
528
  return {
511
529
  id: "template-backend-agent",
@@ -76,7 +76,7 @@ export const TASK_PLANNER_TEMPLATE = {
76
76
  node("materialize-tasks", "action.materialize_planner_tasks", "Create Tasks", {
77
77
  plannerNodeId: "run-planner",
78
78
  maxTasks: "{{taskCount}}",
79
- status: "draft",
79
+ status: "todo",
80
80
  dedup: true,
81
81
  failOnZero: true,
82
82
  minCreated: 1,
@@ -929,7 +929,8 @@ Structured context:
929
929
 
930
930
  Rules:
931
931
  - Generate at most {{maxFollowupTasks}} tasks
932
- - Include fields: title, description, implementation_steps, acceptance_criteria, verification, priority, tags, base_branch, impact, confidence, risk, estimated_effort, repo_areas, why_now, kill_criteria
932
+ - Include fields: task_key, title, description, implementation_steps, files, tests, api_contracts, acceptance_criteria, verification, priority, tags, base_branch, impact, confidence, risk, estimated_effort, repo_areas, why_now, kill_criteria
933
+ - Use sprint_id, epic_id, parent_task_key, and depends_on_task_keys when the work belongs in an existing sprint/epic or has DAG ordering
933
934
  - Use trend deltas from the summary artifact to justify urgency and avoid parse errors
934
935
  - Keep tasks implementation-ready and avoid duplicates
935
936
  - Return only JSON`,
@@ -993,5 +994,3 @@ Rules:
993
994
  },
994
995
  };
995
996
 
996
-
997
-
@@ -125,7 +125,7 @@ export const TASK_BATCH_PROCESSOR_TEMPLATE = {
125
125
  recommended: true,
126
126
  trigger: "trigger.task_available",
127
127
  variables: {
128
- maxConcurrent: 3,
128
+ maxConcurrent: 5,
129
129
  pollStatus: "todo",
130
130
  maxBatchSize: 10,
131
131
  subWorkflow: "template-task-lifecycle",