@runfusion/fusion 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/bin.js +1991 -993
  2. package/dist/client/assets/AgentDetailView-BKKpbp1S.js +18 -0
  3. package/dist/client/assets/AgentDetailView-CeO_1MK7.css +1 -0
  4. package/dist/client/assets/AgentsView-BRXFmrcJ.js +527 -0
  5. package/dist/client/assets/AgentsView-Bs03ptrd.css +1 -0
  6. package/dist/client/assets/ChatView-D7L2e_qu.js +1 -0
  7. package/dist/client/assets/DevServerView-l8RCyL2k.js +1 -0
  8. package/dist/client/assets/DirectoryPicker-CS1dwqcC.js +1 -0
  9. package/dist/client/assets/DocumentsView-DmthQWDZ.js +1 -0
  10. package/dist/client/assets/{InsightsView-CqDethVs.js → InsightsView-DvXpMKmH.js} +2 -2
  11. package/dist/client/assets/{MemoryView-BLIm9Vr7.js → MemoryView-CPwlKnUI.js} +2 -2
  12. package/dist/client/assets/{NodesView-DEXvp3WT.js → NodesView-BLlfUfsy.js} +3 -3
  13. package/dist/client/assets/{PiExtensionsManager-C2YjI9o2.js → PiExtensionsManager-j8rPXqmB.js} +2 -2
  14. package/dist/client/assets/PluginManager-pW6RMz5z.js +1 -0
  15. package/dist/client/assets/ResearchView-D9DNJYDq.js +1 -0
  16. package/dist/client/assets/{RoadmapsView-DPcfX5MS.js → RoadmapsView-Djc_X35v.js} +2 -2
  17. package/dist/client/assets/SettingsModal-WGCF_pk8.js +31 -0
  18. package/dist/client/assets/{SettingsModal-BRNAPR1u.js → SettingsModal-fxvTFLtR.js} +1 -1
  19. package/dist/client/assets/SetupWizardModal-tG_MF_nA.js +1 -0
  20. package/dist/client/assets/SkillsView-Ddf0YL8z.js +1 -0
  21. package/dist/client/assets/agentSkills-DDHJnrkn.css +1 -0
  22. package/dist/client/assets/agentSkills-EwIwBlG8.js +1 -0
  23. package/dist/client/assets/folder-open-BiJpmnaT.js +6 -0
  24. package/dist/client/assets/index-D6ebxTPF.css +1 -0
  25. package/dist/client/assets/index-DYDLmOcK.js +694 -0
  26. package/dist/client/assets/{star-B314SwLA.js → star-BwRZmiuZ.js} +2 -2
  27. package/dist/client/assets/upload-D4NwZhPp.js +6 -0
  28. package/dist/client/assets/{users-Bu_ltePs.js → users-DNISDtI1.js} +2 -2
  29. package/dist/client/index.html +2 -2
  30. package/dist/client/version.json +1 -1
  31. package/dist/droid-cli/package.json +1 -1
  32. package/dist/extension.js +1154 -401
  33. package/dist/pi-claude-cli/package.json +1 -1
  34. package/dist/plugins/fusion-plugin-dependency-graph/package.json +1 -1
  35. package/dist/plugins/fusion-plugin-hermes-runtime/bundled.js +480 -0
  36. package/dist/plugins/fusion-plugin-hermes-runtime/manifest.json +14 -0
  37. package/dist/plugins/fusion-plugin-hermes-runtime/package.json +11 -0
  38. package/dist/plugins/fusion-plugin-openclaw-runtime/bundled.js +369 -0
  39. package/dist/plugins/fusion-plugin-openclaw-runtime/manifest.json +14 -0
  40. package/dist/plugins/fusion-plugin-openclaw-runtime/package.json +11 -0
  41. package/dist/plugins/fusion-plugin-paperclip-runtime/bundled.js +966 -0
  42. package/dist/plugins/fusion-plugin-paperclip-runtime/manifest.json +15 -0
  43. package/dist/plugins/fusion-plugin-paperclip-runtime/package.json +11 -0
  44. package/package.json +2 -1
  45. package/skill/fusion/references/engine-tools.md +1 -1
  46. package/dist/client/assets/AgentDetailView-CUtWvXBn.css +0 -1
  47. package/dist/client/assets/AgentDetailView-Dg7Qa1rG.js +0 -18
  48. package/dist/client/assets/ChatView-ODq-kBk6.js +0 -1
  49. package/dist/client/assets/DevServerView-6PS9Lvl7.js +0 -1
  50. package/dist/client/assets/DirectoryPicker-B3dza2Dq.js +0 -1
  51. package/dist/client/assets/DocumentsView-Bu9YYlki.js +0 -1
  52. package/dist/client/assets/PluginManager-Dnf-LhYw.js +0 -1
  53. package/dist/client/assets/ResearchView-Z0TZ7WGo.js +0 -1
  54. package/dist/client/assets/SettingsModal-B6RN9VYe.js +0 -31
  55. package/dist/client/assets/SetupWizardModal-BFc3xID2.js +0 -1
  56. package/dist/client/assets/SkillsView-CipGahOR.js +0 -1
  57. package/dist/client/assets/index-Df1bHDY4.css +0 -1
  58. package/dist/client/assets/index-NFptaeUQ.js +0 -1222
