bosun 0.40.3 → 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.
@@ -25,6 +25,8 @@ import { randomUUID } from "node:crypto";
25
25
  import { getAgentToolConfig, getEffectiveTools } from "../agent/agent-tool-config.mjs";
26
26
  import { getToolsPromptBlock } from "../agent/agent-custom-tools.mjs";
27
27
  import { buildRelevantSkillsPromptBlock, findRelevantSkills } from "../agent/bosun-skills.mjs";
28
+ import { getSessionTracker } from "../infra/session-tracker.mjs";
29
+ import { fixGitConfigCorruption } from "../workspace/worktree-manager.mjs";
28
30
 
29
31
  const TAG = "[workflow-nodes]";
30
32
  const PORTABLE_WORKTREE_COUNT_COMMAND = "node -e \"const cp=require('node:child_process');const wt=cp.execSync('git worktree list --porcelain',{encoding:'utf8'});const count=(wt.match(/^worktree /gm)||[]).length;process.stdout.write(String(count)+'\\\\n');\"";
@@ -40,6 +42,29 @@ const WORKFLOW_AGENT_EVENT_PREVIEW_LIMIT = (() => {
40
42
  return Math.max(20, Math.min(500, Math.trunc(raw)));
41
43
  })();
42
44
 
45
+ function makeIsolatedGitEnv(extra = {}) {
46
+ const env = { ...process.env, ...extra };
47
+ for (const key of [
48
+ "GIT_DIR",
49
+ "GIT_WORK_TREE",
50
+ "GIT_COMMON_DIR",
51
+ "GIT_INDEX_FILE",
52
+ "GIT_OBJECT_DIRECTORY",
53
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
54
+ "GIT_PREFIX",
55
+ ]) {
56
+ delete env[key];
57
+ }
58
+ return env;
59
+ }
60
+
61
+ function execGitSync(command, options = {}) {
62
+ return execSync(command, {
63
+ ...options,
64
+ env: makeIsolatedGitEnv(options.env),
65
+ });
66
+ }
67
+
43
68
  function trimLogText(value, max = 180) {
44
69
  const text = String(value || "").replace(/\s+/g, " ").trim();
45
70
  if (!text) return "";
@@ -1510,6 +1535,20 @@ registerNodeType("action.run_agent", {
1510
1535
  const prompt = ctx.resolve(node.config?.prompt || "");
1511
1536
  const sdk = node.config?.sdk || "auto";
1512
1537
  const cwd = ctx.resolve(node.config?.cwd || ctx.data?.worktreePath || process.cwd());
1538
+ const trackedTaskId = String(
1539
+ ctx.data?.taskId ||
1540
+ ctx.data?.task?.id ||
1541
+ ctx.data?.taskDetail?.id ||
1542
+ ctx.resolve(node.config?.taskId || "") ||
1543
+ "",
1544
+ ).trim();
1545
+ const trackedTaskTitle = String(
1546
+ ctx.data?.task?.title ||
1547
+ ctx.data?.taskDetail?.title ||
1548
+ ctx.data?.taskInfo?.title ||
1549
+ trackedTaskId ||
1550
+ "",
1551
+ ).trim();
1513
1552
  const agentProfileId = String(
1514
1553
  ctx.resolve(node.config?.agentProfile || ctx.data?.agentProfile || ""),
1515
1554
  ).trim();
@@ -1637,6 +1676,43 @@ registerNodeType("action.run_agent", {
1637
1676
  const maxRetainedEvents = Number.isFinite(Number(node.config?.maxRetainedEvents))
1638
1677
  ? Math.max(10, Math.min(500, Math.trunc(Number(node.config.maxRetainedEvents))))
1639
1678
  : WORKFLOW_AGENT_EVENT_PREVIEW_LIMIT;
1679
+ const tracker = trackedTaskId ? getSessionTracker() : null;
1680
+ const trackedSessionType = trackedTaskId ? "task" : "flow";
1681
+
1682
+ if (tracker && trackedTaskId) {
1683
+ const existing = tracker.getSessionById(trackedTaskId);
1684
+ if (!existing) {
1685
+ tracker.createSession({
1686
+ id: trackedTaskId,
1687
+ type: "task",
1688
+ taskId: trackedTaskId,
1689
+ metadata: {
1690
+ title: trackedTaskTitle || trackedTaskId,
1691
+ workspaceId: String(ctx.data?.workspaceId || ctx.data?.activeWorkspace || "").trim() || undefined,
1692
+ workspaceDir: String(cwd || "").trim() || undefined,
1693
+ branch:
1694
+ String(
1695
+ ctx.data?.branch ||
1696
+ ctx.data?.task?.branchName ||
1697
+ ctx.data?.taskDetail?.branchName ||
1698
+ "",
1699
+ ).trim() || undefined,
1700
+ },
1701
+ });
1702
+ } else {
1703
+ tracker.updateSessionStatus(trackedTaskId, "active");
1704
+ if (trackedTaskTitle) {
1705
+ tracker.renameSession(trackedTaskId, trackedTaskTitle);
1706
+ }
1707
+ }
1708
+ tracker.recordEvent(trackedTaskId, {
1709
+ role: "system",
1710
+ type: "system",
1711
+ content: `Workflow agent run started in ${cwd}`,
1712
+ timestamp: new Date().toISOString(),
1713
+ _sessionType: trackedSessionType,
1714
+ });
1715
+ }
1640
1716
 
1641
1717
  const launchExtra = {};
1642
1718
  if (sessionId) launchExtra.resumeThreadId = sessionId;
@@ -1644,6 +1720,12 @@ registerNodeType("action.run_agent", {
1644
1720
  if (modelOverride) launchExtra.model = modelOverride;
1645
1721
  launchExtra.onEvent = (event) => {
1646
1722
  try {
1723
+ if (tracker && trackedTaskId) {
1724
+ tracker.recordEvent(trackedTaskId, {
1725
+ ...(event && typeof event === "object" ? event : { content: String(event || "") }),
1726
+ _sessionType: trackedSessionType,
1727
+ });
1728
+ }
1647
1729
  const line = summarizeAgentStreamEvent(event);
1648
1730
  if (!line || line === lastStreamLog) return;
1649
1731
  lastStreamLog = line;
@@ -1711,9 +1793,10 @@ registerNodeType("action.run_agent", {
1711
1793
  timeoutMs,
1712
1794
  maxRetries: sessionRetries,
1713
1795
  maxContinues,
1714
- sessionType: "flow",
1796
+ sessionType: trackedSessionType,
1715
1797
  sdk: sdkOverride,
1716
1798
  model: modelOverride,
1799
+ onEvent: launchExtra.onEvent,
1717
1800
  });
1718
1801
  }
1719
1802
 
@@ -1721,9 +1804,10 @@ registerNodeType("action.run_agent", {
1721
1804
  ctx.log(node.id, `${passLabel} Recovery: launchOrResumeThread taskKey=${recoveryTaskKey}`.trim());
1722
1805
  result = await agentPool.launchOrResumeThread(passPrompt, cwd, timeoutMs, {
1723
1806
  taskKey: recoveryTaskKey,
1724
- sessionType: "flow",
1807
+ sessionType: trackedSessionType,
1725
1808
  sdk: sdkOverride,
1726
1809
  model: modelOverride,
1810
+ onEvent: launchExtra.onEvent,
1727
1811
  });
1728
1812
  }
1729
1813
 
@@ -1736,6 +1820,24 @@ registerNodeType("action.run_agent", {
1736
1820
  }
1737
1821
  ctx.log(node.id, `${passLabel || "Agent"} completed: success=${success} streamEvents=${streamEventCount}`);
1738
1822
 
1823
+ if (tracker && trackedTaskId) {
1824
+ if (streamEventCount === 0) {
1825
+ const fallbackContent = success
1826
+ ? String(result?.output || result?.message || "Agent run completed.").trim()
1827
+ : String(result?.error || "Agent run failed.").trim();
1828
+ if (fallbackContent) {
1829
+ tracker.recordEvent(trackedTaskId, {
1830
+ role: success ? "assistant" : "system",
1831
+ type: success ? "agent_message" : "error",
1832
+ content: fallbackContent,
1833
+ timestamp: new Date().toISOString(),
1834
+ _sessionType: trackedSessionType,
1835
+ });
1836
+ }
1837
+ }
1838
+ tracker.endSession(trackedTaskId, success ? "completed" : "failed");
1839
+ }
1840
+
1739
1841
  const threadId = result?.threadId || result?.sessionId || sessionId || null;
1740
1842
  if (persistSession && threadId) {
1741
1843
  ctx.data.sessionId = threadId;
@@ -2713,7 +2815,7 @@ registerNodeType("action.update_task_status", {
2713
2815
  required: ["taskId", "status"],
2714
2816
  },
2715
2817
  async execute(node, ctx, engine) {
2716
- const taskId = ctx.resolve(node.config?.taskId || "");
2818
+ let taskId = ctx.resolve(node.config?.taskId || "");
2717
2819
  const status = node.config?.status;
2718
2820
  const kanban = engine.services?.kanban;
2719
2821
  const workflowEvent = ctx.resolve(node.config?.workflowEvent || "");
@@ -2732,6 +2834,29 @@ registerNodeType("action.update_task_status", {
2732
2834
  if (workflowData) updateOptions.workflowData = workflowData;
2733
2835
  if (workflowDedupKey) updateOptions.workflowDedupKey = workflowDedupKey;
2734
2836
 
2837
+ if (isUnresolvedTemplateToken(taskId)) {
2838
+ const fallbackTaskId =
2839
+ ctx.data?.taskId ||
2840
+ ctx.data?.task?.id ||
2841
+ ctx.data?.task_id ||
2842
+ "";
2843
+ if (fallbackTaskId && !isUnresolvedTemplateToken(fallbackTaskId)) {
2844
+ taskId = String(fallbackTaskId);
2845
+ }
2846
+ }
2847
+
2848
+ if (!taskId || isUnresolvedTemplateToken(taskId)) {
2849
+ const unresolvedValue = String(taskId || node.config?.taskId || "(empty)");
2850
+ ctx.log(node.id, `Skipping update_task_status due unresolved taskId: ${unresolvedValue}`);
2851
+ return {
2852
+ success: false,
2853
+ skipped: true,
2854
+ error: "unresolved_task_id",
2855
+ taskId: unresolvedValue,
2856
+ status,
2857
+ };
2858
+ }
2859
+
2735
2860
  if (kanban?.updateTaskStatus) {
2736
2861
  await kanban.updateTaskStatus(taskId, status, updateOptions);
2737
2862
  bindTaskContext(ctx, {
@@ -7004,6 +7129,48 @@ function cfgOrCtx(node, ctx, key, defaultVal = "") {
7004
7129
  return defaultVal;
7005
7130
  }
7006
7131
 
7132
+ function getWorkflowRuntimeState(ctx) {
7133
+ if (!ctx || typeof ctx !== "object") return {};
7134
+ if (!ctx.__workflowRuntimeState || typeof ctx.__workflowRuntimeState !== "object") {
7135
+ ctx.__workflowRuntimeState = {};
7136
+ }
7137
+ return ctx.__workflowRuntimeState;
7138
+ }
7139
+
7140
+ function isUnresolvedTemplateToken(value) {
7141
+ return /{{[^{}]+}}/.test(String(value || ""));
7142
+ }
7143
+
7144
+ function normalizeGitRefValue(value) {
7145
+ const text = String(value ?? "").trim();
7146
+ if (!text || isUnresolvedTemplateToken(text)) return "";
7147
+ const lowered = text.toLowerCase();
7148
+ if (lowered === "null" || lowered === "undefined") return "";
7149
+ return text;
7150
+ }
7151
+
7152
+ function pickGitRef(...candidates) {
7153
+ for (const candidate of candidates) {
7154
+ const normalized = normalizeGitRefValue(candidate);
7155
+ if (normalized) return normalized;
7156
+ }
7157
+ return "";
7158
+ }
7159
+
7160
+ function formatExecSyncError(err) {
7161
+ if (!err) return "unknown error";
7162
+ const detail = [err?.stderr, err?.stdout, err?.message]
7163
+ .map((entry) => String(entry || "").trim())
7164
+ .filter(Boolean)
7165
+ .join(" | ");
7166
+ return trimLogText(detail || String(err?.message || err), 420);
7167
+ }
7168
+
7169
+ function isExistingBranchWorktreeError(err) {
7170
+ const detail = formatExecSyncError(err).toLowerCase();
7171
+ return detail.includes("already exists") || detail.includes("is already checked out");
7172
+ }
7173
+
7007
7174
  /**
7008
7175
  * Anti-thrash state — module-scope to survive across workflow runs.
7009
7176
  * Mirrors TaskExecutor._noCommitCounts / _skipUntil / _completedWithPR.
@@ -7544,11 +7711,18 @@ registerNodeType("action.claim_task", {
7544
7711
  ctx.data._claimToken = token;
7545
7712
  ctx.data._claimInstanceId = instanceId;
7546
7713
 
7547
- // Start renewal timer (stored in ctx for cleanup by release_claim)
7548
- if (renewIntervalMs > 0 && claims.renewTaskClaim) {
7714
+ const runtimeState = getWorkflowRuntimeState(ctx);
7715
+ // Start renewal timer (stored in non-serializable runtime state for cleanup by release_claim)
7716
+ const renewClaimFn =
7717
+ typeof claims.renewTaskClaim === "function"
7718
+ ? claims.renewTaskClaim.bind(claims)
7719
+ : typeof claims.renewClaim === "function"
7720
+ ? claims.renewClaim.bind(claims)
7721
+ : null;
7722
+ if (renewIntervalMs > 0 && renewClaimFn) {
7549
7723
  const renewTimer = setInterval(async () => {
7550
7724
  try {
7551
- await claims.renewTaskClaim({ taskId, claimToken: token, instanceId, ttlMinutes });
7725
+ await renewClaimFn({ taskId, claimToken: token, instanceId, ttlMinutes });
7552
7726
  } catch (renewErr) {
7553
7727
  const msg = renewErr?.message || String(renewErr);
7554
7728
  const fatal = ["claimed_by_different_instance", "claim_token_mismatch",
@@ -7556,6 +7730,7 @@ registerNodeType("action.claim_task", {
7556
7730
  if (fatal) {
7557
7731
  ctx.log(node.id, `Claim renewal fatal: ${msg} — aborting task`);
7558
7732
  clearInterval(renewTimer);
7733
+ runtimeState.claimRenewTimer = null;
7559
7734
  ctx.data._claimRenewTimer = null;
7560
7735
  // Signal abort to downstream nodes via context
7561
7736
  ctx.data._claimStolen = true;
@@ -7566,7 +7741,9 @@ registerNodeType("action.claim_task", {
7566
7741
  }, renewIntervalMs);
7567
7742
  // Prevent timer from keeping the process alive
7568
7743
  if (renewTimer.unref) renewTimer.unref();
7569
- ctx.data._claimRenewTimer = renewTimer;
7744
+ runtimeState.claimRenewTimer = renewTimer;
7745
+ // Keep serialized context JSON-safe.
7746
+ ctx.data._claimRenewTimer = null;
7570
7747
  }
7571
7748
 
7572
7749
  ctx.log(node.id, `Task "${taskTitle}" claimed (ttl=${ttlMinutes}min, renew=${renewIntervalMs}ms)`);
@@ -7602,11 +7779,14 @@ registerNodeType("action.release_claim", {
7602
7779
  const claimToken = cfgOrCtx(node, ctx, "claimToken") || ctx.data?._claimToken || "";
7603
7780
  const instanceId = cfgOrCtx(node, ctx, "instanceId") || ctx.data?._claimInstanceId || "";
7604
7781
 
7605
- // Always cancel the renewal timer first
7606
- if (ctx.data?._claimRenewTimer) {
7607
- try { clearInterval(ctx.data._claimRenewTimer); } catch { /* ok */ }
7608
- ctx.data._claimRenewTimer = null;
7782
+ // Always cancel the renewal timer first.
7783
+ const runtimeState = getWorkflowRuntimeState(ctx);
7784
+ const renewTimer = runtimeState.claimRenewTimer || ctx.data?._claimRenewTimer;
7785
+ if (renewTimer) {
7786
+ try { clearInterval(renewTimer); } catch { /* ok */ }
7609
7787
  }
7788
+ runtimeState.claimRenewTimer = null;
7789
+ ctx.data._claimRenewTimer = null;
7610
7790
 
7611
7791
  if (!taskId || !claimToken) {
7612
7792
  ctx.log(node.id, `No claim to release for ${taskId || "(unknown)"}`);
@@ -7622,8 +7802,15 @@ registerNodeType("action.release_claim", {
7622
7802
  ctx.data._claimInstanceId = null;
7623
7803
  return { success: true, taskId, warning: initErr.message };
7624
7804
  }
7805
+ const releaseClaimFn =
7806
+ typeof claims.releaseTaskClaim === "function"
7807
+ ? claims.releaseTaskClaim.bind(claims)
7808
+ : typeof claims.releaseTask === "function"
7809
+ ? claims.releaseTask.bind(claims)
7810
+ : null;
7625
7811
  try {
7626
- await claims.releaseTaskClaim({ taskId, claimToken, instanceId });
7812
+ if (!releaseClaimFn) throw new Error("no claim release function available");
7813
+ await releaseClaimFn({ taskId, claimToken, instanceId });
7627
7814
  ctx.data._claimToken = null;
7628
7815
  ctx.data._claimInstanceId = null;
7629
7816
  ctx.log(node.id, `Claim released for ${taskId}`);
@@ -7742,7 +7929,9 @@ registerNodeType("action.acquire_worktree", {
7742
7929
  const taskId = cfgOrCtx(node, ctx, "taskId");
7743
7930
  const branch = cfgOrCtx(node, ctx, "branch");
7744
7931
  const repoRoot = cfgOrCtx(node, ctx, "repoRoot") || process.cwd();
7745
- const baseBranch = cfgOrCtx(node, ctx, "baseBranch", "origin/main");
7932
+ const baseBranchRaw = cfgOrCtx(node, ctx, "baseBranch", "origin/main");
7933
+ const defaultTargetBranch = cfgOrCtx(node, ctx, "defaultTargetBranch", "origin/main");
7934
+ const baseBranch = pickGitRef(baseBranchRaw, defaultTargetBranch, "origin/main", "main");
7746
7935
  const fetchTimeout = node.config?.fetchTimeout ?? 30000;
7747
7936
  const worktreeTimeout = node.config?.worktreeTimeout ?? 60000;
7748
7937
 
@@ -7799,17 +7988,24 @@ registerNodeType("action.acquire_worktree", {
7799
7988
  `git worktree add "${worktreePath}" -b "${branch}" "${baseBranch}" 2>&1`,
7800
7989
  { cwd: repoRoot, encoding: "utf8", timeout: worktreeTimeout },
7801
7990
  );
7802
- } catch {
7803
- // Branch may already exist — try checkout
7991
+ } catch (createErr) {
7992
+ if (!isExistingBranchWorktreeError(createErr)) {
7993
+ throw new Error(`Worktree creation failed: ${formatExecSyncError(createErr)}`);
7994
+ }
7995
+ // Branch already exists — attach worktree to existing branch.
7804
7996
  try {
7805
7997
  execSync(
7806
7998
  `git worktree add "${worktreePath}" "${branch}" 2>&1`,
7807
7999
  { cwd: repoRoot, encoding: "utf8", timeout: worktreeTimeout },
7808
8000
  );
7809
- } catch (err2) {
7810
- throw new Error(`Worktree creation failed: ${err2.message}`);
8001
+ } catch (reuseErr) {
8002
+ throw new Error(
8003
+ `Worktree creation failed: ${formatExecSyncError(createErr)}; ` +
8004
+ `reuse failed: ${formatExecSyncError(reuseErr)}`,
8005
+ );
7811
8006
  }
7812
8007
  }
8008
+ fixGitConfigCorruption(repoRoot);
7813
8009
 
7814
8010
  ctx.data.worktreePath = worktreePath;
7815
8011
  ctx.data._worktreeCreated = true;
@@ -8204,7 +8400,7 @@ registerNodeType("action.detect_new_commits", {
8204
8400
  // Get current HEAD
8205
8401
  let postExecHead = "";
8206
8402
  try {
8207
- postExecHead = execSync("git rev-parse HEAD", {
8403
+ postExecHead = execGitSync("git rev-parse HEAD", {
8208
8404
  cwd: worktreePath, encoding: "utf8", timeout: 5000,
8209
8405
  }).trim();
8210
8406
  } catch (err) {
@@ -8218,7 +8414,7 @@ registerNodeType("action.detect_new_commits", {
8218
8414
  let hasUnpushed = false;
8219
8415
  let commitCount = 0;
8220
8416
  try {
8221
- const log = execSync(`git log --oneline ${baseBranch}..HEAD`, {
8417
+ const log = execGitSync(`git log --oneline ${baseBranch}..HEAD`, {
8222
8418
  cwd: worktreePath, encoding: "utf8", timeout: 10000,
8223
8419
  stdio: ["ignore", "pipe", "pipe"],
8224
8420
  }).trim();
@@ -8232,7 +8428,7 @@ registerNodeType("action.detect_new_commits", {
8232
8428
  let diffStats = null;
8233
8429
  if (hasNewCommits || hasUnpushed) {
8234
8430
  try {
8235
- const statOutput = execSync(`git diff --stat ${baseBranch}..HEAD`, {
8431
+ const statOutput = execGitSync(`git diff --stat ${baseBranch}..HEAD`, {
8236
8432
  cwd: worktreePath, encoding: "utf8", timeout: 10000,
8237
8433
  stdio: ["ignore", "pipe", "pipe"],
8238
8434
  }).trim();
@@ -8602,3 +8798,6 @@ registerNodeType("action.web_search", {
8602
8798
  // ═══════════════════════════════════════════════════════════════════════════
8603
8799
 
8604
8800
  export { registerNodeType, getNodeType, listNodeTypes } from "./workflow-engine.mjs";
8801
+
8802
+
8803
+
@@ -575,7 +575,7 @@ export const WORKFLOW_SETUP_PROFILES = Object.freeze({
575
575
  "template-task-batch-processor",
576
576
  ]),
577
577
  }),
578
- workflowFirst: Object.freeze({
578
+ workflowfirst: Object.freeze({
579
579
  id: "workflowFirst",
580
580
  name: "Workflow-First (Full)",
581
581
  description:
@@ -10,7 +10,7 @@
10
10
  * - TASK_BATCH_PR_TEMPLATE (batch → agent → PR shortcut)
11
11
  *
12
12
  * DAG overview:
13
- * trigger.task_low
13
+ * trigger.task_available
14
14
  * → condition.expression (is coordinator or solo?)
15
15
  * → action.run_command (list todo tasks)
16
16
  * → loop.for_each (fan-out, maxConcurrent tasks at a time)
@@ -38,9 +38,8 @@ export const TASK_BATCH_PROCESSOR_TEMPLATE = {
38
38
  category: "lifecycle",
39
39
  enabled: true,
40
40
  recommended: true,
41
- trigger: "trigger.task_low",
41
+ trigger: "trigger.task_available",
42
42
  variables: {
43
- backlogThreshold: 3,
44
43
  maxConcurrent: 3,
45
44
  pollStatus: "todo",
46
45
  maxBatchSize: 10,
@@ -48,9 +47,10 @@ export const TASK_BATCH_PROCESSOR_TEMPLATE = {
48
47
  notifyChannel: "telegram",
49
48
  },
50
49
  nodes: [
51
- // ── Trigger: Backlog drops below threshold ───────────────────────────
52
- node("trigger", "trigger.task_low", "Backlog Low?", {
53
- threshold: "{{backlogThreshold}}",
50
+ // ── Trigger: Tasks available for processing ──────────────────────────
51
+ node("trigger", "trigger.task_available", "Tasks Available?", {
52
+ maxParallel: "{{maxConcurrent}}",
53
+ pollIntervalMs: 60000,
54
54
  status: "{{pollStatus}}",
55
55
  }, { x: 400, y: 50 }),
56
56
 
@@ -141,9 +141,8 @@ export const TASK_BATCH_PR_TEMPLATE = {
141
141
  category: "lifecycle",
142
142
  enabled: true,
143
143
  recommended: false,
144
- trigger: "trigger.task_low",
144
+ trigger: "trigger.task_available",
145
145
  variables: {
146
- backlogThreshold: 3,
147
146
  maxConcurrent: 2,
148
147
  pollStatus: "todo",
149
148
  maxBatchSize: 5,
@@ -153,8 +152,9 @@ export const TASK_BATCH_PR_TEMPLATE = {
153
152
  },
154
153
  nodes: [
155
154
  // ── Trigger ──────────────────────────────────────────────────────────
156
- node("trigger", "trigger.task_low", "Backlog Low?", {
157
- threshold: "{{backlogThreshold}}",
155
+ node("trigger", "trigger.task_available", "Tasks Available?", {
156
+ maxParallel: "{{maxConcurrent}}",
157
+ pollIntervalMs: 60000,
158
158
  status: "{{pollStatus}}",
159
159
  }, { x: 400, y: 50 }),
160
160
 
@@ -403,6 +403,16 @@ export function listWorkspaces(configDir, opts = {}) {
403
403
  const standardExists = existsSync(standardPath);
404
404
  let effectivePath = standardPath;
405
405
  let exists = standardExists;
406
+ const repoUrlRaw = String(repo.url || "").trim();
407
+ const looksLikeRemoteUrl =
408
+ /^https?:\/\//i.test(repoUrlRaw) ||
409
+ /^git@[^:]+:/i.test(repoUrlRaw) ||
410
+ /^ssh:\/\//i.test(repoUrlRaw);
411
+ const repoUrlPath = repoUrlRaw && !looksLikeRemoteUrl ? resolve(repoUrlRaw) : null;
412
+ if (!standardExists && repoUrlPath && existsSync(repoUrlPath)) {
413
+ effectivePath = repoUrlPath;
414
+ exists = true;
415
+ }
406
416
  if (!standardExists && repoRootOverride) {
407
417
  const altPath = resolve(repoRootOverride, repo.name);
408
418
  if (existsSync(altPath)) {
@@ -1000,3 +1010,4 @@ export function initializeWorkspaces(configDir, opts = {}) {
1000
1010
 
1001
1011
  return { workspaces: [], isNew: true };
1002
1012
  }
1013
+
@@ -81,6 +81,12 @@ function fixGitConfigCorruption(repoRoot) {
81
81
  timeout: 5000,
82
82
  env: { ...process.env, ...GIT_ENV },
83
83
  });
84
+ spawnSync("git", ["config", "--local", "--unset-all", "core.worktree"], {
85
+ cwd: repoRoot,
86
+ encoding: "utf8",
87
+ timeout: 5000,
88
+ env: { ...process.env, ...GIT_ENV },
89
+ });
84
90
  }
85
91
  } catch {
86
92
  /* best-effort — don't crash on config repair */