bosun 0.40.2 → 0.40.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.
@@ -188,6 +188,12 @@ const TASK_STORE_DEPENDENCY_EXPORTS = {
188
188
  update: ["updateTask"],
189
189
  };
190
190
  const TASK_STORE_ASSIGN_SPRINT_EXPORTS = ["assignTaskToSprint", "setTaskSprint"];
191
+ const TASK_STORE_EPIC_DEPENDENCY_EXPORTS = Object.freeze({
192
+ list: ["getEpicDependencies", "listEpicDependencies"],
193
+ set: ["setEpicDependencies", "updateEpicDependencies"],
194
+ add: ["addEpicDependency"],
195
+ remove: ["removeEpicDependency"],
196
+ });
191
197
  let taskStoreApi = null;
192
198
  let taskStoreApiPromise = null;
193
199
  let didLogTaskStoreLoadFailure = false;
@@ -6154,6 +6160,68 @@ async function setTaskDependenciesForApi({
6154
6160
  };
6155
6161
  }
6156
6162
 
6163
+ async function listEpicDependenciesForApi() {
6164
+ const listed = await callTaskStoreFunction(TASK_STORE_EPIC_DEPENDENCY_EXPORTS.list, []);
6165
+ if (listed.found && Array.isArray(listed.value)) {
6166
+ return {
6167
+ ok: true,
6168
+ source: `task-store.${listed.found}`,
6169
+ data: listed.value
6170
+ .map((entry) => ({
6171
+ epicId: String(entry?.epicId || entry?.id || "").trim(),
6172
+ dependencies: normalizeTaskIdList(entry?.dependencies || entry?.dependsOn || []),
6173
+ }))
6174
+ .filter((entry) => entry.epicId),
6175
+ };
6176
+ }
6177
+ return { ok: false, source: null, data: [] };
6178
+ }
6179
+
6180
+ async function setEpicDependenciesForApi({ epicId, dependencies }) {
6181
+ const normalizedEpicId = String(epicId || "").trim();
6182
+ if (!normalizedEpicId) return { ok: false, status: 400, error: "epicId required" };
6183
+ const normalizedDependencies = normalizeTaskIdList(dependencies, { exclude: normalizedEpicId });
6184
+
6185
+ const setResult = await callTaskStoreFunction(
6186
+ TASK_STORE_EPIC_DEPENDENCY_EXPORTS.set,
6187
+ [normalizedEpicId, normalizedDependencies],
6188
+ );
6189
+ if (setResult.found) {
6190
+ return {
6191
+ ok: true,
6192
+ source: `task-store.${setResult.found}`,
6193
+ data: {
6194
+ epicId: normalizedEpicId,
6195
+ dependencies: normalizeTaskIdList(setResult.value?.dependencies || normalizedDependencies),
6196
+ },
6197
+ };
6198
+ }
6199
+
6200
+ const listed = await listEpicDependenciesForApi();
6201
+ const current = listed.ok
6202
+ ? normalizeTaskIdList((listed.data.find((entry) => entry.epicId === normalizedEpicId) || {}).dependencies || [])
6203
+ : [];
6204
+
6205
+ const toRemove = current.filter((entry) => !normalizedDependencies.includes(entry));
6206
+ const toAdd = normalizedDependencies.filter((entry) => !current.includes(entry));
6207
+
6208
+ for (const dep of toRemove) {
6209
+ await callTaskStoreFunction(TASK_STORE_EPIC_DEPENDENCY_EXPORTS.remove, [normalizedEpicId, dep]);
6210
+ }
6211
+ for (const dep of toAdd) {
6212
+ await callTaskStoreFunction(TASK_STORE_EPIC_DEPENDENCY_EXPORTS.add, [normalizedEpicId, dep]);
6213
+ }
6214
+
6215
+ const refreshed = await listEpicDependenciesForApi();
6216
+ const row = refreshed.ok
6217
+ ? refreshed.data.find((entry) => entry.epicId === normalizedEpicId)
6218
+ : null;
6219
+ return {
6220
+ ok: true,
6221
+ source: refreshed.source || "task-store.fallback",
6222
+ data: { epicId: normalizedEpicId, dependencies: normalizeTaskIdList(row?.dependencies || []) },
6223
+ };
6224
+ }
6157
6225
  async function getTaskCommentsForApi(taskId, adapter = null) {
6158
6226
  const storeComments = await callTaskStoreFunction(TASK_STORE_COMMENT_EXPORTS, [taskId]);
6159
6227
  if (storeComments.found && Array.isArray(storeComments.value)) {
@@ -6354,6 +6422,150 @@ function applyInternalLifecycleTransition(taskId, action, options = {}) {
6354
6422
  return null;
6355
6423
  }
6356
6424
 
6425
+ async function persistTaskStatusForExecution(adapter, taskId, nextStatus, source) {
6426
+ if (!taskId || !nextStatus || !adapter) return null;
6427
+ const normalized = String(nextStatus || "").trim();
6428
+ if (!normalized) return null;
6429
+ let updated = null;
6430
+ if (typeof adapter.updateTaskStatus === "function") {
6431
+ updated = await adapter.updateTaskStatus(taskId, normalized, { source });
6432
+ } else if (typeof adapter.updateTask === "function") {
6433
+ updated = await adapter.updateTask(taskId, { status: normalized });
6434
+ }
6435
+ return withTaskMetadataTopLevel(updated);
6436
+ }
6437
+
6438
+ async function persistTaskExecutionMeta(adapter, taskId, executionPatch = {}) {
6439
+ if (!taskId || !adapter || typeof adapter.updateTask !== "function") return null;
6440
+ const current = typeof adapter.getTask === "function"
6441
+ ? await adapter.getTask(taskId).catch(() => null)
6442
+ : null;
6443
+ const currentMeta = current?.meta && typeof current.meta === "object" ? current.meta : {};
6444
+ const currentExecution = currentMeta.execution && typeof currentMeta.execution === "object"
6445
+ ? currentMeta.execution
6446
+ : {};
6447
+ const nextExecution = {
6448
+ ...currentExecution,
6449
+ ...executionPatch,
6450
+ };
6451
+ if (nextExecution.queueState == null) delete nextExecution.queueState;
6452
+ return withTaskMetadataTopLevel(await adapter.updateTask(taskId, {
6453
+ meta: {
6454
+ ...currentMeta,
6455
+ execution: nextExecution,
6456
+ },
6457
+ }));
6458
+ }
6459
+
6460
+ function resolveFallbackStatusAfterFailedDispatch(previousStatus, startDispatch) {
6461
+ if (startDispatch?.reason === "no_free_slots") return "queued";
6462
+ const previous = String(previousStatus || "").trim();
6463
+ return previous || "todo";
6464
+ }
6465
+
6466
+ async function reconcileTaskAfterDispatchAttempt({
6467
+ adapter,
6468
+ taskId,
6469
+ previousStatus,
6470
+ requestedStatus,
6471
+ lifecycleAction,
6472
+ startDispatch,
6473
+ source,
6474
+ }) {
6475
+ const action = normalizeLifecycleAction(lifecycleAction);
6476
+ if (action !== "start" && action !== "resume") return null;
6477
+ const requested = normalizeTaskStatusKey(requestedStatus);
6478
+ if (requested !== "inprogress") return null;
6479
+ const targetStatus = startDispatch?.started
6480
+ ? "inprogress"
6481
+ : resolveFallbackStatusAfterFailedDispatch(previousStatus, startDispatch);
6482
+ return persistTaskStatusForExecution(adapter, taskId, targetStatus, source);
6483
+ }
6484
+
6485
+ function buildTaskRuntimeSnapshot(task) {
6486
+ const runtimeExecutor = uiDeps.getInternalExecutor?.() || null;
6487
+ const status = runtimeExecutor?.getStatus?.() || {};
6488
+ const activeSlots = Array.isArray(status?.slots) ? status.slots : [];
6489
+ const taskId = String(task?.id || task?.taskId || "").trim();
6490
+ const slot = taskId
6491
+ ? activeSlots.find((entry) => String(entry?.taskId || entry?.task_id || "").trim() === taskId)
6492
+ : null;
6493
+ const normalizedStatus = normalizeTaskStatusKey(task?.status);
6494
+ const queuedFlag = task?.meta?.execution?.queued === true
6495
+ || normalizeTaskStatusKey(task?.meta?.execution?.queueState) === "queued";
6496
+ if (slot) {
6497
+ return {
6498
+ state: "running",
6499
+ isLive: true,
6500
+ taskId,
6501
+ taskStatus: task?.status || null,
6502
+ statusLabel: "Live execution",
6503
+ slot: {
6504
+ taskId,
6505
+ branch: slot?.branch || slot?.branchName || null,
6506
+ sdk: slot?.sdk || slot?.executor || null,
6507
+ model: slot?.model || null,
6508
+ startedAt: slot?.startedAt || slot?.started_at || null,
6509
+ completedCount: slot?.completedCount || 0,
6510
+ },
6511
+ executor: {
6512
+ activeSlots: Number(status?.activeSlots || activeSlots.length || 0),
6513
+ maxParallel: Number(status?.maxParallel || 0),
6514
+ paused: runtimeExecutor?.isPaused?.() === true,
6515
+ },
6516
+ };
6517
+ }
6518
+ if (queuedFlag || normalizedStatus === "queued") {
6519
+ return {
6520
+ state: "queued",
6521
+ isLive: false,
6522
+ taskId,
6523
+ taskStatus: task?.status || null,
6524
+ statusLabel: "Queued for execution",
6525
+ reason: "no_free_slots",
6526
+ };
6527
+ }
6528
+ if (normalizedStatus === "inprogress") {
6529
+ return {
6530
+ state: "pending",
6531
+ isLive: false,
6532
+ taskId,
6533
+ taskStatus: task?.status || null,
6534
+ statusLabel: "No live execution detected",
6535
+ reason: "no_active_executor_slot",
6536
+ };
6537
+ }
6538
+ if (normalizedStatus === "inreview") {
6539
+ return {
6540
+ state: "review",
6541
+ isLive: false,
6542
+ taskId,
6543
+ taskStatus: task?.status || null,
6544
+ statusLabel: "Awaiting review",
6545
+ };
6546
+ }
6547
+ return {
6548
+ state: "idle",
6549
+ isLive: false,
6550
+ taskId,
6551
+ taskStatus: task?.status || null,
6552
+ statusLabel: "No active execution",
6553
+ };
6554
+ }
6555
+
6556
+ function withTaskRuntimeSnapshot(task) {
6557
+ if (!task || typeof task !== "object") return task;
6558
+ const runtimeSnapshot = buildTaskRuntimeSnapshot(task);
6559
+ return {
6560
+ ...task,
6561
+ runtimeSnapshot,
6562
+ meta: {
6563
+ ...(task.meta || {}),
6564
+ runtimeSnapshot,
6565
+ },
6566
+ };
6567
+ }
6568
+
6357
6569
  async function maybeStartTaskFromLifecycleAction({
6358
6570
  taskId,
6359
6571
  updatedTask,
@@ -6848,14 +7060,13 @@ async function resolveLogPath(logType, query) {
6848
7060
  return resolvePreferredSystemLogPath();
6849
7061
  }
6850
7062
  if (logType === "agent") {
7063
+ const matches = await listAgentLogFiles(query, 1);
7064
+ if (matches.length > 0) {
7065
+ return resolve(matches[0].source, matches[0].name);
7066
+ }
6851
7067
  const agentLogsDir = await resolveAgentLogsDir();
6852
7068
  const files = await readdir(agentLogsDir).catch(() => []);
6853
- let candidates = files.filter((f) => f.endsWith(".log")).sort().reverse();
6854
- if (query) {
6855
- const q = query.toLowerCase();
6856
- const filtered = candidates.filter((f) => f.toLowerCase().includes(q));
6857
- if (filtered.length) candidates = filtered;
6858
- }
7069
+ const candidates = files.filter((f) => f.endsWith(".log")).sort().reverse();
6859
7070
  return candidates.length ? resolve(agentLogsDir, candidates[0]) : null;
6860
7071
  }
6861
7072
  return null;
@@ -7884,6 +8095,13 @@ function buildTaskMetadataPatch(input = {}) {
7884
8095
  }
7885
8096
  }
7886
8097
 
8098
+ if (hasOwn(input, "type")) {
8099
+ const type = normalizeTaskTypeInput(input?.type);
8100
+ if (type) {
8101
+ topLevel.type = type;
8102
+ }
8103
+ }
8104
+
7887
8105
  if (hasOwn(input, "epicId")) {
7888
8106
  const epicId = normalizeOptionalStringInput(input?.epicId);
7889
8107
  if (epicId) {
@@ -7919,6 +8137,13 @@ function buildTaskMetadataPatch(input = {}) {
7919
8137
  return { topLevel, meta };
7920
8138
  }
7921
8139
 
8140
+ const TASK_TYPE_VALUES = new Set(["epic", "task", "subtask"]);
8141
+
8142
+ function normalizeTaskTypeInput(input) {
8143
+ const normalized = String(input ?? "").trim().toLowerCase();
8144
+ return TASK_TYPE_VALUES.has(normalized) ? normalized : null;
8145
+ }
8146
+
7922
8147
  const SPRINT_EXECUTION_MODES = new Set(["sequential", "parallel"]);
7923
8148
 
7924
8149
  function normalizeSprintExecutionMode(input) {
@@ -8259,11 +8484,40 @@ async function listAgentLogFiles(query = "", limit = 60) {
8259
8484
  const entries = [];
8260
8485
  const agentLogsDir = await resolveAgentLogsDir();
8261
8486
  const files = await readdir(agentLogsDir).catch(() => []);
8487
+ const normalizedQuery = String(query || "").trim().toLowerCase();
8488
+ const queryTerms = Array.from(new Set([
8489
+ normalizedQuery,
8490
+ ...normalizedQuery
8491
+ .split(/[^a-z0-9]+/i)
8492
+ .map((part) => part.trim())
8493
+ .filter((part) => part.length >= 3),
8494
+ ].filter(Boolean)));
8495
+
8496
+ const scoreAgentLogMatch = (name, lines = []) => {
8497
+ if (!queryTerms.length) return 0;
8498
+ const fileName = String(name || "").toLowerCase();
8499
+ const joined = lines.join("\n").toLowerCase();
8500
+ let score = 0;
8501
+ for (const term of queryTerms) {
8502
+ if (fileName.includes(term)) score += 120;
8503
+ if (joined.includes(term)) score += 80;
8504
+ }
8505
+ if (joined.includes("task id:")) score += 8;
8506
+ if (/(error|warn|failed|exception|timeout|anomal)/i.test(joined)) score += 6;
8507
+ return score;
8508
+ };
8509
+
8262
8510
  for (const name of files) {
8263
8511
  if (!name.endsWith(".log")) continue;
8264
- if (query && !name.toLowerCase().includes(query.toLowerCase())) continue;
8265
8512
  try {
8266
- const info = await stat(resolve(agentLogsDir, name));
8513
+ const filePath = resolve(agentLogsDir, name);
8514
+ const info = await stat(filePath);
8515
+ let score = 0;
8516
+ if (queryTerms.length) {
8517
+ const sample = await tailFile(filePath, 160, 250_000).catch(() => ({ lines: [] }));
8518
+ score = scoreAgentLogMatch(name, sample?.lines || []);
8519
+ if (score <= 0) continue;
8520
+ }
8267
8521
  entries.push({
8268
8522
  name,
8269
8523
  source: agentLogsDir,
@@ -8271,15 +8525,115 @@ async function listAgentLogFiles(query = "", limit = 60) {
8271
8525
  mtime:
8272
8526
  info.mtime?.toISOString?.() || new Date(info.mtime).toISOString(),
8273
8527
  mtimeMs: info.mtimeMs,
8528
+ score,
8274
8529
  });
8275
8530
  } catch {
8276
8531
  // ignore
8277
8532
  }
8278
8533
  }
8279
- entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
8534
+ entries.sort((a, b) => (b.score || 0) - (a.score || 0) || b.mtimeMs - a.mtimeMs);
8280
8535
  return entries.slice(0, limit);
8281
8536
  }
8282
8537
 
8538
+ function buildLogQueryTerms(query = "") {
8539
+ const normalized = String(query || "").trim().toLowerCase();
8540
+ if (!normalized) return [];
8541
+ return Array.from(new Set([
8542
+ normalized,
8543
+ ...normalized
8544
+ .split(/[^a-z0-9]+/i)
8545
+ .map((part) => part.trim())
8546
+ .filter((part) => part.length >= 3),
8547
+ ].filter(Boolean)));
8548
+ }
8549
+
8550
+ function isHighSignalLogLine(line = "") {
8551
+ return /(error|warn|failed|exception|timeout|anomal|retry|blocked|fatal)/i.test(String(line || ""));
8552
+ }
8553
+
8554
+ function filterRelevantLogLines(lines = [], query = "", limit = 200) {
8555
+ const sourceLines = Array.isArray(lines)
8556
+ ? lines.map((line) => String(line || "")).filter(Boolean)
8557
+ : [];
8558
+ if (!sourceLines.length) return [];
8559
+
8560
+ const terms = buildLogQueryTerms(query);
8561
+ if (!terms.length) return sourceLines.slice(-limit);
8562
+
8563
+ const picked = new Set();
8564
+ const addWithContext = (index, radius = 1) => {
8565
+ for (let cursor = Math.max(0, index - radius); cursor <= Math.min(sourceLines.length - 1, index + radius); cursor += 1) {
8566
+ picked.add(cursor);
8567
+ }
8568
+ };
8569
+
8570
+ sourceLines.forEach((line, index) => {
8571
+ const lower = line.toLowerCase();
8572
+ const termHit = terms.some((term) => lower.includes(term));
8573
+ if (termHit) {
8574
+ addWithContext(index, isHighSignalLogLine(line) ? 2 : 1);
8575
+ return;
8576
+ }
8577
+ if (isHighSignalLogLine(line)) {
8578
+ addWithContext(index, 1);
8579
+ }
8580
+ });
8581
+
8582
+ if (!picked.size) {
8583
+ return sourceLines.slice(-limit);
8584
+ }
8585
+
8586
+ const filtered = [...picked]
8587
+ .sort((a, b) => a - b)
8588
+ .map((index) => sourceLines[index]);
8589
+ return filtered.slice(-limit);
8590
+ }
8591
+
8592
+ async function resolveSessionWorktreePath(session) {
8593
+ if (!session || typeof session !== "object") return null;
8594
+ const directCandidates = [
8595
+ session?.metadata?.worktreePath,
8596
+ session?.metadata?.workspaceDir,
8597
+ session?.metadata?.workspacePath,
8598
+ session?.metadata?.cwd,
8599
+ ]
8600
+ .map((value) => String(value || "").trim())
8601
+ .filter(Boolean);
8602
+ for (const candidate of directCandidates) {
8603
+ if (existsSync(candidate)) return candidate;
8604
+ }
8605
+
8606
+ const branchHints = [
8607
+ session?.metadata?.branch,
8608
+ session?.metadata?.branchName,
8609
+ session?.branch,
8610
+ ]
8611
+ .map((value) => String(value || "").trim().replace(/^refs\/heads\//, ""))
8612
+ .filter(Boolean);
8613
+ const taskHints = [session?.taskId, session?.id]
8614
+ .map((value) => String(value || "").trim().toLowerCase())
8615
+ .filter(Boolean);
8616
+
8617
+ try {
8618
+ const active = await listActiveWorktrees(repoRoot);
8619
+ const matched = (active || []).find((worktree) => {
8620
+ const worktreePath = String(worktree?.path || "").trim();
8621
+ const worktreeTaskKey = String(worktree?.taskKey || "").trim().toLowerCase();
8622
+ const worktreeBranch = String(worktree?.branch || "")
8623
+ .trim()
8624
+ .replace(/^refs\/heads\//, "");
8625
+ if (worktreePath && directCandidates.includes(worktreePath)) return true;
8626
+ if (worktreeTaskKey && taskHints.includes(worktreeTaskKey)) return true;
8627
+ return branchHints.some((hint) =>
8628
+ hint && (worktreeBranch === hint || worktreeBranch.endsWith(`/${hint}`)),
8629
+ );
8630
+ });
8631
+ return matched?.path || null;
8632
+ } catch {
8633
+ return null;
8634
+ }
8635
+ }
8636
+
8283
8637
  async function ensurePresenceLoaded() {
8284
8638
  const loaded = await loadWorkspaceRegistry().catch(() => null);
8285
8639
  const registry = loaded?.registry || loaded || null;
@@ -8608,6 +8962,12 @@ async function handleApi(req, res, url) {
8608
8962
  task.repository || task.meta?.repository || "",
8609
8963
  ).trim().toLowerCase();
8610
8964
  if (workspaceFilter && taskWorkspace !== workspaceFilter) {
8965
+ // Backward compatibility: many legacy internal-store tasks predate
8966
+ // workspace stamping and should remain visible in the active
8967
+ // workspace board instead of being filtered out.
8968
+ if (!taskWorkspaceRaw) {
8969
+ return true;
8970
+ }
8611
8971
  const taskWorkspacePath = normalizeCandidatePath(taskWorkspaceRaw);
8612
8972
  const workspaceMatchByPath =
8613
8973
  Boolean(taskWorkspacePath) &&
@@ -8647,9 +9007,10 @@ async function handleApi(req, res, url) {
8647
9007
  const start = page * pageSize;
8648
9008
  const slice = filtered.slice(start, start + pageSize);
8649
9009
  const enriched = await applySharedStateToTasks(slice);
9010
+ const withRuntime = enriched.map((task) => withTaskRuntimeSnapshot(task));
8650
9011
  jsonResponse(res, 200, {
8651
9012
  ok: true,
8652
- data: enriched,
9013
+ data: withRuntime,
8653
9014
  page,
8654
9015
  pageSize,
8655
9016
  total,
@@ -8675,7 +9036,7 @@ async function handleApi(req, res, url) {
8675
9036
  const adapter = getKanbanAdapter();
8676
9037
  const task = await adapter.getTask(taskId);
8677
9038
  const enriched = await applySharedStateToTasks(task ? [task] : []);
8678
- const detailTask = enriched[0] || null;
9039
+ let detailTask = enriched[0] || null;
8679
9040
  if (detailTask) {
8680
9041
  const workflowRuns = await collectWorkflowRunsForTask(detailTask.id, url, 40);
8681
9042
  const mergedWorkflowRuns = mergeTaskWorkflowRuns(detailTask.workflowRuns, workflowRuns, 80);
@@ -8696,6 +9057,7 @@ async function handleApi(req, res, url) {
8696
9057
  };
8697
9058
  if (sprintDag) detailTask.sprintDag = sprintDag.data;
8698
9059
  if (globalDag) detailTask.dagOfDags = globalDag.data;
9060
+ detailTask = withTaskRuntimeSnapshot(detailTask);
8699
9061
  }
8700
9062
  jsonResponse(res, 200, { ok: true, data: detailTask });
8701
9063
  } catch (err) {
@@ -9053,6 +9415,55 @@ async function handleApi(req, res, url) {
9053
9415
  }
9054
9416
  return;
9055
9417
  }
9418
+ if (path === "/api/tasks/epic-dependencies" && req.method === "GET") {
9419
+ try {
9420
+ const listed = await listEpicDependenciesForApi();
9421
+ if (!listed.ok) {
9422
+ jsonResponse(res, 501, { ok: false, error: "Epic dependency APIs are unavailable." });
9423
+ return;
9424
+ }
9425
+ jsonResponse(res, 200, { ok: true, source: listed.source, data: listed.data });
9426
+ } catch (err) {
9427
+ jsonResponse(res, 500, { ok: false, error: err.message });
9428
+ }
9429
+ return;
9430
+ }
9431
+
9432
+ if (path === "/api/tasks/epic-dependencies" && req.method === "PUT") {
9433
+ try {
9434
+ const body = await readJsonBody(req);
9435
+ const epicId = String(body?.epicId || body?.id || "").trim();
9436
+ const dependencies = Array.isArray(body?.dependencies)
9437
+ ? body.dependencies
9438
+ : Array.isArray(body?.dependsOn)
9439
+ ? body.dependsOn
9440
+ : [];
9441
+ if (!epicId) {
9442
+ jsonResponse(res, 400, { ok: false, error: "epicId required" });
9443
+ return;
9444
+ }
9445
+ const updated = await setEpicDependenciesForApi({ epicId, dependencies });
9446
+ if (!updated.ok) {
9447
+ jsonResponse(res, updated.status || 400, { ok: false, error: updated.error || "Failed to update epic dependencies" });
9448
+ return;
9449
+ }
9450
+ const globalDag = await getGlobalDagData();
9451
+ jsonResponse(res, 200, {
9452
+ ok: true,
9453
+ source: updated.source,
9454
+ data: updated.data,
9455
+ dag: globalDag?.data || null,
9456
+ });
9457
+ broadcastUiEvent(["tasks", "overview"], "invalidate", {
9458
+ reason: "epic-dependencies-updated",
9459
+ epicId,
9460
+ });
9461
+ } catch (err) {
9462
+ jsonResponse(res, 500, { ok: false, error: err.message });
9463
+ }
9464
+ return;
9465
+ }
9466
+
9056
9467
  if (path === "/api/tasks/start") {
9057
9468
  try {
9058
9469
  const body = await readJsonBody(req);
@@ -9102,25 +9513,12 @@ async function handleApi(req, res, url) {
9102
9513
  const freeSlots =
9103
9514
  (status.maxParallel || 0) - (status.activeSlots || 0);
9104
9515
 
9105
- try {
9106
- if (typeof adapter.updateTaskStatus === "function") {
9107
- await adapter.updateTaskStatus(taskId, "inprogress", { source: "api.tasks.start" });
9108
- } else if (typeof adapter.updateTask === "function") {
9109
- await adapter.updateTask(taskId, { status: "inprogress" });
9110
- }
9111
- applyInternalLifecycleTransition(taskId, "start", {
9112
- source: "api.tasks.start",
9113
- actor: "ui",
9114
- force: forceStart || manualOverride,
9115
- reason: "manual start",
9116
- });
9117
- } catch (err) {
9118
- console.warn(
9119
- `[telegram-ui] failed to mark task ${taskId} inprogress: ${err.message}`,
9120
- );
9121
- }
9122
-
9123
9516
  if (freeSlots <= 0) {
9517
+ const queuedTask = await persistTaskExecutionMeta(adapter, taskId, {
9518
+ queued: true,
9519
+ queueState: "queued",
9520
+ requestedAt: new Date().toISOString(),
9521
+ });
9124
9522
  jsonResponse(res, 202, {
9125
9523
  ok: true,
9126
9524
  taskId,
@@ -9128,6 +9526,7 @@ async function handleApi(req, res, url) {
9128
9526
  started: false,
9129
9527
  reason: "No free slots",
9130
9528
  canStart,
9529
+ data: withTaskRuntimeSnapshot(queuedTask || task),
9131
9530
  });
9132
9531
  broadcastUiEvent(
9133
9532
  ["tasks", "overview", "executor", "agents"],
@@ -9139,8 +9538,27 @@ async function handleApi(req, res, url) {
9139
9538
  );
9140
9539
  return;
9141
9540
  }
9541
+ let startedTask = task;
9542
+ try {
9543
+ startedTask = await persistTaskStatusForExecution(adapter, taskId, "inprogress", "api.tasks.start") || task;
9544
+ startedTask = await persistTaskExecutionMeta(adapter, taskId, {
9545
+ queued: false,
9546
+ queueState: null,
9547
+ }) || startedTask;
9548
+ applyInternalLifecycleTransition(taskId, "start", {
9549
+ source: "api.tasks.start",
9550
+ actor: "ui",
9551
+ force: forceStart || manualOverride,
9552
+ reason: "manual start",
9553
+ });
9554
+ } catch (err) {
9555
+ console.warn(
9556
+ `[telegram-ui] failed to mark task ${taskId} inprogress: ${err.message}`,
9557
+ );
9558
+ }
9559
+
9142
9560
  const wasPaused = executor.isPaused?.();
9143
- executor.executeTask(task, {
9561
+ executor.executeTask(startedTask, {
9144
9562
  ...(sdk ? { sdk } : {}),
9145
9563
  ...(model ? { model } : {}),
9146
9564
  force: forceStart || manualOverride,
@@ -9148,6 +9566,16 @@ async function handleApi(req, res, url) {
9148
9566
  console.warn(
9149
9567
  `[telegram-ui] failed to execute task ${taskId}: ${error.message}`,
9150
9568
  );
9569
+ void persistTaskStatusForExecution(
9570
+ adapter,
9571
+ taskId,
9572
+ resolveFallbackStatusAfterFailedDispatch(task?.status, { started: false }),
9573
+ "api.tasks.start.failed",
9574
+ );
9575
+ broadcastUiEvent(["tasks", "overview", "executor", "agents"], "invalidate", {
9576
+ reason: "task-start-failed",
9577
+ taskId,
9578
+ });
9151
9579
  });
9152
9580
  jsonResponse(res, 200, {
9153
9581
  ok: true,
@@ -9156,6 +9584,7 @@ async function handleApi(req, res, url) {
9156
9584
  started: true,
9157
9585
  wasPaused,
9158
9586
  canStart,
9587
+ data: withTaskRuntimeSnapshot(startedTask),
9159
9588
  });
9160
9589
  broadcastUiEvent(
9161
9590
  ["tasks", "overview", "executor", "agents"],
@@ -9253,6 +9682,17 @@ async function handleApi(req, res, url) {
9253
9682
  forceStart,
9254
9683
  manualOverride,
9255
9684
  });
9685
+ const reconciled = await reconcileTaskAfterDispatchAttempt({
9686
+ adapter,
9687
+ taskId,
9688
+ previousStatus: previousTask?.status || null,
9689
+ requestedStatus: nextStatus,
9690
+ lifecycleAction,
9691
+ startDispatch,
9692
+ source: "api.tasks.update",
9693
+ });
9694
+ const responseTask = withTaskRuntimeSnapshot(reconciled || updated);
9695
+ const responseStatus = responseTask?.status || nextStatus;
9256
9696
  if (body?.pauseExecution === true && lifecycleAction === "pause" && executor && typeof executor.abortTask === "function") {
9257
9697
  executor.abortTask(taskId, "task_lifecycle_pause");
9258
9698
  }
@@ -9269,19 +9709,19 @@ async function handleApi(req, res, url) {
9269
9709
  });
9270
9710
  jsonResponse(res, 200, {
9271
9711
  ok: true,
9272
- data: updated,
9712
+ data: responseTask,
9273
9713
  restart,
9274
9714
  lifecycle: {
9275
9715
  action: lifecycleAction,
9276
9716
  previousStatus: previousTask?.status || null,
9277
- nextStatus,
9717
+ nextStatus: responseStatus,
9278
9718
  startDispatch,
9279
9719
  },
9280
9720
  });
9281
9721
  broadcastUiEvent(["tasks", "overview"], "invalidate", {
9282
9722
  reason: "task-updated",
9283
9723
  taskId,
9284
- status: nextStatus,
9724
+ status: responseStatus,
9285
9725
  });
9286
9726
  if (restart?.started || startDispatch?.started) {
9287
9727
  broadcastUiEvent(["tasks", "overview", "executor", "agents"], "invalidate", {
@@ -9377,6 +9817,17 @@ async function handleApi(req, res, url) {
9377
9817
  forceStart,
9378
9818
  manualOverride,
9379
9819
  });
9820
+ const reconciled = await reconcileTaskAfterDispatchAttempt({
9821
+ adapter,
9822
+ taskId,
9823
+ previousStatus: previousTask?.status || null,
9824
+ requestedStatus: nextStatus,
9825
+ lifecycleAction,
9826
+ startDispatch,
9827
+ source: "api.tasks.edit",
9828
+ });
9829
+ const responseTask = withTaskRuntimeSnapshot(reconciled || updated);
9830
+ const responseStatus = responseTask?.status || nextStatus;
9380
9831
  if (body?.pauseExecution === true && lifecycleAction === "pause" && executor && typeof executor.abortTask === "function") {
9381
9832
  executor.abortTask(taskId, "task_lifecycle_pause");
9382
9833
  }
@@ -9393,19 +9844,19 @@ async function handleApi(req, res, url) {
9393
9844
  });
9394
9845
  jsonResponse(res, 200, {
9395
9846
  ok: true,
9396
- data: updated,
9847
+ data: responseTask,
9397
9848
  restart,
9398
9849
  lifecycle: {
9399
9850
  action: lifecycleAction,
9400
9851
  previousStatus: previousTask?.status || null,
9401
- nextStatus,
9852
+ nextStatus: responseStatus,
9402
9853
  startDispatch,
9403
9854
  },
9404
9855
  });
9405
9856
  broadcastUiEvent(["tasks", "overview"], "invalidate", {
9406
9857
  reason: "task-edited",
9407
9858
  taskId,
9408
- status: nextStatus,
9859
+ status: responseStatus,
9409
9860
  });
9410
9861
  if (restart?.started || startDispatch?.started) {
9411
9862
  broadcastUiEvent(["tasks", "overview", "executor", "agents"], "invalidate", {
@@ -11034,8 +11485,20 @@ async function handleApi(req, res, url) {
11034
11485
  jsonResponse(res, 200, { ok: true, data: null });
11035
11486
  return;
11036
11487
  }
11037
- const tail = await tailFile(filePath, lines);
11038
- jsonResponse(res, 200, { ok: true, data: { file: fileName, content: tail } });
11488
+ const tail = await tailFile(filePath, Math.max(lines * 4, 240));
11489
+ const filteredLines = filterRelevantLogLines(tail?.lines || [], query || fileName, lines);
11490
+ const contentLines = filteredLines.length ? filteredLines : (tail?.lines || []).slice(-lines);
11491
+ jsonResponse(res, 200, {
11492
+ ok: true,
11493
+ data: {
11494
+ file: fileName,
11495
+ content: contentLines.join("\n"),
11496
+ lines: contentLines,
11497
+ mode: filteredLines.length ? "focused" : "tail",
11498
+ totalLines: Array.isArray(tail?.lines) ? tail.lines.length : 0,
11499
+ truncated: tail?.truncated === true,
11500
+ },
11501
+ });
11039
11502
  } catch (err) {
11040
11503
  jsonResponse(res, 200, { ok: true, data: null });
11041
11504
  }
@@ -13421,7 +13884,7 @@ async function handleApi(req, res, url) {
13421
13884
  });
13422
13885
  return;
13423
13886
  }
13424
- const worktreePath = session.metadata?.worktreePath;
13887
+ const worktreePath = await resolveSessionWorktreePath(session);
13425
13888
  if (!worktreePath || !existsSync(worktreePath)) {
13426
13889
  jsonResponse(res, 200, { ok: true, diff: { files: [], totalFiles: 0, totalAdditions: 0, totalDeletions: 0, formatted: "(no worktree)" }, summary: "(no worktree)", commits: [] });
13427
13890
  return;