package/dist/extension.js CHANGED
@@ -1183,6 +1183,7 @@ function agentToConfigSnapshot(agent) {
1183
1183
  role: agent.role,
1184
1184
  title: agent.title,
1185
1185
  icon: agent.icon,
1186
+ imageUrl: agent.imageUrl,
1186
1187
  reportsTo: agent.reportsTo,
1187
1188
  runtimeConfig: agent.runtimeConfig ? { ...agent.runtimeConfig } : void 0,
1188
1189
  permissions: agent.permissions ? { ...agent.permissions } : void 0,
@@ -1204,6 +1205,7 @@ function diffConfigSnapshots(before, after) {
1204
1205
  "role",
1205
1206
  "title",
1206
1207
  "icon",
1208
+ "imageUrl",
1207
1209
  "reportsTo",
1208
1210
  "runtimeConfig",
1209
1211
  "permissions",
@@ -1574,12 +1576,10 @@ Output Requirements:
1574
1576
  };
1575
1577
  AGENT_VALID_TRANSITIONS = {
1576
1578
  idle: ["active"],
1577
- active: ["running", "paused", "terminated"],
1578
- running: ["active", "paused", "error", "terminated"],
1579
- paused: ["active", "terminated"],
1580
- error: ["active", "terminated"],
1581
- terminated: ["idle", "active", "running"]
1582
- // Can be restarted or reset
1579
+ active: ["idle", "running", "paused", "error"],
1580
+ running: ["idle", "active", "paused", "error"],
1581
+ paused: ["idle", "active"],
1582
+ error: ["idle", "active"]
1583
1583
  };
1584
1584
  AGENT_PERMISSIONS = [
1585
1585
  "tasks:assign",
@@ -5555,6 +5555,7 @@ var init_agent_store = __esm({
5555
5555
  metadata,
5556
5556
  ...input.title && { title: input.title },
5557
5557
  ...input.icon && { icon: input.icon },
5558
+ ...input.imageUrl && { imageUrl: input.imageUrl },
5558
5559
  ...input.reportsTo && { reportsTo: input.reportsTo },
5559
5560
  ...runtimeConfig && { runtimeConfig },
5560
5561
  ...input.permissions && { permissions: input.permissions },
@@ -5938,6 +5939,7 @@ var init_agent_store = __esm({
5938
5939
  updatedAt,
5939
5940
  ..."title" in updates && { title: updates.title },
5940
5941
  ..."icon" in updates && { icon: updates.icon },
5942
+ ..."imageUrl" in updates && { imageUrl: updates.imageUrl },
5941
5943
  ..."reportsTo" in updates && { reportsTo: updates.reportsTo },
5942
5944
  ..."runtimeConfig" in updates && { runtimeConfig: updates.runtimeConfig },
5943
5945
  ..."pauseReason" in updates && { pauseReason: updates.pauseReason },
@@ -6063,7 +6065,9 @@ var init_agent_store = __esm({
6063
6065
  state: newState,
6064
6066
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
6065
6067
  // Clear lastError when transitioning away from terminated
6066
- ...currentState === "terminated" && newState !== "terminated" && { lastError: void 0 }
6068
+ // Clear lastError when an agent re-enters an actionable state so
6069
+ // a resumed agent does not carry stale "Error" badges.
6070
+ ...(newState === "active" || newState === "running") && { lastError: void 0 }
6067
6071
  };
6068
6072
  await this.writeAgent(updated);
6069
6073
  this.emit("agent:stateChanged", agentId, currentState, newState);
@@ -6278,9 +6282,6 @@ var init_agent_store = __esm({
6278
6282
  if (activeRun) {
6279
6283
  await this.endHeartbeatRun(activeRun.id, "terminated");
6280
6284
  }
6281
- if (agent.state !== "idle" && agent.state !== "terminated") {
6282
- agent = await this.updateAgentState(agentId, "terminated");
6283
- }
6284
6285
  if (agent.state !== "idle") {
6285
6286
  agent = await this.updateAgentState(agentId, "idle");
6286
6287
  }
@@ -6867,6 +6868,7 @@ var init_agent_store = __esm({
6867
6868
  role: snapshot.role,
6868
6869
  title: snapshot.title,
6869
6870
  icon: snapshot.icon,
6871
+ imageUrl: snapshot.imageUrl,
6870
6872
  reportsTo: snapshot.reportsTo,
6871
6873
  runtimeConfig: snapshot.runtimeConfig ? { ...snapshot.runtimeConfig } : void 0,
6872
6874
  permissions: snapshot.permissions ? { ...snapshot.permissions } : void 0,
@@ -7090,6 +7092,7 @@ var init_agent_store = __esm({
7090
7092
  metadata: data.metadata ?? {},
7091
7093
  title: data.title,
7092
7094
  icon: data.icon,
7095
+ imageUrl: data.imageUrl,
7093
7096
  reportsTo: data.reportsTo,
7094
7097
  runtimeConfig: data.runtimeConfig,
7095
7098
  pauseReason: data.pauseReason,
@@ -7118,6 +7121,7 @@ var init_agent_store = __esm({
7118
7121
  metadata: agent.metadata,
7119
7122
  title: agent.title,
7120
7123
  icon: agent.icon,
7124
+ imageUrl: agent.imageUrl,
7121
7125
  reportsTo: agent.reportsTo,
7122
7126
  runtimeConfig: agent.runtimeConfig,
7123
7127
  pauseReason: agent.pauseReason,
@@ -32546,20 +32550,17 @@ This project has OpenClaw-style memory files:
32546
32550
 
32547
32551
  **At the end of execution (before calling \`fn_task_done()\`):**
32548
32552
  1. Review what you learned during this task that would genuinely benefit future runs
32549
- 2. Write durable decisions, conventions, and pitfalls to \`.fusion/memory/MEMORY.md\`
32550
- 3. Write running observations, unresolved context, and open loops to today's \`.fusion/memory/YYYY-MM-DD.md\`
32551
- 4. **If nothing durable was learned, skip the memory update entirely** \u2014 do not append trivial or task-specific notes
32552
- 5. Only write when you have genuinely durable, reusable insights such as:
32553
- - New architectural patterns or module boundaries discovered
32554
- - Conventions or standards that should be followed
32555
- - Pitfalls or anti-patterns to avoid in future work
32556
- - Important constraints or context that affects implementation decisions
32557
- 6. **Avoid** writing task-specific trivia such as:
32558
- - Per-task implementation logs or changelog entries
32559
- - Transient failures resolved without broader lessons
32560
- - One-off file paths, variable names, or minor code changes
32561
- - Notes about what you did rather than what future agents should know
32562
- 7. **Consolidate when possible**: If an existing entry already covers a concept, update or refine it rather than adding a duplicate. Delete entries that are no longer accurate.
32553
+ 2. Choose scope intentionally:
32554
+ - Use \`fn_memory_append(scope="agent")\` for your private operating context (personal checklists, delegation habits, temporary playbooks, self-improvement notes)
32555
+ - Use \`fn_memory_append(scope="project")\` for repository-wide durable knowledge any future agent should know
32556
+ 3. Choose layer intentionally:
32557
+ - \`layer="long-term"\` for durable conventions/decisions/pitfalls
32558
+ - \`layer="daily"\` for running observations, unresolved context, and open loops
32559
+ 4. If using project scope with file backend, write long-term memory to \`.fusion/memory/MEMORY.md\` and daily notes to today's \`.fusion/memory/YYYY-MM-DD.md\`
32560
+ 5. **If nothing durable was learned, skip the memory update entirely** \u2014 do not append trivial or task-specific notes
32561
+ 6. Only write to **project** memory when the insight is genuinely reusable across the workspace (architecture patterns, shared conventions, durable pitfalls, cross-task constraints)
32562
+ 7. **Do not** write private/ephemeral items to project memory, such as personal TODOs, one-off scratch notes, or preferences that only help you as an individual agent
32563
+ 8. **Consolidate when possible**: If an existing entry already covers a concept, update or refine it rather than adding a duplicate. Delete entries that are no longer accurate.
32563
32564
 
32564
32565
  **Format for additions:** Add bullet points under the relevant section heading:
32565
32566
  - Use \`- \` prefix for list items
@@ -32579,18 +32580,15 @@ This project has a memory system that stores durable project learnings accumulat
32579
32580
 
32580
32581
  **At the end of execution (before calling \`fn_task_done()\`):**
32581
32582
  1. Review what you learned during this task that would genuinely benefit future runs
32582
- 2. **If nothing durable was learned, skip the memory update entirely** \u2014 do not append trivial or task-specific notes
32583
- 3. Only write when you have genuinely durable, reusable insights such as:
32584
- - New architectural patterns or module boundaries discovered
32585
- - Conventions or standards that should be followed
32586
- - Pitfalls or anti-patterns to avoid in future work
32587
- - Important constraints or context that affects implementation decisions
32588
- 4. **Avoid** writing task-specific trivia such as:
32589
- - Per-task implementation logs or changelog entries
32590
- - Transient failures resolved without broader lessons
32591
- - One-off file paths, variable names, or minor code changes
32592
- - Notes about what you did rather than what future agents should know
32593
- 5. Consolidate when possible: refine an existing memory entry instead of adding duplicates.
32583
+ 2. Choose scope intentionally:
32584
+ - Use \`fn_memory_append(scope="agent")\` for your private operating context
32585
+ - Use \`fn_memory_append(scope="project")\` only for repo-wide durable knowledge
32586
+ 3. Choose layer intentionally:
32587
+ - \`layer="long-term"\` for durable conventions/decisions/pitfalls
32588
+ - \`layer="daily"\` for running observations and open loops
32589
+ 4. **If nothing durable was learned, skip the memory update entirely** \u2014 do not append trivial or task-specific notes
32590
+ 5. **Avoid task-specific trivia** in project scope (for example: personal reminders, one-off scratch thoughts, individual communication preferences)
32591
+ 6. Consolidate when possible: refine an existing memory entry instead of adding duplicates.
32594
32592
  `;
32595
32593
  }
32596
32594
  function buildReviewerMemoryInstructions(rootDir, settings) {
@@ -56340,8 +56338,12 @@ function createFusionAuthStorage() {
56340
56338
  const primary = AuthStorage.create(getFusionAuthPath());
56341
56339
  let supplementalCredentials = readSupplementalCredentials();
56342
56340
  let modelsJsonApiKeys = readModelsJsonApiKeys();
56341
+ const loggedOutProviders = /* @__PURE__ */ new Set();
56343
56342
  const syncSupplementalOauthCredentials = () => {
56344
56343
  for (const [provider, credential] of Object.entries(supplementalCredentials)) {
56344
+ if (loggedOutProviders.has(provider)) {
56345
+ continue;
56346
+ }
56345
56347
  const current = primary.get(provider);
56346
56348
  if (!shouldHydrateStoredCredential(current, credential)) {
56347
56349
  continue;
@@ -56362,6 +56364,24 @@ function createFusionAuthStorage() {
56362
56364
  return true;
56363
56365
  },
56364
56366
  get(target, prop, receiver) {
56367
+ if (prop === "logout") {
56368
+ return (provider) => {
56369
+ target.logout(provider);
56370
+ loggedOutProviders.add(provider);
56371
+ };
56372
+ }
56373
+ if (prop === "remove") {
56374
+ return (provider) => {
56375
+ target.remove(provider);
56376
+ loggedOutProviders.add(provider);
56377
+ };
56378
+ }
56379
+ if (prop === "set") {
56380
+ return (provider, credential) => {
56381
+ target.set(provider, credential);
56382
+ loggedOutProviders.delete(provider);
56383
+ };
56384
+ }
56365
56385
  if (prop === "reload") {
56366
56386
  return () => {
56367
56387
  target.reload();
@@ -56371,25 +56391,43 @@ function createFusionAuthStorage() {
56371
56391
  };
56372
56392
  }
56373
56393
  if (prop === "get") {
56374
- return (provider) => choosePreferredStoredCredential(
56375
- target.get(provider),
56376
- supplementalCredentials[provider]
56377
- );
56394
+ return (provider) => {
56395
+ if (loggedOutProviders.has(provider)) {
56396
+ return void 0;
56397
+ }
56398
+ return choosePreferredStoredCredential(
56399
+ target.get(provider),
56400
+ supplementalCredentials[provider]
56401
+ );
56402
+ };
56378
56403
  }
56379
56404
  if (prop === "has") {
56380
- return (provider) => target.has(provider) || provider in supplementalCredentials || modelsJsonApiKeys.has(provider);
56405
+ return (provider) => {
56406
+ if (loggedOutProviders.has(provider)) {
56407
+ return false;
56408
+ }
56409
+ return target.has(provider) || provider in supplementalCredentials || modelsJsonApiKeys.has(provider);
56410
+ };
56381
56411
  }
56382
56412
  if (prop === "hasAuth") {
56383
- return (provider) => target.hasAuth(provider) || Boolean(supplementalCredentials[provider]) || modelsJsonApiKeys.has(provider);
56413
+ return (provider) => {
56414
+ if (loggedOutProviders.has(provider)) {
56415
+ return false;
56416
+ }
56417
+ return target.hasAuth(provider) || Boolean(supplementalCredentials[provider]) || modelsJsonApiKeys.has(provider);
56418
+ };
56384
56419
  }
56385
56420
  if (prop === "getAll") {
56386
56421
  return () => {
56387
56422
  const providerIds = /* @__PURE__ */ new Set([
56388
- ...Object.keys(supplementalCredentials),
56389
- ...Object.keys(target.getAll())
56423
+ ...Object.keys(target.getAll()),
56424
+ ...loggedOutProviders.size > 0 ? Object.keys(supplementalCredentials).filter((p) => !loggedOutProviders.has(p)) : Object.keys(supplementalCredentials)
56390
56425
  ]);
56391
56426
  const merged = {};
56392
56427
  for (const providerId of providerIds) {
56428
+ if (loggedOutProviders.has(providerId)) {
56429
+ continue;
56430
+ }
56393
56431
  const credential = choosePreferredStoredCredential(
56394
56432
  target.get(providerId),
56395
56433
  supplementalCredentials[providerId]
@@ -56402,10 +56440,26 @@ function createFusionAuthStorage() {
56402
56440
  };
56403
56441
  }
56404
56442
  if (prop === "list") {
56405
- return () => Array.from(/* @__PURE__ */ new Set([...Object.keys(supplementalCredentials), ...target.list(), ...modelsJsonApiKeys.keys()]));
56443
+ return () => {
56444
+ const providers = /* @__PURE__ */ new Set([...target.list()]);
56445
+ for (const p of modelsJsonApiKeys.keys()) {
56446
+ if (!loggedOutProviders.has(p)) {
56447
+ providers.add(p);
56448
+ }
56449
+ }
56450
+ for (const p of Object.keys(supplementalCredentials)) {
56451
+ if (!loggedOutProviders.has(p)) {
56452
+ providers.add(p);
56453
+ }
56454
+ }
56455
+ return Array.from(providers).filter((p) => !loggedOutProviders.has(p));
56456
+ };
56406
56457
  }
56407
56458
  if (prop === "getApiKey") {
56408
56459
  return async (provider) => {
56460
+ if (loggedOutProviders.has(provider)) {
56461
+ return void 0;
56462
+ }
56409
56463
  const primaryKey = await target.getApiKey(provider);
56410
56464
  if (primaryKey) return primaryKey;
56411
56465
  const supplementalKey = resolveStoredCredentialApiKey(provider, supplementalCredentials[provider]);
@@ -56659,7 +56713,8 @@ async function flushMemoryBeforeSessionCompaction(session) {
56659
56713
  }
56660
56714
  const flushPrompt = [
56661
56715
  "Before context compaction, preserve only unresolved durable memory if needed.",
56662
- "If fn_memory_append is available and you learned reusable project decisions, conventions, pitfalls, or open loops that are not already saved, append them now.",
56716
+ "If fn_memory_append is available and you learned reusable project decisions/conventions/pitfalls/open loops or private operating context that is not already saved, append it now.",
56717
+ 'Use scope="project" for shared workspace knowledge and scope="agent" for private operating context.',
56663
56718
  'Use layer="long-term" for durable facts and layer="daily" for running notes/open loops.',
56664
56719
  "If there is nothing durable to save, reply exactly: NONE."
56665
56720
  ].join("\n");
@@ -57153,7 +57208,10 @@ async function createFnAgent2(options) {
57153
57208
  if (selectionResult.diagnostics.length > 0) {
57154
57209
  const purpose = effectiveSkillSelection.sessionPurpose ?? "skills";
57155
57210
  for (const diag of selectionResult.diagnostics) {
57156
- piLog.warn(`[skills] [${purpose}] ${diag.type}: ${diag.message}`);
57211
+ const msg = `[skills] [${purpose}] ${diag.type}: ${diag.message}`;
57212
+ if (diag.type === "error") piLog.error(msg);
57213
+ else if (diag.type === "warning") piLog.warn(msg);
57214
+ else piLog.log(msg);
57157
57215
  }
57158
57216
  }
57159
57217
  skillsOverrideFn = createSkillsOverrideFromSelection(selectionResult, {
@@ -58619,6 +58677,33 @@ function buildSystemPromptWithInstructions(basePrompt, instructions) {
58619
58677
 
58620
58678
  ${instructions}`;
58621
58679
  }
58680
+ function buildPluginPromptSection(surface, pluginRunner) {
58681
+ if (!pluginRunner) {
58682
+ return "";
58683
+ }
58684
+ const contributions = pluginRunner.getPromptContributionsForSurface(surface);
58685
+ if (contributions.length === 0) {
58686
+ return "";
58687
+ }
58688
+ const prependByPlugin = /* @__PURE__ */ new Map();
58689
+ const appendByPlugin = /* @__PURE__ */ new Map();
58690
+ for (const { pluginId, contribution } of contributions) {
58691
+ const target = contribution.position === "prepend" ? prependByPlugin : appendByPlugin;
58692
+ const existing = target.get(pluginId) ?? [];
58693
+ existing.push(contribution.content);
58694
+ target.set(pluginId, existing);
58695
+ }
58696
+ const toSections = (group) => {
58697
+ return Array.from(group.entries()).map(([pluginId, contents]) => {
58698
+ return `## Plugin: ${pluginId}
58699
+
58700
+ ${contents.join("\n\n")}`;
58701
+ });
58702
+ };
58703
+ const sections = [...toSections(prependByPlugin), ...toSections(appendByPlugin)];
58704
+ log11.log(`Applied ${contributions.length} prompt contributions for surface '${surface}'`);
58705
+ return sections.join("\n\n");
58706
+ }
58622
58707
  var log11, MAX_INSTRUCTIONS_PATH_LENGTH, MAX_INSTRUCTIONS_TEXT_LENGTH, MAX_SOUL_LENGTH, MAX_MEMORY_LENGTH;
58623
58708
  var init_agent_instructions = __esm({
58624
58709
  "../engine/src/agent-instructions.ts"() {
@@ -59173,7 +59258,7 @@ function createMemoryAppendTool(rootDir, settings, options) {
59173
59258
  return {
59174
59259
  name: "fn_memory_append",
59175
59260
  label: "Append Memory",
59176
- description: "Append concise Markdown to project memory. Use long-term only for durable conventions/decisions/pitfalls; use daily for running observations and open loops. Skip this tool when there is no reusable memory.",
59261
+ description: 'Append concise Markdown to memory. Use scope="agent" for private operating context and scope="project" for workspace-wide durable knowledge. Use layer="long-term" for durable conventions/decisions/pitfalls and layer="daily" for running observations/open loops.',
59177
59262
  parameters: memoryAppendParams,
59178
59263
  execute: async (_id, params) => {
59179
59264
  const content = params.content.trim();
@@ -61332,6 +61417,17 @@ async function reviewStep(cwd, taskId, stepNumber, stepName, reviewType, promptC
61332
61417
  reviewerBasePrompt + memorySection,
61333
61418
  reviewerInstructions
61334
61419
  );
61420
+ const reviewerContributions = options.pluginRunner?.getPromptContributionsForSurface("reviewer") ?? [];
61421
+ if (reviewerContributions.length > 0) {
61422
+ reviewerLog.log(`applied ${reviewerContributions.length} plugin prompt contributions for reviewer surface`);
61423
+ }
61424
+ const reviewerPluginContributions = buildPluginPromptSection(
61425
+ "reviewer",
61426
+ options.pluginRunner
61427
+ );
61428
+ const reviewerSystemPromptFinal = reviewerPluginContributions ? `${reviewerSystemPrompt}
61429
+
61430
+ ${reviewerPluginContributions}` : reviewerSystemPrompt;
61335
61431
  let skillContext = void 0;
61336
61432
  if (options.agentStore && options.rootDir) {
61337
61433
  try {
@@ -61377,7 +61473,7 @@ async function reviewStep(cwd, taskId, stepNumber, stepName, reviewType, promptC
61377
61473
  runtimeHint: extractRuntimeHint(memoryAgent?.runtimeConfig),
61378
61474
  pluginRunner: options.pluginRunner,
61379
61475
  cwd,
61380
- systemPrompt: reviewerSystemPrompt,
61476
+ systemPrompt: reviewerSystemPromptFinal,
61381
61477
  tools: "readonly",
61382
61478
  customTools: memoryTools,
61383
61479
  onText: agentLogger ? agentLogger.onText : (delta) => options.onText?.(delta),
@@ -62637,23 +62733,7 @@ Write the PROMPT.md directly using the write tool, then call \`fn_review_spec()\
62637
62733
  this.options = options;
62638
62734
  store.on("settings:updated", ({ settings, previous }) => {
62639
62735
  if (settings.globalPause && !previous.globalPause) {
62640
- for (const taskId of [...this.activeSubagentSessions.keys()]) {
62641
- this.disposeSubagentsForTask(taskId, "global pause");
62642
- }
62643
- for (const [taskId, session] of this.activeSessions) {
62644
- planLog.log(
62645
- `Global pause \u2014 terminating triage session for ${taskId}`
62646
- );
62647
- this.pauseAborted.add(taskId);
62648
- this.options.stuckTaskDetector?.untrackTask(taskId);
62649
- const sessionWithAbort = session;
62650
- if (typeof sessionWithAbort.abort === "function") {
62651
- void sessionWithAbort.abort().catch((err) => {
62652
- planLog.warn(`Failed to abort triage session for ${taskId}: ${err}`);
62653
- });
62654
- }
62655
- session.dispose();
62656
- }
62736
+ this.abortAndDisposeActiveSessions("global pause");
62657
62737
  }
62658
62738
  });
62659
62739
  store.on("settings:updated", ({ settings, previous }) => {
@@ -62722,8 +62802,35 @@ Write the PROMPT.md directly using the write tool, then call \`fn_review_spec()\
62722
62802
  this.pollInterval = null;
62723
62803
  this.activePollMs = null;
62724
62804
  }
62805
+ this.abortAndDisposeActiveSessions("engine stop");
62725
62806
  planLog.log("Processor stopped");
62726
62807
  }
62808
+ /**
62809
+ * Abort and dispose every active specify session and reviewer subagent.
62810
+ * Used by the global-pause handler and by `stop()`.
62811
+ *
62812
+ * Reviewer subagents are torn down first so they don't keep streaming
62813
+ * verdicts while the main triage session is being disposed. abort()
62814
+ * interrupts any in-flight LLM stream / tool call; dispose() then
62815
+ * releases session resources.
62816
+ */
62817
+ abortAndDisposeActiveSessions(reason) {
62818
+ for (const taskId of [...this.activeSubagentSessions.keys()]) {
62819
+ this.disposeSubagentsForTask(taskId, reason);
62820
+ }
62821
+ for (const [taskId, session] of this.activeSessions) {
62822
+ planLog.log(`${reason} \u2014 terminating triage session for ${taskId}`);
62823
+ this.pauseAborted.add(taskId);
62824
+ this.options.stuckTaskDetector?.untrackTask(taskId);
62825
+ const sessionWithAbort = session;
62826
+ if (typeof sessionWithAbort.abort === "function") {
62827
+ void sessionWithAbort.abort().catch((err) => {
62828
+ planLog.warn(`Failed to abort triage session for ${taskId}: ${err}`);
62829
+ });
62830
+ }
62831
+ session.dispose();
62832
+ }
62833
+ }
62727
62834
  /**
62728
62835
  * Mark a task as stuck-aborted so the catch block knows not to treat
62729
62836
  * the disposed session as a genuine failure.
@@ -63017,6 +63124,17 @@ Write the PROMPT.md directly using the write tool, then call \`fn_review_spec()\
63017
63124
  resolveAgentPrompt("triage", settings.agentPrompts) || (isFast ? FAST_TRIAGE_SYSTEM_PROMPT : TRIAGE_SYSTEM_PROMPT),
63018
63125
  triageInstructions
63019
63126
  );
63127
+ const triageContributions = this.options.pluginRunner?.getPromptContributionsForSurface("triage") ?? [];
63128
+ if (triageContributions.length > 0) {
63129
+ planLog.log(`${task.id}: applied ${triageContributions.length} plugin prompt contributions for triage surface`);
63130
+ }
63131
+ const triagePluginContributions = buildPluginPromptSection(
63132
+ "triage",
63133
+ this.options.pluginRunner
63134
+ );
63135
+ const triageSystemPromptFinal = triagePluginContributions ? `${triageSystemPrompt}
63136
+
63137
+ ${triagePluginContributions}` : triageSystemPrompt;
63020
63138
  const skillContext = await buildSessionSkillContext({
63021
63139
  agentStore: this.options.agentStore,
63022
63140
  task,
@@ -63029,7 +63147,7 @@ Write the PROMPT.md directly using the write tool, then call \`fn_review_spec()\
63029
63147
  runtimeHint: triageRuntimeHint,
63030
63148
  pluginRunner: this.options.pluginRunner,
63031
63149
  cwd: this.rootDir,
63032
- systemPrompt: triageSystemPrompt,
63150
+ systemPrompt: triageSystemPromptFinal,
63033
63151
  tools: "coding",
63034
63152
  customTools,
63035
63153
  onText: agentLogger.onText,
@@ -63193,7 +63311,7 @@ Write the PROMPT.md directly using the write tool, then call \`fn_review_spec()\
63193
63311
  runtimeHint: triageRuntimeHint,
63194
63312
  pluginRunner: this.options.pluginRunner,
63195
63313
  cwd: this.rootDir,
63196
- systemPrompt: triageSystemPrompt,
63314
+ systemPrompt: triageSystemPromptFinal,
63197
63315
  tools: "coding",
63198
63316
  customTools,
63199
63317
  onText: agentLogger.onText,
@@ -64767,6 +64885,7 @@ Do not refactor, rename broadly, or make opportunistic improvements.
64767
64885
  taskTitle: taskForSkillContext?.title
64768
64886
  })
64769
64887
  });
64888
+ options.onSession?.(session);
64770
64889
  const runId = mergeRunContext?.runId;
64771
64890
  const agentId = mergeRunContext?.agentId ?? "merger";
64772
64891
  await store.logEntry(
@@ -64867,50 +64986,322 @@ function resetMergeWithWarn(rootDir, taskId, label) {
64867
64986
  mergerLog.warn(`${taskId}: git reset --merge cleanup failed during ${label}: ${msg}`);
64868
64987
  }
64869
64988
  }
64870
- async function stashUnrelatedRootDirChanges(rootDir, taskId) {
64989
+ async function listOrphanedAutostashes(rootDir) {
64871
64990
  try {
64872
- const dirty = await snapshotDirtyFiles(rootDir);
64873
- if (dirty.size === 0) return null;
64874
- const label = `fusion-merger-autostash:${taskId}:${Date.now()}`;
64875
- await execAsync2(
64876
- `git stash push -u -m "${label}"`,
64877
- { cwd: rootDir }
64878
- );
64879
64991
  const { stdout } = await execAsync2(
64880
- `git stash list --format="%gd %s"`,
64992
+ `git stash list --format="%H %gd %s"`,
64881
64993
  { cwd: rootDir, encoding: "utf-8" }
64882
64994
  );
64883
- const lines = String(stdout).split("\n");
64884
- const match = lines.find((line) => line.includes(label));
64885
- if (!match) {
64995
+ const lines = String(stdout).split("\n").map((l) => l.trim()).filter(Boolean);
64996
+ const orphans = [];
64997
+ for (const line of lines) {
64998
+ const idx = line.indexOf(AUTOSTASH_LABEL_PREFIX);
64999
+ if (idx === -1) continue;
65000
+ const parts = line.split(/\s+/);
65001
+ const sha = parts[0] ?? "";
65002
+ const ref = parts[1] ?? "";
65003
+ const label = line.slice(idx);
65004
+ if (sha && ref) orphans.push({ sha, ref, label });
65005
+ }
65006
+ return orphans;
65007
+ } catch {
65008
+ return [];
65009
+ }
65010
+ }
65011
+ async function stashUnrelatedRootDirChanges(rootDir, taskId) {
65012
+ try {
65013
+ const orphans = await listOrphanedAutostashes(rootDir);
65014
+ if (orphans.length > 0) {
65015
+ const refs = orphans.map((o) => `${o.ref}@${o.sha.slice(0, 7)}`).join(", ");
64886
65016
  mergerLog.warn(
64887
- `${taskId}: created autostash but could not locate it in stash list \u2014 leaving in place to avoid data loss`
65017
+ `${taskId}: ${orphans.length} orphaned fusion-merger-autostash entry(ies) in stash list (${refs}) \u2014 these are uncommitted dev changes from prior merges whose restore failed. Recover with: cd ${rootDir} && git stash list && git stash apply <sha>`
64888
65018
  );
65019
+ }
65020
+ } catch {
65021
+ }
65022
+ try {
65023
+ const dirty = await snapshotDirtyFiles(rootDir);
65024
+ if (dirty.size === 0) return null;
65025
+ const label = `${AUTOSTASH_LABEL_PREFIX}${taskId}:${Date.now()}`;
65026
+ await execAsync2("git add -A", { cwd: rootDir });
65027
+ const { stdout: createOut } = await execAsync2("git stash create", {
65028
+ cwd: rootDir,
65029
+ encoding: "utf-8"
65030
+ });
65031
+ const sha = String(createOut).trim();
65032
+ if (!sha) {
65033
+ await execAsync2("git reset", { cwd: rootDir }).catch(() => void 0);
64889
65034
  return null;
64890
65035
  }
64891
- const ref = match.split(/\s+/)[0] ?? null;
65036
+ await execAsync2(
65037
+ `git stash store -m ${quoteArg(label)} ${sha}`,
65038
+ { cwd: rootDir }
65039
+ );
65040
+ await execAsync2("git reset --hard HEAD", { cwd: rootDir });
65041
+ await execAsync2("git clean -fd", { cwd: rootDir });
64892
65042
  mergerLog.log(
64893
- `${taskId}: stashed ${dirty.size} unrelated dirty path(s) in rootDir as ${ref} (${label})`
65043
+ `${taskId}: stashed ${dirty.size} unrelated dirty path(s) in rootDir as ${sha.slice(0, 7)} (${label})`
64894
65044
  );
64895
- return ref;
65045
+ return { sha, label };
64896
65046
  } catch (err) {
64897
65047
  const msg = err instanceof Error ? err.message : String(err);
64898
65048
  mergerLog.warn(
64899
65049
  `${taskId}: pre-merge autostash failed (${msg}) \u2014 proceeding without stash; concurrent dev edits in rootDir may be wiped`
64900
65050
  );
65051
+ try {
65052
+ await execAsync2("git reset", { cwd: rootDir });
65053
+ } catch {
65054
+ }
65055
+ return null;
65056
+ }
65057
+ }
65058
+ async function findStashRefBySha(rootDir, sha) {
65059
+ try {
65060
+ const { stdout } = await execAsync2(
65061
+ `git stash list --format="%H %gd"`,
65062
+ { cwd: rootDir, encoding: "utf-8" }
65063
+ );
65064
+ for (const line of String(stdout).split("\n")) {
65065
+ const trimmed = line.trim();
65066
+ if (!trimmed) continue;
65067
+ const [entrySha, ref] = trimmed.split(/\s+/);
65068
+ if (entrySha === sha && ref) return ref;
65069
+ }
65070
+ return null;
65071
+ } catch {
64901
65072
  return null;
64902
65073
  }
64903
65074
  }
64904
- async function restoreUnrelatedRootDirChanges(rootDir, taskId, stashRef) {
65075
+ async function dropAutostashBySha(rootDir, taskId, sha) {
65076
+ const ref = await findStashRefBySha(rootDir, sha);
65077
+ if (!ref) {
65078
+ mergerLog.log(`${taskId}: autostash ${sha.slice(0, 7)} no longer in stash list (already dropped)`);
65079
+ return;
65080
+ }
64905
65081
  try {
64906
- await execAsync2(`git stash pop "${stashRef}"`, { cwd: rootDir });
64907
- mergerLog.log(`${taskId}: restored autostash ${stashRef}`);
65082
+ await execAsync2(`git stash drop ${ref}`, { cwd: rootDir });
64908
65083
  } catch (err) {
64909
65084
  const msg = err instanceof Error ? err.message : String(err);
65085
+ mergerLog.warn(`${taskId}: failed to drop autostash ${ref} (${msg}) \u2014 harmless, will linger in stash list`);
65086
+ }
65087
+ }
65088
+ async function runAiAgentForAutostashConflict(params) {
65089
+ const { store, rootDir, taskId, conflictedFiles, options, settings } = params;
65090
+ const agentLogger = new AgentLogger({
65091
+ store,
65092
+ taskId,
65093
+ agent: "merger",
65094
+ persistAgentToolOutput: settings.persistAgentToolOutput,
65095
+ onAgentText: options.onAgentText ? (_id, delta) => options.onAgentText(delta) : void 0,
65096
+ onAgentTool: options.onAgentTool ? (_id, name) => options.onAgentTool(name) : void 0
65097
+ });
65098
+ let taskForSkillContext = null;
65099
+ let skillContext = void 0;
65100
+ if (options.agentStore) {
65101
+ try {
65102
+ taskForSkillContext = await store.getTask(taskId);
65103
+ skillContext = await buildSessionSkillContext({
65104
+ agentStore: options.agentStore,
65105
+ task: taskForSkillContext,
65106
+ sessionPurpose: "merger",
65107
+ projectRootDir: rootDir,
65108
+ pluginRunner: options.pluginRunner
65109
+ });
65110
+ } catch {
65111
+ }
65112
+ }
65113
+ const assignedAgentId = taskForSkillContext?.assignedAgentId?.trim();
65114
+ const agentStoreWithGetAgent = options.agentStore && typeof options.agentStore.getAgent === "function" ? options.agentStore : null;
65115
+ const assignedAgent = assignedAgentId && agentStoreWithGetAgent ? await agentStoreWithGetAgent.getAgent(assignedAgentId).catch(() => null) : null;
65116
+ const mergerRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
65117
+ const systemPrompt = `You are an autostash-conflict resolution agent running after a Fusion merge has already committed on the main branch.
65118
+
65119
+ Before the merge ran, the developer had uncommitted local changes in their working tree. The merger snapshotted those changes into a git stash, ran the merge cleanly, and is now reapplying the stash on top of the merged HEAD. The reapply hit conflicts because the merge committed changes that overlap the developer's stashed edits.
65120
+
65121
+ ## Your job
65122
+ Edit the conflicted files in place to remove every conflict marker (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`) and produce a coherent merged result that:
65123
+ - Preserves the developer's intended uncommitted changes (the "Updated upstream" / branch-side, depending on which side the stash pop wrote)
65124
+ - Layers them onto the merged HEAD content (the other side)
65125
+
65126
+ ## Rules
65127
+ 1. Read each conflicted file carefully before editing
65128
+ 2. Resolve every conflict marker \u2014 none may remain after you finish
65129
+ 3. Do NOT make any git commits. Do NOT run \`git add\` or \`git stash drop\`. Just edit the files.
65130
+ 4. Do NOT touch files that are not in the conflicted-files list
65131
+ 5. If you genuinely cannot determine the right resolution for a hunk, prefer the developer's stashed edits (their work is the unsaved context) and add a brief \`// TODO(autostash-conflict)\` comment so they can review
65132
+
65133
+ The orchestrator will verify post-run that no conflict markers remain. If any do, this attempt is treated as a failure and the stash is left intact for manual recovery.`;
65134
+ const fileList = conflictedFiles.map((f) => `- ${f}`).join("\n");
65135
+ const prompt = `Resolve autostash apply conflicts for task ${taskId}.
65136
+
65137
+ ## Conflicted files
65138
+ ${fileList}
65139
+
65140
+ ## Steps
65141
+ 1. For each file above, read its current contents (it has conflict markers from the failed \`git stash apply\`)
65142
+ 2. Edit it to a clean state with no conflict markers \u2014 preserving the developer's intended changes layered on top of the merged HEAD
65143
+ 3. After all files are clean, you are done. Do NOT commit or run git stash commands.`;
65144
+ mergerLog.log(`${taskId}: starting autostash-conflict resolution agent (${conflictedFiles.length} file(s))`);
65145
+ const { session } = await createResolvedAgentSession({
65146
+ sessionPurpose: "merger",
65147
+ runtimeHint: mergerRuntimeHint,
65148
+ pluginRunner: options.pluginRunner,
65149
+ cwd: rootDir,
65150
+ systemPrompt,
65151
+ tools: "coding",
65152
+ onText: agentLogger.onText,
65153
+ onThinking: agentLogger.onThinking,
65154
+ onToolStart: agentLogger.onToolStart,
65155
+ onToolEnd: agentLogger.onToolEnd,
65156
+ defaultProvider: settings.defaultProviderOverride && settings.defaultModelIdOverride ? settings.defaultProviderOverride : settings.defaultProvider,
65157
+ defaultModelId: settings.defaultProviderOverride && settings.defaultModelIdOverride ? settings.defaultModelIdOverride : settings.defaultModelId,
65158
+ fallbackProvider: settings.fallbackProvider,
65159
+ fallbackModelId: settings.fallbackModelId,
65160
+ defaultThinkingLevel: settings.defaultThinkingLevel,
65161
+ ...skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {},
65162
+ taskId,
65163
+ taskTitle: taskForSkillContext?.title,
65164
+ onFallbackModelUsed: createFallbackModelObserver({
65165
+ agent: "merger",
65166
+ label: "autostash conflict agent",
65167
+ store,
65168
+ taskId,
65169
+ taskTitle: taskForSkillContext?.title
65170
+ })
65171
+ });
65172
+ options.onSession?.(session);
65173
+ try {
65174
+ await store.appendAgentLog(
65175
+ taskId,
65176
+ `Autostash conflict agent started (model: ${describeModel(session)}, files: ${conflictedFiles.length})`,
65177
+ "text",
65178
+ void 0,
65179
+ "merger"
65180
+ );
65181
+ await withRateLimitRetry(async () => {
65182
+ throwIfAborted(options.signal, taskId);
65183
+ await promptWithFallback(session, prompt);
65184
+ checkSessionError(session);
65185
+ }, {
65186
+ onRetry: (attempt, delayMs, error) => {
65187
+ const delaySec = Math.round(delayMs / 1e3);
65188
+ mergerLog.warn(`\u23F3 ${taskId} autostash-conflict agent rate limited \u2014 retry ${attempt} in ${delaySec}s: ${error.message}`);
65189
+ },
65190
+ signal: options.signal
65191
+ });
65192
+ return { success: true };
65193
+ } catch (err) {
65194
+ const msg = err instanceof Error ? err.message : String(err);
65195
+ mergerLog.warn(`${taskId}: autostash-conflict agent error: ${msg}`);
65196
+ await store.logEntry(taskId, "Autostash conflict agent encountered an error", msg);
65197
+ return { success: false, error: msg };
65198
+ } finally {
65199
+ try {
65200
+ session.dispose();
65201
+ } catch {
65202
+ }
65203
+ }
65204
+ }
65205
+ async function findFilesWithConflictMarkers(rootDir, files) {
65206
+ const stillConflicted = [];
65207
+ for (const file of files) {
65208
+ try {
65209
+ const fullPath = join30(rootDir, file);
65210
+ if (!existsSync24(fullPath)) continue;
65211
+ const { stdout } = await execAsync2(
65212
+ `git grep -l -e "^<<<<<<< " -e "^=======$" -e "^>>>>>>> " --no-index -- ${quoteArg(fullPath)}`,
65213
+ { cwd: rootDir, encoding: "utf-8" }
65214
+ ).catch(() => ({ stdout: "" }));
65215
+ if (String(stdout).trim()) stillConflicted.push(file);
65216
+ } catch {
65217
+ }
65218
+ }
65219
+ return stillConflicted;
65220
+ }
65221
+ async function restoreUnrelatedRootDirChanges(rootDir, taskId, handle, ctx) {
65222
+ const { sha } = handle;
65223
+ let applyConflicted = false;
65224
+ try {
65225
+ await execAsync2(`git stash apply ${sha}`, { cwd: rootDir });
65226
+ } catch (err) {
65227
+ const msg = err instanceof Error ? err.message : String(err);
65228
+ const conflicted = await getConflictedFiles(rootDir);
65229
+ if (conflicted.length === 0) {
65230
+ mergerLog.warn(
65231
+ `${taskId}: failed to apply autostash ${sha.slice(0, 7)} (${msg}) \u2014 stash left intact; recover with: cd ${rootDir} && git stash apply ${sha}`
65232
+ );
65233
+ return { status: "failed", stashSha: sha, errorMessage: msg };
65234
+ }
65235
+ applyConflicted = true;
64910
65236
  mergerLog.warn(
64911
- `${taskId}: failed to pop autostash ${stashRef} (${msg}) \u2014 stash left intact; recover with: cd ${rootDir} && git stash list && git stash pop ${stashRef}`
65237
+ `${taskId}: autostash apply hit conflict in ${conflicted.length} file(s): ${conflicted.join(", ")}`
64912
65238
  );
64913
65239
  }
65240
+ if (!applyConflicted) {
65241
+ mergerLog.log(`${taskId}: restored autostash ${sha.slice(0, 7)} cleanly`);
65242
+ await dropAutostashBySha(rootDir, taskId, sha);
65243
+ return { status: "restored", stashSha: sha };
65244
+ }
65245
+ const conflictedFiles = await getConflictedFiles(rootDir);
65246
+ const smartConflictResolution = (ctx.settings.smartConflictResolution ?? ctx.settings.autoResolveConflicts) !== false;
65247
+ if (!smartConflictResolution) {
65248
+ const message = `Autostash apply conflicted in ${conflictedFiles.length} file(s) and smartConflictResolution is disabled. Stash ${sha.slice(0, 7)} left intact; resolve manually with: cd ${rootDir} && # edit files, then git stash drop <ref>`;
65249
+ mergerLog.warn(`${taskId}: ${message}`);
65250
+ return {
65251
+ status: "conflict-needs-manual",
65252
+ stashSha: sha,
65253
+ conflictedFiles,
65254
+ message
65255
+ };
65256
+ }
65257
+ await ctx.store.logEntry(
65258
+ taskId,
65259
+ `Autostash apply conflicted in ${conflictedFiles.length} file(s) \u2014 invoking AI to resolve`,
65260
+ conflictedFiles.join("\n")
65261
+ );
65262
+ const aiResult = await runAiAgentForAutostashConflict({
65263
+ store: ctx.store,
65264
+ rootDir,
65265
+ taskId,
65266
+ conflictedFiles,
65267
+ options: ctx.options,
65268
+ settings: ctx.settings
65269
+ });
65270
+ if (!aiResult.success) {
65271
+ const message = `Autostash apply conflict, AI resolution failed (${aiResult.error ?? "unknown error"}). Stash ${sha.slice(0, 7)} left intact; recover with: cd ${rootDir} && git status (conflicts in working tree) && # resolve, then git stash drop <ref>`;
65272
+ mergerLog.warn(`${taskId}: ${message}`);
65273
+ return {
65274
+ status: "conflict-needs-manual",
65275
+ stashSha: sha,
65276
+ conflictedFiles,
65277
+ message
65278
+ };
65279
+ }
65280
+ const stillConflicted = await findFilesWithConflictMarkers(rootDir, conflictedFiles);
65281
+ if (stillConflicted.length > 0) {
65282
+ const message = `AI agent reported success but conflict markers remain in: ${stillConflicted.join(", ")}. Stash ${sha.slice(0, 7)} left intact; recover manually.`;
65283
+ mergerLog.warn(`${taskId}: ${message}`);
65284
+ return {
65285
+ status: "conflict-needs-manual",
65286
+ stashSha: sha,
65287
+ conflictedFiles: stillConflicted,
65288
+ message
65289
+ };
65290
+ }
65291
+ mergerLog.log(
65292
+ `${taskId}: AI-resolved autostash conflict in ${conflictedFiles.length} file(s); dropping stash ${sha.slice(0, 7)}`
65293
+ );
65294
+ await ctx.store.logEntry(
65295
+ taskId,
65296
+ `Autostash conflict resolved by AI in ${conflictedFiles.length} file(s)`,
65297
+ conflictedFiles.join("\n")
65298
+ );
65299
+ await dropAutostashBySha(rootDir, taskId, sha);
65300
+ return {
65301
+ status: "ai-resolved",
65302
+ stashSha: sha,
65303
+ conflictedFiles
65304
+ };
64914
65305
  }
64915
65306
  async function generateAiMergeSummary(commitLog, diffStat, settings, rootDir) {
64916
65307
  try {
@@ -65679,6 +66070,7 @@ You are assisting with a paused \`git pull --rebase\`.
65679
66070
  taskId
65680
66071
  })
65681
66072
  });
66073
+ options?.onSession?.(session);
65682
66074
  const prompt = [
65683
66075
  `Resolve rebase conflicts for task ${taskId}.`,
65684
66076
  "",
@@ -65892,7 +66284,8 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
65892
66284
  if (mergeBlocker) {
65893
66285
  throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`);
65894
66286
  }
65895
- const autostashRef = await stashUnrelatedRootDirChanges(rootDir, taskId);
66287
+ const autostashHandle = await stashUnrelatedRootDirChanges(rootDir, taskId);
66288
+ let resultForFinally;
65896
66289
  try {
65897
66290
  const branch = task.branch || `fusion/${taskId.toLowerCase()}`;
65898
66291
  const sourceIssueRef = buildSourceIssueRef(task.sourceIssue);
@@ -65904,6 +66297,7 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
65904
66297
  worktreeRemoved: false,
65905
66298
  branchDeleted: false
65906
66299
  };
66300
+ resultForFinally = result;
65907
66301
  const mergeRunId = generateSyntheticRunId("merge", taskId);
65908
66302
  const engineRunContext = {
65909
66303
  runId: mergeRunId,
@@ -66858,7 +67252,8 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
66858
67252
  const pushResult = await pushToRemoteAfterMerge(store, rootDir, taskId, settings, {
66859
67253
  onAgentText: options.onAgentText,
66860
67254
  signal: options.signal,
66861
- runtimeHint: pushRuntimeHint
67255
+ runtimeHint: pushRuntimeHint,
67256
+ onSession: options.onSession
66862
67257
  });
66863
67258
  if (pushResult.pushed) {
66864
67259
  mergerLog.log(`${taskId}: pushed merged result to remote`);
@@ -66910,8 +67305,29 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
66910
67305
  await completeTask(store, taskId, result);
66911
67306
  return result;
66912
67307
  } finally {
66913
- if (autostashRef) {
66914
- await restoreUnrelatedRootDirChanges(rootDir, taskId, autostashRef);
67308
+ if (autostashHandle) {
67309
+ try {
67310
+ const settings = await store.getSettings();
67311
+ const outcome = await restoreUnrelatedRootDirChanges(
67312
+ rootDir,
67313
+ taskId,
67314
+ autostashHandle,
67315
+ { store, options, settings }
67316
+ );
67317
+ if (resultForFinally) {
67318
+ resultForFinally.autostash = outcome;
67319
+ }
67320
+ } catch (err) {
67321
+ const msg = err instanceof Error ? err.message : String(err);
67322
+ mergerLog.warn(`${taskId}: autostash restore threw unexpectedly (${msg}) \u2014 stash may be left in place; check git stash list`);
67323
+ if (resultForFinally) {
67324
+ resultForFinally.autostash = {
67325
+ status: "failed",
67326
+ stashSha: autostashHandle.sha,
67327
+ errorMessage: msg
67328
+ };
67329
+ }
67330
+ }
66915
67331
  }
66916
67332
  }
66917
67333
  }
@@ -67962,7 +68378,7 @@ async function completeTask(store, taskId, result) {
67962
68378
  result.task = task;
67963
68379
  store.emit("task:merged", result);
67964
68380
  }
67965
- var execAsync2, execFileAsync, LOCKFILE_PATTERNS, GENERATED_PATTERNS, DEPENDENCY_SYNC_TRIGGER_PATTERNS, WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS, PULL_REBASE_TIMEOUT_MS, PUSH_TIMEOUT_MS, MERGE_COMMIT_LOG_MAX_CHARS, MERGE_DIFF_STAT_MAX_CHARS, VerificationError, MergeAbortedError, VERIFICATION_EXTRA_ENV, FUSION_TASK_ID_TRAILER_KEY;
68381
+ var execAsync2, execFileAsync, LOCKFILE_PATTERNS, GENERATED_PATTERNS, DEPENDENCY_SYNC_TRIGGER_PATTERNS, WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS, PULL_REBASE_TIMEOUT_MS, PUSH_TIMEOUT_MS, MERGE_COMMIT_LOG_MAX_CHARS, MERGE_DIFF_STAT_MAX_CHARS, VerificationError, MergeAbortedError, VERIFICATION_EXTRA_ENV, AUTOSTASH_LABEL_PREFIX, FUSION_TASK_ID_TRAILER_KEY;
67966
68382
  var init_merger = __esm({
67967
68383
  "../engine/src/merger.ts"() {
67968
68384
  "use strict";
@@ -68044,6 +68460,7 @@ var init_merger = __esm({
68044
68460
  ["FUSION_TEST_WORKSPACE_CONCURRENCY", "4"]
68045
68461
  ].filter(([key]) => !(key in process.env))
68046
68462
  );
68463
+ AUTOSTASH_LABEL_PREFIX = "fusion-merger-autostash:";
68047
68464
  FUSION_TASK_ID_TRAILER_KEY = "Fusion-Task-Id";
68048
68465
  }
68049
68466
  });
@@ -69968,7 +70385,7 @@ function buildSourceIssueRef2(sourceIssue) {
69968
70385
  }
69969
70386
  return `${sourceIssue.repository}#${issueNumber}`;
69970
70387
  }
69971
- function buildExecutionPrompt(task, rootDir, settings, worktreePath) {
70388
+ function buildExecutionPrompt(task, rootDir, settings, worktreePath, pluginRunner) {
69972
70389
  const prompt = scopePromptToWorktree2(task.prompt, rootDir, worktreePath);
69973
70390
  const reviewMatch = prompt.match(/##\s*Review Level[:\s]*(\d)/);
69974
70391
  const reviewLevel = reviewMatch ? parseInt(reviewMatch[1], 10) : 0;
@@ -70041,6 +70458,11 @@ git log --oneline
70041
70458
  }
70042
70459
  steeringSection = lines.join("\n");
70043
70460
  }
70461
+ const taskPromptContributions = pluginRunner?.getPromptContributionsForSurface("executor-task") ?? [];
70462
+ if (taskPromptContributions.length > 0) {
70463
+ executorLog.log(`${task.id}: applied ${taskPromptContributions.length} plugin prompt contributions for executor-task surface`);
70464
+ }
70465
+ const pluginTaskContributions = buildPluginPromptSection("executor-task", pluginRunner);
70044
70466
  return `Execute this task.
70045
70467
 
70046
70468
  ## Task: ${task.id}
@@ -70059,6 +70481,10 @@ ${reviewLevel >= 1 ? `Before implementing each step (except Step 0 and the final
70059
70481
  ${reviewLevel >= 2 ? `After implementing + committing each step, call:
70060
70482
  \`fn_review_step(step=N, type="code", step_name="...", baseline="<SHA from before step>")\`` : ""}
70061
70483
  ${reviewLevel >= 3 ? `After tests, also call fn_review_step with type="code" for test review.` : ""}
70484
+ ${pluginTaskContributions ? `
70485
+
70486
+ ${pluginTaskContributions}
70487
+ ` : ""}
70062
70488
 
70063
70489
  ## Worktree Boundaries
70064
70490
 
@@ -71435,6 +71861,41 @@ The tool prevents your session from being killed by the inactivity watchdog duri
71435
71861
  return false;
71436
71862
  }
71437
71863
  }
71864
+ /**
71865
+ * Returns true when execute() should be deferred because the agent bound to
71866
+ * this task has an active heartbeat run and allowParallelExecution=false.
71867
+ *
71868
+ * Only applies to permanent (non-ephemeral) agents. Always returns false
71869
+ * when agentStore is unavailable or the agent cannot be resolved.
71870
+ */
71871
+ async shouldDeferForHeartbeat(agentId) {
71872
+ if (!this.options.agentStore) return false;
71873
+ const agent = await this.options.agentStore.getAgent(agentId).catch(() => null);
71874
+ if (!agent) return false;
71875
+ if (isEphemeralAgent(agent)) return false;
71876
+ const rc = agent.runtimeConfig ?? {};
71877
+ if (rc.allowParallelExecution !== false) return false;
71878
+ const activeRun = await this.options.agentStore.getActiveHeartbeatRun(agentId).catch(() => null);
71879
+ return activeRun !== null;
71880
+ }
71881
+ /**
71882
+ * Re-dispatch execute() for any unstarted in-progress task belonging to the
71883
+ * given agent. Called after a heartbeat run completes to unblock tasks that
71884
+ * were deferred by the allowParallelExecution=false gate.
71885
+ */
71886
+ async resumeTaskForAgent(agentId) {
71887
+ const settings = await this.store.getSettings();
71888
+ if (settings.globalPause || settings.enginePaused) return;
71889
+ const tasks = await this.store.listTasks({ slim: true, column: "in-progress" });
71890
+ for (const task of tasks) {
71891
+ if (task.assignedAgentId === agentId && !task.paused && !this.executing.has(task.id) && !this.activeSessions.has(task.id) && !this.activeStepExecutors.has(task.id) && !this.activeWorkflowStepSessions.has(task.id)) {
71892
+ executorLog.log(`${task.id}: re-dispatching execute() after heartbeat completion for agent ${agentId}`);
71893
+ this.execute(task).catch(
71894
+ (err) => executorLog.error(`Failed to resume ${task.id} after heartbeat completion:`, err)
71895
+ );
71896
+ }
71897
+ }
71898
+ }
71438
71899
  /**
71439
71900
  * Resume orphaned in-progress tasks (e.g., after crash/restart).
71440
71901
  * Call once after engine startup.
@@ -71526,28 +71987,6 @@ The tool prevents your session from being killed by the inactivity watchdog duri
71526
71987
  }
71527
71988
  return "";
71528
71989
  }
71529
- resolveDependencyWorktree(task, allTasks) {
71530
- if (task.dependencies.length === 0) return null;
71531
- for (const depId of task.dependencies) {
71532
- const dep = allTasks.find((t) => t.id === depId);
71533
- if (dep && dep.worktree && (dep.column === "done" || dep.column === "in-review") && existsSync29(dep.worktree)) {
71534
- return dep.worktree;
71535
- }
71536
- }
71537
- return null;
71538
- }
71539
- /**
71540
- * Reuse an existing worktree directory from a dependency task.
71541
- * Instead of creating a new worktree with `git worktree add`, this creates
71542
- * a new branch in the existing worktree via `git checkout -b`. The worktree
71543
- * directory (and its build caches) are preserved.
71544
- */
71545
- async reuseWorktree(branch, worktreePath) {
71546
- await execAsync5(`git checkout -b "${branch}"`, {
71547
- cwd: worktreePath
71548
- });
71549
- executorLog.log(`Reused worktree at ${worktreePath}, created branch ${branch}`);
71550
- }
71551
71990
  /**
71552
71991
  * Execute a task in an isolated git worktree.
71553
71992
  *
@@ -71561,6 +72000,11 @@ The tool prevents your session from being killed by the inactivity watchdog duri
71561
72000
  async execute(task) {
71562
72001
  executorLog.log(`execute() called for ${task.id} (already executing=${this.executing.has(task.id)})`);
71563
72002
  if (this.executing.has(task.id)) return;
72003
+ const assignedAgentId = task.assignedAgentId;
72004
+ if (assignedAgentId && await this.shouldDeferForHeartbeat(assignedAgentId)) {
72005
+ executorLog.log(`${task.id}: skipping execute \u2014 agent ${assignedAgentId} has active heartbeat run (allowParallelExecution=false)`);
72006
+ return;
72007
+ }
71564
72008
  this.executing.add(task.id);
71565
72009
  executorLog.log(`Starting ${task.id}: ${task.title || task.description.slice(0, 60)}`);
71566
72010
  const settings = await this.store.getSettings();
@@ -72150,9 +72594,9 @@ ${summary}`,
72150
72594
  const sessionRef = { current: null };
72151
72595
  const stepCheckpoints = /* @__PURE__ */ new Map();
72152
72596
  const stuckDetector = this.options.stuckTaskDetector;
72153
- const assignedAgentId = detail.assignedAgentId?.trim();
72154
- const reflectionTools = this.options.reflectionService && settings.reflectionEnabled && assignedAgentId ? [createReflectOnPerformanceTool(this.options.reflectionService, assignedAgentId)] : [];
72155
- const assignedAgent = assignedAgentId && this.options.agentStore ? await this.options.agentStore.getAgent(assignedAgentId).catch(() => null) : null;
72597
+ const assignedAgentId2 = detail.assignedAgentId?.trim();
72598
+ const reflectionTools = this.options.reflectionService && settings.reflectionEnabled && assignedAgentId2 ? [createReflectOnPerformanceTool(this.options.reflectionService, assignedAgentId2)] : [];
72599
+ const assignedAgent = assignedAgentId2 && this.options.agentStore ? await this.options.agentStore.getAgent(assignedAgentId2).catch(() => null) : null;
72156
72600
  const executorRuntimeHint = extractRuntimeHint(assignedAgent?.runtimeConfig);
72157
72601
  if (executionMode === "fast") {
72158
72602
  executorLog.log(`${task.id}: fast mode \u2014 fn_review_step tool not injected`);
@@ -72201,15 +72645,15 @@ ${summary}`,
72201
72645
  ...this.options.agentStore ? [
72202
72646
  createListAgentsTool(this.options.agentStore),
72203
72647
  createDelegateTaskTool(this.options.agentStore, this.store, { rootDir: this.rootDir }),
72204
- ...assignedAgentId ? [
72205
- createGetAgentConfigTool(this.options.agentStore, assignedAgentId),
72206
- createUpdateAgentConfigTool(this.options.agentStore, assignedAgentId)
72648
+ ...assignedAgentId2 ? [
72649
+ createGetAgentConfigTool(this.options.agentStore, assignedAgentId2),
72650
+ createUpdateAgentConfigTool(this.options.agentStore, assignedAgentId2)
72207
72651
  ] : []
72208
72652
  ] : [],
72209
72653
  // Messaging tools — allows executor agents to send and receive messages.
72210
- ...this.options.messageStore && assignedAgentId ? [
72211
- createSendMessageTool(this.options.messageStore, assignedAgentId),
72212
- createReadMessagesTool(this.options.messageStore, assignedAgentId)
72654
+ ...this.options.messageStore && assignedAgentId2 ? [
72655
+ createSendMessageTool(this.options.messageStore, assignedAgentId2),
72656
+ createReadMessagesTool(this.options.messageStore, assignedAgentId2)
72213
72657
  ] : [],
72214
72658
  // Add plugin tools from PluginRunner
72215
72659
  ...this.options.pluginRunner?.getPluginTools() ?? []
@@ -72247,12 +72691,23 @@ ${summary}`,
72247
72691
  getExecutorSystemPrompt(settings),
72248
72692
  executorInstructions
72249
72693
  );
72694
+ const executorSystemContributions = this.options.pluginRunner?.getPromptContributionsForSurface("executor-system") ?? [];
72695
+ if (executorSystemContributions.length > 0) {
72696
+ executorLog.log(`${task.id}: applied ${executorSystemContributions.length} plugin prompt contributions for executor-system surface`);
72697
+ }
72698
+ const executorPluginContributions = buildPluginPromptSection(
72699
+ "executor-system",
72700
+ this.options.pluginRunner
72701
+ );
72702
+ const executorSystemPromptFinal = executorPluginContributions ? `${executorSystemPrompt}
72703
+
72704
+ ${executorPluginContributions}` : executorSystemPrompt;
72250
72705
  let { session, sessionFile } = await createResolvedAgentSession({
72251
72706
  sessionPurpose: "executor",
72252
72707
  runtimeHint: executorRuntimeHint,
72253
72708
  pluginRunner: this.options.pluginRunner,
72254
72709
  cwd: worktreePath,
72255
- systemPrompt: executorSystemPrompt,
72710
+ systemPrompt: executorSystemPromptFinal,
72256
72711
  tools: "coding",
72257
72712
  customTools,
72258
72713
  onText: agentLogger.onText,
@@ -72313,7 +72768,13 @@ ${summary}`,
72313
72768
  "Review the current state of your worktree and proceed with the next pending step."
72314
72769
  ].join("\n"));
72315
72770
  } else {
72316
- const agentPrompt = buildExecutionPrompt(detail, this.rootDir, settings, worktreePath);
72771
+ const agentPrompt = buildExecutionPrompt(
72772
+ detail,
72773
+ this.rootDir,
72774
+ settings,
72775
+ worktreePath,
72776
+ this.options.pluginRunner
72777
+ );
72317
72778
  await promptWithFallback(session, agentPrompt);
72318
72779
  }
72319
72780
  checkSessionError(session);
@@ -72489,7 +72950,7 @@ ${summary}`,
72489
72950
  runtimeHint: executorRuntimeHint,
72490
72951
  pluginRunner: this.options.pluginRunner,
72491
72952
  cwd: worktreePath,
72492
- systemPrompt: executorSystemPrompt,
72953
+ systemPrompt: executorSystemPromptFinal,
72493
72954
  tools: "coding",
72494
72955
  customTools,
72495
72956
  onText: agentLogger.onText,
@@ -72539,7 +73000,7 @@ ${summary}`,
72539
73000
  "Do NOT ask for permission. Do NOT write a summary. Just call a tool and keep working.",
72540
73001
  "",
72541
73002
  "Original task:",
72542
- buildExecutionPrompt(detail, this.rootDir, settings, worktreePath)
73003
+ buildExecutionPrompt(detail, this.rootDir, settings, worktreePath, this.options.pluginRunner)
72543
73004
  ].join("\n");
72544
73005
  } else {
72545
73006
  retryPrompt = [
@@ -72549,7 +73010,7 @@ ${summary}`,
72549
73010
  "2. If there is remaining work, finish it and then call fn_task_done.",
72550
73011
  "",
72551
73012
  "Original task:",
72552
- buildExecutionPrompt(detail, this.rootDir, settings, worktreePath)
73013
+ buildExecutionPrompt(detail, this.rootDir, settings, worktreePath, this.options.pluginRunner)
72553
73014
  ].join("\n");
72554
73015
  }
72555
73016
  stuckDetector?.recordActivity(task.id);
@@ -73609,7 +74070,7 @@ ${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
73609
74070
  return reRunResult.allPassed;
73610
74071
  } finally {
73611
74072
  await logger2.flush();
73612
- await session.dispose();
74073
+ session.dispose();
73613
74074
  }
73614
74075
  } catch (err) {
73615
74076
  const errorMessage = err instanceof Error ? err.message : String(err);
@@ -74328,7 +74789,7 @@ Review the work done in this worktree and evaluate it against the criteria in yo
74328
74789
  * like `fusion/fn-2729`) used purely for log messages. `depTip` is the
74329
74790
  * resolved SHA of the dep's tip — that's what gets squash-merged.
74330
74791
  */
74331
- async planSquashImportFromDep(taskId, depTip, originalStartPoint) {
74792
+ async planSquashImportFromDep(_taskId, depTip, originalStartPoint) {
74332
74793
  let settings;
74333
74794
  try {
74334
74795
  settings = await this.store.getSettings();
@@ -75217,7 +75678,7 @@ Review the work done in this worktree and evaluate it against the criteria in yo
75217
75678
  this.childSessions.delete(childId);
75218
75679
  }
75219
75680
  try {
75220
- await this.options.agentStore?.updateAgentState(childId, "terminated");
75681
+ await this.options.agentStore?.updateAgentState(childId, "paused");
75221
75682
  } catch (err) {
75222
75683
  const msg = err instanceof Error ? err.message : String(err);
75223
75684
  executorLog.warn(`Failed to update spawned child ${childId} state to 'terminated' during cleanup: ${msg}`);
@@ -78260,9 +78721,6 @@ function taskRelevanceScore(agent, task) {
78260
78721
  }
78261
78722
  return score;
78262
78723
  }
78263
- function isBlockedStateDuplicate(current, previous) {
78264
- return current.blockedBy === previous.blockedBy && current.contextHash === previous.contextHash;
78265
- }
78266
78724
  function truncatePrompt(text, maxChars) {
78267
78725
  if (text.length <= maxChars) return text;
78268
78726
  return `${text.slice(0, maxChars)}
@@ -78322,28 +78780,42 @@ var init_agent_heartbeat = __esm({
78322
78780
 
78323
78781
  ## Your Role
78324
78782
 
78325
- You are a lightweight periodic checker in the broader Fusion system, not the primary implementation agent.
78326
- Your purpose is to keep momentum: detect issues early, surface blockers, and route work to the right place.
78327
- Think in single-pass interventions, not long coding sessions.
78783
+ This is an ambient heartbeat. Task implementation work (coding, running tests, making commits) runs in a separate
78784
+ execution path handled by the executor. Do NOT do task body work or implementation in this heartbeat.
78785
+
78786
+ Your purpose is to keep momentum through coordination: surface blockers, respond to messages, manage memory,
78787
+ delegate, and route work to the right place. Think in single-pass interventions, not coding sessions.
78328
78788
 
78329
78789
  Your job:
78330
- 1. Check your assigned task \u2014 read the description and PROMPT.md if present.
78331
- 2. Do ONE useful action that changes project clarity or flow.
78790
+ 1. Check your assigned task context \u2014 review its state, blockedBy field, and any new comments.
78791
+ 2. Do ONE useful coordination action.
78332
78792
  3. Use fn_task_create to spawn follow-up work, fn_task_log to record observations, and fn_task_document_write for durable artifacts.
78333
78793
  4. Use fn_list_agents + fn_delegate_task when work should be assigned to a specific capable agent now.
78334
78794
  5. Use fn_get_agent_config and fn_update_agent_config to tune direct reports before delegating recurring work.
78335
- 5. Call fn_heartbeat_done when finished with an optional summary of what was accomplished.
78795
+ 6. Call fn_heartbeat_done when finished with an optional summary of what was accomplished.
78336
78796
 
78337
- Examples of ONE useful action:
78338
- - DO: summarize a blocker in fn_task_log with concrete next step(s).
78797
+ **If your bound task is blocked** (blockedBy is set in the task context):
78798
+ - Surface the blocker concretely with fn_task_log.
78799
+ - Chase the dependency: comment on the blocking task, send a message to the responsible agent, or ping an owner.
78800
+ - Look for unblocking work you can spawn or delegate right now.
78801
+ - Pivot to other relevant coordination work if the blocker cannot be immediately resolved.
78802
+
78803
+ **If your bound task is not blocked:**
78804
+ - Surface progress, status, or coordination needs with fn_task_log or fn_task_document_write.
78805
+ - Create follow-up tasks for discovered risks or gaps.
78806
+ - Respond to new steering comments or user messages.
78807
+
78808
+ Examples of ONE useful coordination action:
78809
+ - DO: log a concrete blocker with next steps and message the agent responsible for unblocking.
78339
78810
  - DO: create a focused follow-up task when a missing dependency is discovered.
78340
78811
  - DO: delegate a well-scoped task to an appropriate idle specialist agent.
78341
78812
  - DO: save a short investigation note with fn_task_document_write when the analysis is reusable.
78342
- - DON'T: attempt full implementation, broad refactors, or multi-hour coding.
78813
+ - DON'T: attempt full implementation, run tests, commit code, or do multi-step coding work.
78343
78814
  - DON'T: create vague tasks like "investigate stuff" without actionable scope.
78344
78815
 
78345
- Keep work lightweight \u2014 this is a single-pass check, not a full implementation run.
78346
- You have coding-capable workspace tools (read/write/edit/bash within worktree boundaries) plus fn_task_create, fn_task_log, and fn_task_document tools.
78816
+ Keep work lightweight \u2014 this is a single-pass coordination check, not an implementation run.
78817
+ You have workspace read tools (for context gathering) plus fn_task_create, fn_task_log, fn_task_document tools,
78818
+ fn_send_message, fn_read_messages, fn_list_agents, fn_delegate_task, and memory tools.
78347
78819
 
78348
78820
  **Task Documents:** Save important findings with fn_task_document_write(key="...", content="...").
78349
78821
  Documents persist across sessions and are visible in the dashboard's Documents tab.
@@ -78362,7 +78834,8 @@ Prefer fn_delegate_task when immediate ownership by a specific agent materially
78362
78834
 
78363
78835
  ## Common Patterns
78364
78836
 
78365
- - **Stuck task:** log the concrete blocker, create a narrowly scoped unblocker task if needed, and optionally message the responsible agent.
78837
+ - **Blocked task:** log the concrete blocker, chase the dependency via fn_send_message, create a narrowly scoped unblocker task if needed.
78838
+ - **Stuck task with no blockedBy:** log the observation and create a follow-up task to investigate the root cause.
78366
78839
  - **Completed task with follow-up risk:** create explicit follow-up task(s) for residual risk instead of burying notes in a long log.
78367
78840
  - **New user/agent comments:** summarize what changed, identify required action, and route via task creation/delegation.
78368
78841
  - **Dependency drift:** log the mismatch and create reconciliation tasks with clear dependencies.
@@ -78433,7 +78906,7 @@ You have coding-capable workspace tools (read/write/edit/bash within worktree bo
78433
78906
  Use this decision rule:
78434
78907
  - **fn_task_create:** create executable work when ownership is not predetermined.
78435
78908
  - **fn_delegate_task:** assign immediately when a specific agent should own the work now.
78436
- - **fn_memory_append:** persist durable conventions/pitfalls; avoid transient run-by-run chatter.
78909
+ - **fn_memory_append:** use \`scope="agent"\` for your own operating context and \`scope="project"\` for repo-wide durable knowledge; avoid transient run-by-run chatter.
78437
78910
 
78438
78911
  If unsure who should do the work, prefer fn_task_create and let scheduler routing happen naturally.
78439
78912
 
@@ -78581,10 +79054,71 @@ not loop on the same plan across heartbeats without recording why.`;
78581
79054
  if (this.messageStore) {
78582
79055
  this.messageStore.setMessageToAgentHook(this.handleMessageToAgent.bind(this));
78583
79056
  }
79057
+ void this.reconcileOrphanedRunningAgents();
78584
79058
  this.pollInterval = setInterval(() => {
78585
79059
  void this.checkMissedHeartbeats();
78586
79060
  }, this.pollIntervalMs);
78587
79061
  }
79062
+ /**
79063
+ * Find agents in `state="running"` that are not actually running and flip
79064
+ * them to `"active"`. An agent is considered orphaned when either:
79065
+ * (a) it has no active heartbeat run record, or
79066
+ * (b) it is not in this monitor's in-memory tracked set AND its
79067
+ * lastHeartbeatAt is older than 3× the configured timeout.
79068
+ *
79069
+ * Case (a) covers historical bypass paths (governance-skip, supersede-on-
79070
+ * startRun, safety-net run termination) that ended the run record but
79071
+ * never propagated the agent-state transition. Case (b) covers a process
79072
+ * that crashed mid-run, leaving both the run row and the agent row stuck.
79073
+ *
79074
+ * Called on monitor start AND periodically from the polling loop to keep
79075
+ * the system self-healing across versions. Best-effort — failures are
79076
+ * logged but do not block the caller.
79077
+ */
79078
+ async reconcileOrphanedRunningAgents() {
79079
+ try {
79080
+ const runningAgents = await this.store.listAgents({ state: "running", includeEphemeral: true });
79081
+ const now = Date.now();
79082
+ for (const agent of runningAgents) {
79083
+ let reason = null;
79084
+ const activeRun = await this.store.getActiveHeartbeatRun(agent.id);
79085
+ if (!activeRun) {
79086
+ reason = "no active run";
79087
+ } else if (!this.trackedAgents.has(agent.id)) {
79088
+ const timeoutMs = this.resolveAgentConfig(agent.id).heartbeatTimeoutMs;
79089
+ const lastTs = agent.lastHeartbeatAt ? Date.parse(agent.lastHeartbeatAt) : NaN;
79090
+ const heartbeatAgeMs = Number.isFinite(lastTs) ? Math.max(0, now - lastTs) : Infinity;
79091
+ if (heartbeatAgeMs > timeoutMs * 3) {
79092
+ try {
79093
+ const detail = await this.store.getRunDetail(agent.id, activeRun.id);
79094
+ if (detail && detail.status !== "completed" && detail.status !== "failed" && detail.status !== "terminated") {
79095
+ await this.store.saveRun({
79096
+ ...detail,
79097
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
79098
+ status: "terminated",
79099
+ stderrExcerpt: `Reconciled stale run (no heartbeat for ${formatDuration(heartbeatAgeMs)}; threshold ${formatDuration(timeoutMs * 3)})`
79100
+ });
79101
+ }
79102
+ await this.store.endHeartbeatRun(activeRun.id, "terminated");
79103
+ } catch (runEndErr) {
79104
+ heartbeatLog.warn(`Failed to terminate stale run ${activeRun.id} for ${agent.id}: ${runEndErr instanceof Error ? runEndErr.message : String(runEndErr)}`);
79105
+ }
79106
+ reason = `stale heartbeat (${formatDuration(heartbeatAgeMs)} since lastHeartbeatAt)`;
79107
+ }
79108
+ }
79109
+ if (!reason) continue;
79110
+ try {
79111
+ await this.store.updateAgentState(agent.id, "active");
79112
+ this.clearRunState(agent.id);
79113
+ heartbeatLog.log(`Reconciled orphaned running agent ${agent.id} \u2192 active (${reason})`);
79114
+ } catch (err) {
79115
+ heartbeatLog.warn(`Failed to reconcile orphaned running agent ${agent.id}: ${err instanceof Error ? err.message : String(err)}`);
79116
+ }
79117
+ }
79118
+ } catch (err) {
79119
+ heartbeatLog.warn(`reconcileOrphanedRunningAgents scan failed: ${err instanceof Error ? err.message : String(err)}`);
79120
+ }
79121
+ }
78588
79122
  /**
78589
79123
  * Stop the heartbeat monitoring loop.
78590
79124
  * Does not untrack agents - they remain in memory.
@@ -78772,7 +79306,7 @@ not loop on the same plan across heartbeats without recording why.`;
78772
79306
  await this.store.updateAgentState(agentId, "error");
78773
79307
  await this.store.updateAgent(agentId, { lastError: completionResult.stderrExcerpt ?? "Run failed" });
78774
79308
  } else if (completionResult.status === "terminated") {
78775
- await this.store.updateAgentState(agentId, "terminated");
79309
+ await this.store.updateAgentState(agentId, "paused");
78776
79310
  } else {
78777
79311
  await this.store.updateAgentState(agentId, "active");
78778
79312
  }
@@ -78781,6 +79315,9 @@ not loop on the same plan across heartbeats without recording why.`;
78781
79315
  }
78782
79316
  }
78783
79317
  await this.store.endHeartbeatRun(runId, completionResult.status === "completed" ? "completed" : "terminated");
79318
+ if (completionResult.status === "terminated") {
79319
+ this.onTerminated?.(agentId, completionResult.stderrExcerpt ?? "Run terminated");
79320
+ }
78784
79321
  this.onRunCompleted?.(agentId, completedRun);
78785
79322
  }
78786
79323
  /**
@@ -79306,45 +79843,8 @@ not loop on the same plan across heartbeats without recording why.`;
79306
79843
  });
79307
79844
  return await this.store.getRunDetail(agentId, run.id);
79308
79845
  }
79309
- const blockedBy = typeof liveTaskDetail.blockedBy === "string" ? liveTaskDetail.blockedBy.trim() : "";
79310
- const isBlockedTask = liveTaskDetail.status === "queued" && blockedBy.length > 0;
79311
- if (isBlockedTask) {
79312
- const commentCount = (liveTaskDetail.comments?.length ?? 0) + (liveTaskDetail.steeringComments?.length ?? 0);
79313
- const lastCommentId = liveTaskDetail.comments?.at(-1)?.id;
79314
- const lastSteeringCommentId = liveTaskDetail.steeringComments?.at(-1)?.id;
79315
- const contextHash = Buffer.from(
79316
- JSON.stringify({ commentCount, lastCommentId, lastSteeringCommentId, blockedBy })
79317
- ).toString("base64").slice(0, 16);
79318
- const currentBlockedState = {
79319
- taskId: resolvedTaskId2,
79320
- blockedBy,
79321
- recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
79322
- contextHash
79323
- };
79324
- const previousBlockedState = await this.store.getLastBlockedState(agentId);
79325
- if (previousBlockedState && isBlockedStateDuplicate(currentBlockedState, previousBlockedState)) {
79326
- await this.completeRun(agentId, run.id, {
79327
- status: "completed",
79328
- resultJson: { reason: "blocked_duplicate", taskId: resolvedTaskId2, blockedBy }
79329
- });
79330
- return await this.store.getRunDetail(agentId, run.id);
79331
- }
79332
- const blockedMessage = `Task is blocked by ${blockedBy}; waiting for dependency/context changes before retrying.`;
79333
- await taskStore.addComment(resolvedTaskId2, blockedMessage, "agent", void 0, runContext);
79334
- await audit.database({ type: "task:comment:add", target: resolvedTaskId2, metadata: { blockedBy } });
79335
- await this.store.setLastBlockedState(agentId, currentBlockedState);
79336
- heartbeatLog.log(`Task ${resolvedTaskId2} is blocked by ${blockedBy} \u2014 recorded blocked state`);
79337
- await this.completeRun(agentId, run.id, {
79338
- status: "completed",
79339
- resultJson: { reason: "blocked", taskId: resolvedTaskId2, blockedBy }
79340
- });
79341
- return await this.store.getRunDetail(agentId, run.id);
79342
- }
79343
79846
  }
79344
79847
  }
79345
- if (!isNoTaskRun) {
79346
- await this.store.clearLastBlockedState(agentId);
79347
- }
79348
79848
  const STDOUT_EXCERPT_LIMIT = 4e3;
79349
79849
  let outputLength = 0;
79350
79850
  let toolCallCount = 0;
@@ -79450,6 +79950,17 @@ not loop on the same plan across heartbeats without recording why.`;
79450
79950
  baseHeartbeatSystemPrompt,
79451
79951
  [resolvedInstructionsForIdentity, memoryInstructions, selfImprovePrompt].filter((part) => part.trim()).join("\n\n")
79452
79952
  );
79953
+ const heartbeatContributions = this.pluginRunner?.getPromptContributionsForSurface("heartbeat") ?? [];
79954
+ if (heartbeatContributions.length > 0) {
79955
+ heartbeatLog.log(`applied ${heartbeatContributions.length} plugin prompt contributions for heartbeat surface`);
79956
+ }
79957
+ const heartbeatPluginContributions = buildPluginPromptSection(
79958
+ "heartbeat",
79959
+ this.pluginRunner
79960
+ );
79961
+ const systemPromptFinal = heartbeatPluginContributions ? `${systemPrompt}
79962
+
79963
+ ${heartbeatPluginContributions}` : systemPrompt;
79453
79964
  heartbeatTools.push(heartbeatDoneTool);
79454
79965
  if (isNoTaskRun) {
79455
79966
  agentLogger = new AgentLogger({
@@ -79471,7 +79982,7 @@ not loop on the same plan across heartbeats without recording why.`;
79471
79982
  runtimeHint: extractRuntimeHint2(agent.runtimeConfig),
79472
79983
  pluginRunner: this.pluginRunner,
79473
79984
  cwd: rootDir,
79474
- systemPrompt,
79985
+ systemPrompt: systemPromptFinal,
79475
79986
  tools: "coding",
79476
79987
  customTools: heartbeatTools,
79477
79988
  ...(() => {
@@ -79674,7 +80185,7 @@ ${taskDetail.prompt}` : "No PROMPT.md available.",
79674
80185
  try {
79675
80186
  const runWithPrompts = {
79676
80187
  ...run,
79677
- systemPrompt: truncatePrompt(systemPrompt, 1e5),
80188
+ systemPrompt: truncatePrompt(systemPromptFinal, 1e5),
79678
80189
  executionPrompt: truncatePrompt(executionPrompt, 1e5),
79679
80190
  heartbeatProcedureSource: customProcedure ? "custom" : "default"
79680
80191
  };
@@ -79821,8 +80332,6 @@ ${taskDetail.prompt}` : "No PROMPT.md available.",
79821
80332
  let health = "healthy";
79822
80333
  if (report.state === "paused") {
79823
80334
  health = report.pauseReason ? `paused (${report.pauseReason})` : "paused";
79824
- } else if (report.state === "terminated") {
79825
- health = "terminated";
79826
80335
  } else if (report.state === "error") {
79827
80336
  health = "**stuck**";
79828
80337
  } else if (report.state === "running") {
@@ -79837,7 +80346,6 @@ ${taskDetail.prompt}` : "No PROMPT.md available.",
79837
80346
  });
79838
80347
  const hasStuck = rows.some((row) => row.includes("**stuck**"));
79839
80348
  const hasStale = rows.some((row) => row.includes("**stale**"));
79840
- const hasTerminated = rows.some((row) => row.includes("terminated"));
79841
80349
  const actionLines = ["### Actions for Unresponsive Reports"];
79842
80350
  if (hasStuck) {
79843
80351
  actionLines.push("- For **stuck** reports: consider sending a message via fn_send_message asking for status, or reassigning their task via fn_delegate_task to a healthy agent.");
@@ -79845,9 +80353,6 @@ ${taskDetail.prompt}` : "No PROMPT.md available.",
79845
80353
  if (hasStale) {
79846
80354
  actionLines.push("- For **stale** reports: the agent may have lost its heartbeat trigger \u2014 create a follow-up task to investigate.");
79847
80355
  }
79848
- if (hasTerminated) {
79849
- actionLines.push("- For **terminated** reports: if they had active work, reassign their tasks or spawn replacement agents.");
79850
- }
79851
80356
  return [
79852
80357
  "## Reports Health Check",
79853
80358
  "",
@@ -80003,6 +80508,7 @@ ${taskDetail.prompt}` : "No PROMPT.md available.",
80003
80508
  }
80004
80509
  }
80005
80510
  }
80511
+ await this.reconcileOrphanedRunningAgents();
80006
80512
  }
80007
80513
  async handleMissedHeartbeat(tracked, reason) {
80008
80514
  await this.store.recordHeartbeat(tracked.agentId, "missed", tracked.runId);
@@ -80013,12 +80519,21 @@ ${taskDetail.prompt}` : "No PROMPT.md available.",
80013
80519
  const elapsed = now - tracked.lastSeen;
80014
80520
  const reason = `No heartbeat for ${formatDuration(elapsed)} (2\xD7 timeout threshold: ${formatDuration(heartbeatTimeoutMs * 2)})`;
80015
80521
  heartbeatLog.warn(`Recovering unresponsive agent ${tracked.agentId}: ${reason}`);
80522
+ const runIdToTerminate = tracked.runId;
80016
80523
  try {
80017
80524
  tracked.session.dispose();
80018
80525
  } catch (err) {
80019
80526
  heartbeatLog.warn(`Error disposing session for ${tracked.agentId}: ${err instanceof Error ? err.message : String(err)}`);
80020
80527
  }
80021
80528
  this.untrackAgent(tracked.agentId);
80529
+ try {
80530
+ await this.completeRun(tracked.agentId, runIdToTerminate, {
80531
+ status: "terminated",
80532
+ stderrExcerpt: reason
80533
+ });
80534
+ } catch (err) {
80535
+ heartbeatLog.warn(`completeRun(terminated) failed for ${tracked.agentId}/${runIdToTerminate}: ${err instanceof Error ? err.message : String(err)}`);
80536
+ }
80022
80537
  try {
80023
80538
  await this.pauseAgent(tracked.agentId, { pauseReason: "heartbeat-unresponsive", stopActiveRun: false });
80024
80539
  } catch (err) {
@@ -80048,10 +80563,12 @@ ${taskDetail.prompt}` : "No PROMPT.md available.",
80048
80563
  updatedListener = null;
80049
80564
  configRevisionListener = null;
80050
80565
  deletedListener = null;
80051
- constructor(store, callback, taskStore) {
80566
+ isTaskExecuting;
80567
+ constructor(store, callback, taskStore, options) {
80052
80568
  this.store = store;
80053
80569
  this.callback = callback;
80054
80570
  this.taskStore = taskStore;
80571
+ this.isTaskExecuting = options?.isTaskExecuting;
80055
80572
  }
80056
80573
  /**
80057
80574
  * Start the scheduler. Enables assignment watching.
@@ -80263,6 +80780,10 @@ ${taskDetail.prompt}` : "No PROMPT.md available.",
80263
80780
  heartbeatLog.log(`Assignment trigger skipped for ${agent.id} (active run)`);
80264
80781
  return;
80265
80782
  }
80783
+ if (runtimeConfig.allowParallelExecution === false && this.isTaskExecuting?.(taskId)) {
80784
+ heartbeatLog.log(`Assignment tick skipped for ${agent.id} (parallel execution disabled, task ${taskId} executing)`);
80785
+ return;
80786
+ }
80266
80787
  let budgetStatus;
80267
80788
  try {
80268
80789
  budgetStatus = await this.store.getBudgetStatus(agent.id);
@@ -80433,6 +80954,11 @@ ${taskDetail.prompt}` : "No PROMPT.md available.",
80433
80954
  heartbeatLog.log(`Timer tick skipped for ${agentId} (active run)`);
80434
80955
  return;
80435
80956
  }
80957
+ const timerRc = agent.runtimeConfig ?? {};
80958
+ if (timerRc.allowParallelExecution === false && agent.taskId && this.isTaskExecuting?.(agent.taskId)) {
80959
+ heartbeatLog.log(`Timer tick skipped for ${agentId} (parallel execution disabled, task ${agent.taskId} executing)`);
80960
+ return;
80961
+ }
80436
80962
  if (this.taskStore) {
80437
80963
  const settings = await this.taskStore.getSettings();
80438
80964
  if (settings.globalPause) {
@@ -82090,6 +82616,7 @@ var init_stuck_task_detector = __esm({
82090
82616
  onStuck;
82091
82617
  beforeRequeue;
82092
82618
  onLoopDetected;
82619
+ paused = false;
82093
82620
  /**
82094
82621
  * Start the polling loop that checks for stuck tasks.
82095
82622
  * Safe to call multiple times (no-ops if already running).
@@ -82315,6 +82842,29 @@ var init_stuck_task_detector = __esm({
82315
82842
  }
82316
82843
  this.tracked.delete(taskId);
82317
82844
  }
82845
+ /**
82846
+ * Pause stuck detection checks while the engine is in a paused lifecycle.
82847
+ * Active tracked sessions are preserved and refreshed on resume.
82848
+ */
82849
+ pause() {
82850
+ if (this.paused) return;
82851
+ this.paused = true;
82852
+ }
82853
+ /**
82854
+ * Resume stuck detection checks and refresh tracked timestamps so the paused
82855
+ * interval does not count as inactivity/no-progress time.
82856
+ */
82857
+ resume() {
82858
+ if (!this.paused) return;
82859
+ this.paused = false;
82860
+ if (this.tracked.size === 0) return;
82861
+ const now = Date.now();
82862
+ for (const entry of this.tracked.values()) {
82863
+ entry.lastActivity = now;
82864
+ entry.lastProgressAt = now;
82865
+ entry.activitySinceProgress = 0;
82866
+ }
82867
+ }
82318
82868
  /**
82319
82869
  * Check for stuck tasks immediately, outside the normal polling cycle.
82320
82870
  * Safe to call at any time — will no-op if no tasks are tracked or timeout is disabled.
@@ -82336,6 +82886,7 @@ var init_stuck_task_detector = __esm({
82336
82886
  */
82337
82887
  async checkStuckTasks() {
82338
82888
  if (this.tracked.size === 0) return;
82889
+ if (this.paused) return;
82339
82890
  let settings;
82340
82891
  try {
82341
82892
  settings = await this.store.getSettings();
@@ -84751,6 +85302,282 @@ var init_plugin_runner = __esm({
84751
85302
  }
84752
85303
  });
84753
85304
 
85305
+ // ../engine/src/ephemeral-worker-manager.ts
85306
+ var TERMINAL_TASK_COLUMNS, EphemeralWorkerManager;
85307
+ var init_ephemeral_worker_manager = __esm({
85308
+ "../engine/src/ephemeral-worker-manager.ts"() {
85309
+ "use strict";
85310
+ init_src();
85311
+ TERMINAL_TASK_COLUMNS = /* @__PURE__ */ new Set(["done", "archived"]);
85312
+ EphemeralWorkerManager = class {
85313
+ agentStore;
85314
+ taskStore;
85315
+ log;
85316
+ isDeletionPendingExternal;
85317
+ /** taskId → owner. In-memory only; on-disk fallback covers restart gaps. */
85318
+ taskAgentMap = /* @__PURE__ */ new Map();
85319
+ /** agentIds with in-flight delete; prevents racing parallel cleanup paths. */
85320
+ pendingDeletions = /* @__PURE__ */ new Set();
85321
+ stateChangeListener;
85322
+ constructor(options) {
85323
+ this.agentStore = options.agentStore;
85324
+ this.taskStore = options.taskStore;
85325
+ this.log = options.logger;
85326
+ this.isDeletionPendingExternal = options.isDeletionPendingExternal ?? (() => false);
85327
+ }
85328
+ // ── public surface ───────────────────────────────────────────────────────
85329
+ /**
85330
+ * Establish ownership for a task that just started executing.
85331
+ * - If the task carries an `assignedAgentId` pointing at a durable agent,
85332
+ * bind that agent to the task and flip it through active → running.
85333
+ * - Otherwise spawn (or reclaim) an ephemeral `executor-${task.id}` worker.
85334
+ *
85335
+ * Cross-restart safe: looks up an existing ephemeral by name before
85336
+ * creating a new one.
85337
+ */
85338
+ async onTaskStart(task) {
85339
+ try {
85340
+ const assignedAgentId = task.assignedAgentId;
85341
+ if (assignedAgentId) {
85342
+ const assignedAgent = await this.agentStore.getAgent(assignedAgentId);
85343
+ if (assignedAgent && !isEphemeralAgent(assignedAgent)) {
85344
+ this.taskAgentMap.set(task.id, { agentId: assignedAgent.id, ephemeral: false });
85345
+ await this.agentStore.syncExecutionTaskLink(assignedAgent.id, task.id);
85346
+ const currentState = assignedAgent.state;
85347
+ if (currentState !== "running") {
85348
+ if (currentState !== "active") {
85349
+ await this.agentStore.updateAgentState(assignedAgent.id, "active");
85350
+ }
85351
+ await this.agentStore.updateAgentState(assignedAgent.id, "running");
85352
+ }
85353
+ return { agentId: assignedAgent.id, ephemeral: false };
85354
+ }
85355
+ }
85356
+ const cached = this.taskAgentMap.get(task.id);
85357
+ if (cached) {
85358
+ this.log.warn(`Skipping task-worker creation for ${task.id}: task already has execution owner`);
85359
+ return cached;
85360
+ }
85361
+ const existing = await this.lookupExistingByName(`executor-${task.id}`);
85362
+ if (existing) {
85363
+ if (existing.taskId === task.id) {
85364
+ this.taskAgentMap.set(task.id, { agentId: existing.id, ephemeral: true });
85365
+ this.log.log(`Reusing existing ephemeral worker ${existing.id} for task ${task.id} after restart`);
85366
+ return { agentId: existing.id, ephemeral: true };
85367
+ }
85368
+ try {
85369
+ await this.agentStore.deleteAgent(existing.id);
85370
+ this.log.log(`Deleted stale ephemeral worker ${existing.id} for task ${task.id} before respawn`);
85371
+ } catch (delErr) {
85372
+ this.log.warn(`Failed to delete stale ephemeral worker ${existing.id} for ${task.id}:`, delErr);
85373
+ }
85374
+ }
85375
+ const agent = await this.agentStore.createAgent({
85376
+ name: `executor-${task.id}`,
85377
+ role: "executor",
85378
+ metadata: {
85379
+ agentKind: "task-worker",
85380
+ taskWorker: true,
85381
+ managedBy: "task-executor"
85382
+ },
85383
+ runtimeConfig: { enabled: false }
85384
+ });
85385
+ this.taskAgentMap.set(task.id, { agentId: agent.id, ephemeral: true });
85386
+ await this.agentStore.assignTask(agent.id, task.id);
85387
+ await this.agentStore.updateAgentState(agent.id, "active");
85388
+ await this.agentStore.updateAgentState(agent.id, "running");
85389
+ return { agentId: agent.id, ephemeral: true };
85390
+ } catch (err) {
85391
+ this.log.warn(`Failed to initialize execution owner for task ${task.id}:`, err);
85392
+ return null;
85393
+ }
85394
+ }
85395
+ /**
85396
+ * Tear down ownership after a task completes or errors.
85397
+ * Final state for durable agents matches the outcome (idle/error).
85398
+ * Ephemeral workers are deleted regardless; if the in-memory owner is
85399
+ * missing (e.g. restart between onStart and this callback), falls back
85400
+ * to a name-based lookup so the worker still gets cleaned up.
85401
+ */
85402
+ async onTaskComplete(taskId) {
85403
+ return this.finalize(taskId, "active", "completion");
85404
+ }
85405
+ async onTaskError(taskId) {
85406
+ return this.finalize(taskId, "error", "error");
85407
+ }
85408
+ /**
85409
+ * Listener for agent:stateChanged. Cleans up ephemerals that get halted
85410
+ * out-of-band — e.g. by HeartbeatMonitor flipping them to paused/error
85411
+ * outside the onComplete/onError callbacks.
85412
+ *
85413
+ * Returns the listener fn so the caller can detach it on shutdown.
85414
+ */
85415
+ attachStateChangeListener() {
85416
+ if (this.stateChangeListener) return this.stateChangeListener;
85417
+ const listener = (agentId, from, to) => {
85418
+ if (to !== "paused" && to !== "error") return;
85419
+ if (from === to) return;
85420
+ if (this.pendingDeletions.has(agentId) || this.isDeletionPendingExternal(agentId)) return;
85421
+ void (async () => {
85422
+ try {
85423
+ const agent = await this.agentStore.getAgent(agentId);
85424
+ if (!agent) return;
85425
+ const isWorkerLike = isEphemeralAgent(agent) || agent.metadata?.taskWorker === true || agent.metadata?.agentKind === "task-worker" || agent.metadata?.agentKind === "spawned";
85426
+ if (!isWorkerLike) return;
85427
+ await this.deleteEphemeralAgent(agentId, "halt-listener");
85428
+ } catch (err) {
85429
+ this.log.warn(`Failed to process halt event for agent ${agentId}: ${this.formatError(err)}`);
85430
+ }
85431
+ })();
85432
+ };
85433
+ this.stateChangeListener = listener;
85434
+ this.agentStore.on("agent:stateChanged", listener);
85435
+ return listener;
85436
+ }
85437
+ detachStateChangeListener() {
85438
+ if (!this.stateChangeListener) return;
85439
+ this.agentStore.off("agent:stateChanged", this.stateChangeListener);
85440
+ this.stateChangeListener = void 0;
85441
+ }
85442
+ /**
85443
+ * Startup sweep. Returns the count of zombies cleaned up. Best-effort —
85444
+ * failures are logged and skipped so they never block runtime startup.
85445
+ *
85446
+ * Survivors after this pass: agents bound to a still-in-progress task.
85447
+ * Anything else (no taskId, terminal task column, or halted state) is
85448
+ * by definition a leak.
85449
+ */
85450
+ async reconcileOrphaned() {
85451
+ let cleanedCount = 0;
85452
+ try {
85453
+ const allAgents = await this.agentStore.listAgents({ includeEphemeral: true });
85454
+ for (const agent of allAgents) {
85455
+ if (!isEphemeralAgent(agent)) continue;
85456
+ if (!await this.shouldDeleteOnSweep(agent)) continue;
85457
+ try {
85458
+ await this.agentStore.deleteAgent(agent.id);
85459
+ cleanedCount += 1;
85460
+ } catch (err) {
85461
+ if (this.isBenignDeleteRace(agent.id, err)) {
85462
+ cleanedCount += 1;
85463
+ continue;
85464
+ }
85465
+ this.log.warn(`Startup sweep failed to delete ephemeral agent ${agent.id}: ${this.formatError(err)}`);
85466
+ }
85467
+ }
85468
+ } catch (err) {
85469
+ this.log.warn(`Startup ephemeral sweep failed: ${this.formatError(err)}`);
85470
+ }
85471
+ if (cleanedCount > 0) {
85472
+ this.log.log(`Startup ephemeral sweep cleaned ${cleanedCount} orphaned agent(s)`);
85473
+ }
85474
+ return cleanedCount;
85475
+ }
85476
+ /** Drop in-memory state. Call on runtime stop. */
85477
+ reset() {
85478
+ this.taskAgentMap.clear();
85479
+ this.pendingDeletions.clear();
85480
+ }
85481
+ /** True if a delete is in flight; lets external callers avoid double-delete races. */
85482
+ isDeletionPending(agentId) {
85483
+ return this.pendingDeletions.has(agentId);
85484
+ }
85485
+ getOwner(taskId) {
85486
+ return this.taskAgentMap.get(taskId);
85487
+ }
85488
+ // ── internals ────────────────────────────────────────────────────────────
85489
+ async finalize(taskId, terminalState, reason) {
85490
+ const owner = this.taskAgentMap.get(taskId) ?? await this.recoverOwnerFromDisk(taskId);
85491
+ if (!owner) return;
85492
+ const { agentId, ephemeral } = owner;
85493
+ if (ephemeral) {
85494
+ this.pendingDeletions.add(agentId);
85495
+ }
85496
+ try {
85497
+ await this.agentStore.updateAgentState(agentId, terminalState);
85498
+ } catch (err) {
85499
+ this.log.warn(`Failed to update agent ${agentId} to ${terminalState} (${reason}): ${this.formatError(err)}`);
85500
+ }
85501
+ try {
85502
+ await this.agentStore.syncExecutionTaskLink(agentId, void 0);
85503
+ } catch (err) {
85504
+ this.log.warn(`Failed to clear execution task link for agent ${agentId} on ${reason}: ${this.formatError(err)}`);
85505
+ }
85506
+ this.taskAgentMap.delete(taskId);
85507
+ if (!ephemeral) return;
85508
+ try {
85509
+ await this.agentStore.deleteAgent(agentId);
85510
+ } catch (err) {
85511
+ if (this.isBenignDeleteRace(agentId, err)) return;
85512
+ this.log.warn(`Failed to delete agent ${agentId} after ${reason}: ${this.formatError(err)}`);
85513
+ } finally {
85514
+ this.pendingDeletions.delete(agentId);
85515
+ }
85516
+ }
85517
+ /**
85518
+ * Look up the ephemeral worker on disk when the in-memory map has no
85519
+ * record. Covers the cross-restart case where onComplete fires in a
85520
+ * different process session than the onStart that created the worker.
85521
+ */
85522
+ async recoverOwnerFromDisk(taskId) {
85523
+ try {
85524
+ const candidate = await this.lookupExistingByName(`executor-${taskId}`);
85525
+ if (candidate) {
85526
+ this.log.log(`Recovered ephemeral owner ${candidate.id} for task ${taskId} from disk (cross-restart)`);
85527
+ return { agentId: candidate.id, ephemeral: true };
85528
+ }
85529
+ } catch (err) {
85530
+ this.log.warn(`Cross-restart owner lookup failed for task ${taskId}: ${this.formatError(err)}`);
85531
+ }
85532
+ return null;
85533
+ }
85534
+ async lookupExistingByName(name) {
85535
+ try {
85536
+ const found = await this.agentStore.findAgentByName(name);
85537
+ if (found && isEphemeralAgent(found)) return found;
85538
+ return null;
85539
+ } catch (err) {
85540
+ this.log.warn(`findAgentByName(${name}) failed: ${this.formatError(err)}`);
85541
+ return null;
85542
+ }
85543
+ }
85544
+ async shouldDeleteOnSweep(agent) {
85545
+ if (agent.state === "paused" || agent.state === "error") return true;
85546
+ if (!agent.taskId) return true;
85547
+ try {
85548
+ const task = await this.taskStore.getTask(agent.taskId);
85549
+ if (!task) return true;
85550
+ if (TERMINAL_TASK_COLUMNS.has(task.column)) return true;
85551
+ return task.column !== "in-progress";
85552
+ } catch {
85553
+ return true;
85554
+ }
85555
+ }
85556
+ async deleteEphemeralAgent(agentId, reason) {
85557
+ if (this.pendingDeletions.has(agentId)) return;
85558
+ this.pendingDeletions.add(agentId);
85559
+ try {
85560
+ await this.agentStore.deleteAgent(agentId);
85561
+ } catch (err) {
85562
+ if (this.isBenignDeleteRace(agentId, err)) return;
85563
+ this.log.warn(`Failed to delete ephemeral agent ${agentId} (${reason}): ${this.formatError(err)}`);
85564
+ } finally {
85565
+ this.pendingDeletions.delete(agentId);
85566
+ }
85567
+ }
85568
+ isBenignDeleteRace(agentId, err) {
85569
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
85570
+ if (msg.includes("already deleted") || msg.includes("already removed")) return true;
85571
+ if (msg.includes(`agent ${agentId.toLowerCase()} not found`)) return true;
85572
+ return false;
85573
+ }
85574
+ formatError(err) {
85575
+ return err instanceof Error ? err.message : String(err);
85576
+ }
85577
+ };
85578
+ }
85579
+ });
85580
+
84754
85581
  // ../engine/src/runtimes/in-process-runtime.ts
84755
85582
  import { EventEmitter as EventEmitter19 } from "node:events";
84756
85583
  var InProcessRuntime;
@@ -84773,6 +85600,7 @@ var init_in_process_runtime = __esm({
84773
85600
  init_mission_autopilot();
84774
85601
  init_mission_execution_loop();
84775
85602
  init_triage();
85603
+ init_ephemeral_worker_manager();
84776
85604
  InProcessRuntime = class extends EventEmitter19 {
84777
85605
  /**
84778
85606
  * @param config - Runtime configuration
@@ -84797,8 +85625,12 @@ var init_in_process_runtime = __esm({
84797
85625
  agentStore;
84798
85626
  heartbeatMonitor;
84799
85627
  triggerScheduler;
84800
- /** Maps task IDs to execution owner metadata for lifecycle tracking */
84801
- taskAgentMap = /* @__PURE__ */ new Map();
85628
+ /**
85629
+ * Coordinates the ephemeral task-worker lifecycle (spawn dedup, finalize,
85630
+ * halt-listener cleanup, startup sweep). See `ephemeral-worker-manager.ts`.
85631
+ * Created once the AgentStore is available; guard call sites with `?`.
85632
+ */
85633
+ workerManager;
84802
85634
  lastActivityAt = (/* @__PURE__ */ new Date()).toISOString();
84803
85635
  pluginRunner;
84804
85636
  pluginStore;
@@ -84811,10 +85643,6 @@ var init_in_process_runtime = __esm({
84811
85643
  triageProcessor;
84812
85644
  messageStore;
84813
85645
  concurrencyChangedListener;
84814
- /** Set of agent IDs with in-flight ephemeral cleanup (prevents duplicate deletion) */
84815
- pendingEphemeralDeletions = /* @__PURE__ */ new Set();
84816
- /** Listener for agent:stateChanged events to clean up terminated ephemeral agents */
84817
- ephemeralTerminationListener;
84818
85646
  /**
84819
85647
  * Optional callback the runtime forwards to SelfHealingManager so that
84820
85648
  * stale-merge recovery can re-enqueue tasks immediately. Set by ProjectEngine
@@ -84844,7 +85672,7 @@ var init_in_process_runtime = __esm({
84844
85672
  runtimeLog.log(`Starting InProcessRuntime for project ${this.config.projectId}`);
84845
85673
  try {
84846
85674
  const {
84847
- TaskStore: TaskStore2,
85675
+ TaskStore: TaskStore3,
84848
85676
  PluginStore: PluginStoreClass,
84849
85677
  PluginLoader: PluginLoaderClass,
84850
85678
  MessageStore: MessageStoreClass
@@ -84853,7 +85681,7 @@ var init_in_process_runtime = __esm({
84853
85681
  this.taskStore = this.config.externalTaskStore;
84854
85682
  runtimeLog.log(`TaskStore provided externally for project ${this.config.projectId}`);
84855
85683
  } else {
84856
- this.taskStore = new TaskStore2(this.config.workingDirectory);
85684
+ this.taskStore = new TaskStore3(this.config.workingDirectory);
84857
85685
  await this.taskStore.init();
84858
85686
  runtimeLog.log(`TaskStore initialized for project ${this.config.projectId}`);
84859
85687
  }
@@ -85022,84 +85850,13 @@ var init_in_process_runtime = __esm({
85022
85850
  onStart: (task, worktreePath) => {
85023
85851
  this.recordActivity();
85024
85852
  runtimeLog.log(`Started executing task ${task.id} in ${worktreePath}`);
85025
- if (!this.agentStore) return;
85026
- void (async () => {
85027
- try {
85028
- const assignedAgentId = task.assignedAgentId;
85029
- if (assignedAgentId) {
85030
- const assignedAgent = await this.agentStore.getAgent(assignedAgentId);
85031
- if (assignedAgent && !isEphemeralAgent(assignedAgent)) {
85032
- this.taskAgentMap.set(task.id, { agentId: assignedAgent.id, ephemeral: false });
85033
- await this.agentStore.syncExecutionTaskLink(assignedAgent.id, task.id);
85034
- const currentState = assignedAgent.state;
85035
- if (currentState !== "running") {
85036
- if (currentState !== "active") {
85037
- await this.agentStore.updateAgentState(assignedAgent.id, "active");
85038
- }
85039
- await this.agentStore.updateAgentState(assignedAgent.id, "running");
85040
- }
85041
- return;
85042
- }
85043
- }
85044
- if (this.taskAgentMap.has(task.id)) {
85045
- runtimeLog.warn(`Skipping task-worker creation for ${task.id}: task already has execution owner`);
85046
- return;
85047
- }
85048
- const agent = await this.agentStore.createAgent({
85049
- name: `executor-${task.id}`,
85050
- role: "executor",
85051
- metadata: {
85052
- agentKind: "task-worker",
85053
- taskWorker: true,
85054
- managedBy: "task-executor"
85055
- },
85056
- runtimeConfig: {
85057
- enabled: false
85058
- }
85059
- });
85060
- this.taskAgentMap.set(task.id, { agentId: agent.id, ephemeral: true });
85061
- await this.agentStore.assignTask(agent.id, task.id);
85062
- await this.agentStore.updateAgentState(agent.id, "active");
85063
- await this.agentStore.updateAgentState(agent.id, "running");
85064
- } catch (err) {
85065
- runtimeLog.warn(`Failed to initialize execution owner for task ${task.id}:`, err);
85066
- }
85067
- })();
85853
+ void this.workerManager?.onTaskStart(task);
85068
85854
  },
85069
85855
  onComplete: (task) => {
85070
85856
  this.recordActivity();
85071
85857
  runtimeLog.log(`Completed task ${task.id}`);
85072
85858
  this.recordTaskCompletion(task.id, true);
85073
- const owner = this.taskAgentMap.get(task.id);
85074
- if (owner && this.agentStore) {
85075
- const { agentId, ephemeral } = owner;
85076
- if (ephemeral) {
85077
- this.pendingEphemeralDeletions.add(agentId);
85078
- }
85079
- void this.agentStore.updateAgentState(agentId, "terminated").catch((err) => {
85080
- const msg = err instanceof Error ? err.message : String(err);
85081
- runtimeLog.warn(`Failed to update agent ${agentId} state to terminated (completion): ${msg}`);
85082
- });
85083
- void this.agentStore.syncExecutionTaskLink(agentId, void 0).catch((err) => {
85084
- const msg = err instanceof Error ? err.message : String(err);
85085
- runtimeLog.warn(`Failed to clear execution task link for agent ${agentId} on completion: ${msg}`);
85086
- });
85087
- this.taskAgentMap.delete(task.id);
85088
- if (!ephemeral) return;
85089
- void (async () => {
85090
- try {
85091
- await this.agentStore?.deleteAgent(agentId);
85092
- } catch (err) {
85093
- if (this.isBenignEphemeralDeleteRaceError(agentId, err)) {
85094
- return;
85095
- }
85096
- const msg = err instanceof Error ? err.message : String(err);
85097
- runtimeLog.warn(`Failed to delete agent ${agentId} after completion: ${msg}`);
85098
- } finally {
85099
- this.pendingEphemeralDeletions.delete(agentId);
85100
- }
85101
- })();
85102
- }
85859
+ void this.workerManager?.onTaskComplete(task.id);
85103
85860
  },
85104
85861
  onError: (task, error) => {
85105
85862
  this.recordActivity();
@@ -85117,36 +85874,7 @@ var init_in_process_runtime = __esm({
85117
85874
  }
85118
85875
  })();
85119
85876
  }
85120
- const owner = this.taskAgentMap.get(task.id);
85121
- if (owner && this.agentStore) {
85122
- const { agentId, ephemeral } = owner;
85123
- if (ephemeral) {
85124
- this.pendingEphemeralDeletions.add(agentId);
85125
- }
85126
- void this.agentStore.updateAgentState(agentId, "terminated").catch((err) => {
85127
- const msg = err instanceof Error ? err.message : String(err);
85128
- runtimeLog.warn(`Failed to update agent ${agentId} state to terminated (error): ${msg}`);
85129
- });
85130
- void this.agentStore.syncExecutionTaskLink(agentId, void 0).catch((err) => {
85131
- const msg = err instanceof Error ? err.message : String(err);
85132
- runtimeLog.warn(`Failed to clear execution task link for agent ${agentId} on error: ${msg}`);
85133
- });
85134
- this.taskAgentMap.delete(task.id);
85135
- if (!ephemeral) return;
85136
- void (async () => {
85137
- try {
85138
- await this.agentStore?.deleteAgent(agentId);
85139
- } catch (err) {
85140
- if (this.isBenignEphemeralDeleteRaceError(agentId, err)) {
85141
- return;
85142
- }
85143
- const msg = err instanceof Error ? err.message : String(err);
85144
- runtimeLog.warn(`Failed to delete agent ${agentId} after error: ${msg}`);
85145
- } finally {
85146
- this.pendingEphemeralDeletions.delete(agentId);
85147
- }
85148
- })();
85149
- }
85877
+ void this.workerManager?.onTaskError(task.id);
85150
85878
  }
85151
85879
  };
85152
85880
  this.executor = new TaskExecutor(
@@ -85173,6 +85901,13 @@ var init_in_process_runtime = __esm({
85173
85901
  },
85174
85902
  onTerminated: (agentId, reason) => {
85175
85903
  runtimeLog.warn(`Agent ${agentId} terminated (unresponsive): ${reason}`);
85904
+ },
85905
+ onRunCompleted: (agentId) => {
85906
+ if (this.executor) {
85907
+ void this.executor.resumeTaskForAgent(agentId).catch((err) => {
85908
+ runtimeLog.warn(`resumeTaskForAgent failed for ${agentId}: ${err instanceof Error ? err.message : String(err)}`);
85909
+ });
85910
+ }
85176
85911
  }
85177
85912
  });
85178
85913
  this.heartbeatMonitor.start();
@@ -85192,83 +85927,24 @@ var init_in_process_runtime = __esm({
85192
85927
  contextSnapshot: { ...context }
85193
85928
  });
85194
85929
  },
85195
- this.taskStore
85930
+ this.taskStore,
85931
+ { isTaskExecuting: (taskId) => this.executor.getExecutingTaskIds().has(taskId) }
85196
85932
  );
85197
85933
  this.triggerScheduler.start();
85198
85934
  const isHeartbeatEnabledAgent = (agent) => !isEphemeralAgent(agent) && agent.runtimeConfig?.enabled !== false;
85199
85935
  const isTickableHeartbeatState = (state) => state === "active" || state === "running" || state === "idle";
85200
85936
  const isTimerManagedAgent = (agent) => isHeartbeatEnabledAgent(agent) && isTickableHeartbeatState(agent.state);
85201
- this.ephemeralTerminationListener = (agentId, from, to) => {
85202
- if (to !== "terminated") return;
85203
- if (from === "terminated") return;
85204
- if (this.pendingEphemeralDeletions.has(agentId) || this.executor?.isEphemeralDeletionPending(agentId)) return;
85205
- void (async () => {
85206
- try {
85207
- const agent = await this.agentStore?.getAgent(agentId);
85208
- if (!agent) return;
85209
- if (!isEphemeralAgent(agent)) return;
85210
- this.pendingEphemeralDeletions.add(agentId);
85211
- try {
85212
- await this.agentStore?.deleteAgent(agentId);
85213
- } catch (err) {
85214
- if (this.isBenignEphemeralDeleteRaceError(agentId, err)) {
85215
- return;
85216
- }
85217
- const msg = err instanceof Error ? err.message : String(err);
85218
- runtimeLog.warn(`Failed to delete ephemeral agent ${agentId} after termination: ${msg}`);
85219
- } finally {
85220
- this.pendingEphemeralDeletions.delete(agentId);
85221
- }
85222
- } catch (err) {
85223
- const msg = err instanceof Error ? err.message : String(err);
85224
- runtimeLog.warn(`Failed to process termination event for agent ${agentId}: ${msg}`);
85225
- }
85226
- })();
85227
- };
85228
- this.agentStore.on("agent:stateChanged", this.ephemeralTerminationListener);
85229
- try {
85230
- const allAgents = await this.agentStore.listAgents({ includeEphemeral: true });
85231
- let cleanedCount = 0;
85232
- for (const agent of allAgents) {
85233
- if (!isEphemeralAgent(agent)) continue;
85234
- let shouldDelete = agent.state === "terminated" || agent.state === "error";
85235
- if (!shouldDelete && agent.taskId) {
85236
- try {
85237
- const task = await this.taskStore.getTask(agent.taskId);
85238
- if (!task || task.column !== "in-progress") {
85239
- shouldDelete = true;
85240
- }
85241
- } catch {
85242
- shouldDelete = true;
85243
- }
85244
- }
85245
- if (!shouldDelete) continue;
85246
- try {
85247
- if (agent.state !== "terminated") {
85248
- await this.agentStore.updateAgentState(agent.id, "terminated");
85249
- }
85250
- } catch (err) {
85251
- const msg = err instanceof Error ? err.message : String(err);
85252
- runtimeLog.warn(`Startup sweep failed to set ephemeral agent ${agent.id} terminated: ${msg}`);
85253
- }
85254
- try {
85255
- await this.agentStore.deleteAgent(agent.id);
85256
- cleanedCount += 1;
85257
- } catch (err) {
85258
- if (this.isBenignEphemeralDeleteRaceError(agent.id, err)) {
85259
- cleanedCount += 1;
85260
- continue;
85261
- }
85262
- const msg = err instanceof Error ? err.message : String(err);
85263
- runtimeLog.warn(`Startup sweep failed to delete ephemeral agent ${agent.id}: ${msg}`);
85264
- }
85265
- }
85266
- if (cleanedCount > 0) {
85267
- runtimeLog.log(`Startup ephemeral sweep cleaned ${cleanedCount} orphaned agent(s)`);
85268
- }
85269
- } catch (err) {
85270
- const msg = err instanceof Error ? err.message : String(err);
85271
- runtimeLog.warn(`Startup ephemeral sweep failed (continuing): ${msg}`);
85937
+ if (this.agentStore && !this.workerManager) {
85938
+ this.workerManager = new EphemeralWorkerManager({
85939
+ agentStore: this.agentStore,
85940
+ taskStore: this.taskStore,
85941
+ logger: runtimeLog,
85942
+ isDeletionPendingExternal: (agentId) => this.executor?.isEphemeralDeletionPending(agentId) ?? false
85943
+ });
85944
+ }
85945
+ if (this.workerManager) {
85946
+ this.workerManager.attachStateChangeListener();
85947
+ await this.workerManager.reconcileOrphaned();
85272
85948
  }
85273
85949
  try {
85274
85950
  const agents = await this.agentStore.listAgents();
@@ -85439,12 +86115,8 @@ var init_in_process_runtime = __esm({
85439
86115
  this.routineScheduler.stop();
85440
86116
  runtimeLog.log("RoutineScheduler stopped");
85441
86117
  }
85442
- if (this.ephemeralTerminationListener && this.agentStore) {
85443
- this.agentStore.off("agent:stateChanged", this.ephemeralTerminationListener);
85444
- this.ephemeralTerminationListener = void 0;
85445
- runtimeLog.log("AgentStore agent:stateChanged listener removed");
85446
- }
85447
- this.pendingEphemeralDeletions.clear();
86118
+ this.workerManager?.detachStateChangeListener();
86119
+ this.workerManager?.reset();
85448
86120
  this.executor?.disposeEphemeralTimers();
85449
86121
  if (this.triggerScheduler) {
85450
86122
  this.triggerScheduler.stop();
@@ -85740,21 +86412,6 @@ var init_in_process_runtime = __esm({
85740
86412
  });
85741
86413
  runtimeLog.log("Event forwarding setup complete");
85742
86414
  }
85743
- /**
85744
- * Returns true when an ephemeral delete failure is expected due to cleanup races
85745
- * (for example the agent was already removed by a parallel cleanup path).
85746
- */
85747
- isBenignEphemeralDeleteRaceError(agentId, err) {
85748
- const msg = err instanceof Error ? err.message : String(err);
85749
- const normalized = msg.toLowerCase();
85750
- if (normalized.includes("already deleted") || normalized.includes("already removed")) {
85751
- return true;
85752
- }
85753
- if (normalized.includes(`agent ${agentId.toLowerCase()} not found`)) {
85754
- return true;
85755
- }
85756
- return /^agent\s+.+\s+not found$/i.test(msg.trim());
85757
- }
85758
86415
  /**
85759
86416
  * Update status and emit health-changed event.
85760
86417
  */
@@ -89487,6 +90144,37 @@ ${detail}`
89487
90144
  }
89488
90145
  }
89489
90146
  wireSettingsListeners(store) {
90147
+ const applyDetectorPauseLifecycle = (paused, source) => {
90148
+ try {
90149
+ const detector = this.runtime.stuckTaskDetector;
90150
+ if (paused) {
90151
+ detector?.pause?.();
90152
+ } else {
90153
+ detector?.resume?.();
90154
+ }
90155
+ } catch (err) {
90156
+ runtimeLog.warn(
90157
+ `${source}: stuck detector ${paused ? "pause" : "resume"} hook failed: ${err instanceof Error ? err.message : String(err)}`
90158
+ );
90159
+ }
90160
+ };
90161
+ const onPauseLifecycleTransition = ({
90162
+ settings: s,
90163
+ previous: prev
90164
+ }) => {
90165
+ const wasPaused = prev.globalPause || prev.enginePaused;
90166
+ const isPaused = s.globalPause || s.enginePaused;
90167
+ if (!wasPaused && isPaused) {
90168
+ const source = s.globalPause && !prev.globalPause ? "Global pause" : "Engine pause";
90169
+ applyDetectorPauseLifecycle(true, source);
90170
+ }
90171
+ if (wasPaused && !isPaused) {
90172
+ const source = prev.globalPause && !s.globalPause ? "Global unpause" : "Engine unpause";
90173
+ applyDetectorPauseLifecycle(false, source);
90174
+ }
90175
+ };
90176
+ store.on("settings:updated", onPauseLifecycleTransition);
90177
+ this.settingsHandlers.push(onPauseLifecycleTransition);
89490
90178
  const onGlobalPause = ({ settings, previous }) => {
89491
90179
  if (settings.globalPause && !previous.globalPause) {
89492
90180
  if (this.mergeAbortController) {
@@ -100018,12 +100706,14 @@ var init_register_settings_sync_inbound_routes = __esm({
100018
100706
  });
100019
100707
 
100020
100708
  // ../dashboard/src/routes/register-agent-core-routes.ts
100709
+ var MAX_AVATAR_BYTES;
100021
100710
  var init_register_agent_core_routes = __esm({
100022
100711
  "../dashboard/src/routes/register-agent-core-routes.ts"() {
100023
100712
  "use strict";
100024
100713
  init_src();
100025
100714
  init_api_error();
100026
100715
  init_src2();
100716
+ MAX_AVATAR_BYTES = 2 * 1024 * 1024;
100027
100717
  }
100028
100718
  });
100029
100719
 
@@ -101056,6 +101746,7 @@ import { Router as Router3 } from "express";
101056
101746
  var init_mission_routes = __esm({
101057
101747
  "../dashboard/src/mission-routes.ts"() {
101058
101748
  "use strict";
101749
+ init_src();
101059
101750
  init_project_store_resolver();
101060
101751
  init_src();
101061
101752
  init_sse_buffer();
@@ -106938,11 +107629,69 @@ var INSIGHT_CATEGORIES = [
106938
107629
  var INSIGHT_STATUSES = ["generated", "confirmed", "stale", "dismissed", "archived"];
106939
107630
  var INSIGHT_RUN_STATUSES = ["pending", "running", "completed", "failed", "cancelled"];
106940
107631
  var INSIGHT_RUN_TRIGGERS = ["schedule", "manual", "task_completion", "merge_event", "api"];
107632
+ function getTaskSourceAgentLabel(task) {
107633
+ const metadataAgentName = task.sourceMetadata?.agentName;
107634
+ if (typeof metadataAgentName === "string" && metadataAgentName.trim().length > 0) {
107635
+ return metadataAgentName.trim();
107636
+ }
107637
+ if (typeof task.sourceAgentId === "string" && task.sourceAgentId.trim().length > 0) {
107638
+ return task.sourceAgentId.trim();
107639
+ }
107640
+ return void 0;
107641
+ }
107642
+ function getTaskSourceLabel(task) {
107643
+ switch (task.sourceType) {
107644
+ case "dashboard_ui":
107645
+ return "Dashboard";
107646
+ case "quick_chat":
107647
+ return "Quick Chat";
107648
+ case "chat_session":
107649
+ return "Chat Session";
107650
+ case "agent_heartbeat": {
107651
+ const sourceAgent = getTaskSourceAgentLabel(task);
107652
+ return sourceAgent ? `Agent (${sourceAgent})` : "Agent";
107653
+ }
107654
+ case "automation": {
107655
+ const sourceAgent = getTaskSourceAgentLabel(task);
107656
+ return sourceAgent ? `Automation (${sourceAgent})` : "Automation";
107657
+ }
107658
+ case "cron":
107659
+ return "Scheduled Task";
107660
+ case "workflow_step":
107661
+ return "Workflow Step";
107662
+ case "github_import": {
107663
+ const issueUrl = task.sourceMetadata?.issueUrl;
107664
+ return typeof issueUrl === "string" && issueUrl.length > 0 ? `GitHub Import (${issueUrl})` : "GitHub Import";
107665
+ }
107666
+ case "research": {
107667
+ const findingLabel = task.sourceMetadata?.findingLabel;
107668
+ if (typeof findingLabel === "string" && findingLabel.length > 0) {
107669
+ return `Research (${findingLabel})`;
107670
+ }
107671
+ const runId = task.sourceMetadata?.runId;
107672
+ return typeof runId === "string" && runId.length > 0 ? `Research (${runId})` : "Research";
107673
+ }
107674
+ case "task_refine":
107675
+ return task.sourceParentTaskId ? `Refinement of ${task.sourceParentTaskId}` : "Refinement";
107676
+ case "task_duplicate":
107677
+ return task.sourceParentTaskId ? `Duplicate of ${task.sourceParentTaskId}` : "Duplicate";
107678
+ case "cli":
107679
+ return "CLI";
107680
+ case "api":
107681
+ return "API";
107682
+ case "recovery":
107683
+ return "Recovery";
107684
+ default:
107685
+ return void 0;
107686
+ }
107687
+ }
106941
107688
  function formatTaskLine(t) {
106942
107689
  const label = t.title || t.description.slice(0, 60) + (t.description.length > 60 ? "\u2026" : "");
107690
+ const source = getTaskSourceLabel(t);
107691
+ const sourceSuffix = source ? ` [via: ${source}]` : "";
106943
107692
  const deps = t.dependencies.length ? ` [deps: ${t.dependencies.join(", ")}]` : "";
106944
107693
  const paused = t.paused ? " (paused)" : "";
106945
- return `${t.id} ${label}${deps}${paused}`;
107694
+ return `${t.id} ${label}${sourceSuffix}${deps}${paused}`;
106946
107695
  }
106947
107696
  async function getResearchAvailability(store) {
106948
107697
  const settings = await store.getSettings();
@@ -107278,6 +108027,10 @@ Column: triage
107278
108027
  if (task.dependencies.length) {
107279
108028
  lines.push(`Dependencies: ${task.dependencies.join(", ")}`);
107280
108029
  }
108030
+ const sourceLabel = getTaskSourceLabel(task);
108031
+ if (sourceLabel) {
108032
+ lines.push(`Created via: ${sourceLabel}`);
108033
+ }
107281
108034
  if (task.paused) lines.push("Status: PAUSED");
107282
108035
  lines.push("");
107283
108036
  if (task.steps.length > 0) {