@runfusion/fusion 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/bin.js +3737 -1373
  2. package/dist/client/assets/{AgentDetailView-DyzuiJas.js → AgentDetailView-CLzxf6Z7.js} +3 -3
  3. package/dist/client/assets/{AgentDetailView-C1_lTTET.css → AgentDetailView-DIBOY8V-.css} +1 -1
  4. package/dist/client/assets/{AgentsView-CgweOTe6.js → AgentsView-CXaYJX_G.js} +3 -3
  5. package/dist/client/assets/{ChatView-DrY8FMIt.js → ChatView-iXxGAaN1.js} +1 -1
  6. package/dist/client/assets/DevServerView-BeXfFkF4.js +1 -0
  7. package/dist/client/assets/{DirectoryPicker-D5KQ-im_.js → DirectoryPicker-BMn5fjn9.js} +1 -1
  8. package/dist/client/assets/{DocumentsView-D2wK7FYJ.js → DocumentsView-CjrtI3TX.js} +1 -1
  9. package/dist/client/assets/{InsightsView-DfY3sa1j.js → InsightsView-BkfQ-TV1.js} +1 -1
  10. package/dist/client/assets/MemoryView-1G0zWu1i.js +2 -0
  11. package/dist/client/assets/{NodesView-g26-j7rg.js → NodesView-Bn_1R73N.js} +1 -1
  12. package/dist/client/assets/{NodesView-BYVG2yY-.css → NodesView-DCoS6iYh.css} +1 -1
  13. package/dist/client/assets/{PiExtensionsManager-DfMr3Gls.js → PiExtensionsManager-CqGOtQnR.js} +3 -3
  14. package/dist/client/assets/{PluginManager-DiMOD-Kj.js → PluginManager-CM5QGvSG.js} +1 -1
  15. package/dist/client/assets/{RoadmapsView-DJC4F4CD.js → RoadmapsView-B4VnQP83.js} +1 -1
  16. package/dist/client/assets/SettingsModal-BiLA-BeG.js +31 -0
  17. package/dist/client/assets/{SettingsModal-Cx3iMWDs.js → SettingsModal-C3LckzfT.js} +1 -1
  18. package/dist/client/assets/SettingsModal-D5hLoLXp.css +1 -0
  19. package/dist/client/assets/SetupWizardModal-Bk_8HfLm.js +1 -0
  20. package/dist/client/assets/SetupWizardModal-DRF5fOoR.css +1 -0
  21. package/dist/client/assets/{SkillsView-DTB2cmXQ.js → SkillsView-CRvqF8P1.js} +1 -1
  22. package/dist/client/assets/{TodoView-CyxdHUdz.js → TodoView-Vzui5Eha.js} +2 -2
  23. package/dist/client/assets/{folder-open-C3zB1vmh.js → folder-open-CMF89prE.js} +1 -1
  24. package/dist/client/assets/index-B8kH5y4Q.js +656 -0
  25. package/dist/client/assets/index-D2fXOwWF.css +1 -0
  26. package/dist/client/assets/{list-checks-CK3_6p5e.js → list-checks-M95d1uAy.js} +1 -1
  27. package/dist/client/assets/{star-BQhDgM9V.js → star-DHhJD6ow.js} +1 -1
  28. package/dist/client/assets/{upload-DDdZveEJ.js → upload-CEq8jic8.js} +1 -1
  29. package/dist/client/assets/{users-DWWgd19M.js → users-CUA8Tv-d.js} +1 -1
  30. package/dist/client/index.html +2 -2
  31. package/dist/client/version.json +1 -1
  32. package/dist/extension.js +2403 -471
  33. package/dist/pi-claude-cli/index.ts +27 -0
  34. package/dist/pi-claude-cli/package.json +4 -5
  35. package/dist/pi-claude-cli/src/__tests__/process-manager.test.ts +31 -9
  36. package/dist/pi-claude-cli/src/__tests__/provider.test.ts +122 -8
  37. package/dist/pi-claude-cli/src/process-manager.ts +25 -7
  38. package/dist/pi-claude-cli/src/provider.ts +31 -7
  39. package/dist/pi-claude-cli/src/types/cross-spawn.d.ts +7 -0
  40. package/package.json +2 -6
  41. package/skill/fusion/references/extension-tools.md +1 -0
  42. package/dist/client/assets/DevServerView-fvjo36sF.js +0 -6
  43. package/dist/client/assets/MemoryView-CyAQgXwO.js +0 -2
  44. package/dist/client/assets/SettingsModal-BnekMOV2.css +0 -1
  45. package/dist/client/assets/SettingsModal-DjVE27r5.js +0 -31
  46. package/dist/client/assets/SetupWizardModal-BMa6p24b.css +0 -1
  47. package/dist/client/assets/SetupWizardModal-Cow6woq6.js +0 -1
  48. package/dist/client/assets/index-Belw0PQt.css +0 -1
  49. package/dist/client/assets/index-DJDWSrju.js +0 -646
package/dist/extension.js CHANGED
@@ -37,7 +37,6 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
37
37
  isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
38
38
  mod
39
39
  ));
40
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
41
40
 
42
41
  // ../core/src/settings-schema.ts
43
42
  function isGlobalSettingsKey(key) {
@@ -61,8 +60,13 @@ var init_settings_schema = __esm({
61
60
  ntfyEnabled: false,
62
61
  ntfyTopic: void 0,
63
62
  ntfyBaseUrl: void 0,
64
- ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"],
63
+ ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input", "gridlock"],
65
64
  ntfyDashboardHost: void 0,
65
+ webhookEnabled: false,
66
+ webhookUrl: void 0,
67
+ webhookFormat: "generic",
68
+ webhookEvents: [],
69
+ notificationProviders: [],
66
70
  defaultProjectId: void 0,
67
71
  setupComplete: void 0,
68
72
  favoriteProviders: void 0,
@@ -115,6 +119,7 @@ var init_settings_schema = __esm({
115
119
  pushAfterMerge: false,
116
120
  pushRemote: "origin",
117
121
  unavailableNodePolicy: "block",
122
+ defaultNodeId: void 0,
118
123
  worktreeInitCommand: void 0,
119
124
  testCommand: void 0,
120
125
  buildCommand: void 0,
@@ -145,6 +150,9 @@ var init_settings_schema = __esm({
145
150
  smartConflictResolution: true,
146
151
  worktreeRebaseBeforeMerge: true,
147
152
  worktreeRebaseRemote: "",
153
+ worktreeRebaseLocalBase: true,
154
+ mergeConflictStrategy: "smart-prefer-main",
155
+ workflowStepTimeoutMs: 36e4,
148
156
  strictScopeEnforcement: false,
149
157
  buildRetryCount: 0,
150
158
  verificationFixRetries: 3,
@@ -210,7 +218,7 @@ var init_settings_schema = __esm({
210
218
  },
211
219
  cloudflare: {
212
220
  enabled: false,
213
- quickTunnel: false,
221
+ quickTunnel: true,
214
222
  tunnelName: "",
215
223
  tunnelToken: null,
216
224
  ingressUrl: ""
@@ -728,7 +736,132 @@ var init_error_message = __esm({
728
736
  }
729
737
  });
730
738
 
739
+ // ../core/src/model-resolution.ts
740
+ function hasCompleteModelPair(pair) {
741
+ return Boolean(pair?.provider && pair?.modelId);
742
+ }
743
+ function pickFirstModelPair(...pairs) {
744
+ for (const pair of pairs) {
745
+ if (hasCompleteModelPair(pair)) {
746
+ return { provider: pair.provider, modelId: pair.modelId };
747
+ }
748
+ }
749
+ return {};
750
+ }
751
+ function resolveProjectDefaultModel(settings) {
752
+ return pickFirstModelPair(
753
+ {
754
+ provider: settings?.defaultProviderOverride,
755
+ modelId: settings?.defaultModelIdOverride
756
+ },
757
+ {
758
+ provider: settings?.defaultProvider,
759
+ modelId: settings?.defaultModelId
760
+ }
761
+ );
762
+ }
763
+ function resolveExecutionSettingsModel(settings) {
764
+ return pickFirstModelPair(
765
+ {
766
+ provider: settings?.executionProvider,
767
+ modelId: settings?.executionModelId
768
+ },
769
+ {
770
+ provider: settings?.executionGlobalProvider,
771
+ modelId: settings?.executionGlobalModelId
772
+ },
773
+ resolveProjectDefaultModel(settings)
774
+ );
775
+ }
776
+ function resolvePlanningSettingsModel(settings) {
777
+ return pickFirstModelPair(
778
+ {
779
+ provider: settings?.planningProvider,
780
+ modelId: settings?.planningModelId
781
+ },
782
+ {
783
+ provider: settings?.planningGlobalProvider,
784
+ modelId: settings?.planningGlobalModelId
785
+ },
786
+ resolveProjectDefaultModel(settings)
787
+ );
788
+ }
789
+ function resolveValidatorSettingsModel(settings) {
790
+ return pickFirstModelPair(
791
+ {
792
+ provider: settings?.validatorProvider,
793
+ modelId: settings?.validatorModelId
794
+ },
795
+ {
796
+ provider: settings?.validatorGlobalProvider,
797
+ modelId: settings?.validatorGlobalModelId
798
+ },
799
+ resolveProjectDefaultModel(settings)
800
+ );
801
+ }
802
+ function resolveTitleSummarizerSettingsModel(settings) {
803
+ return pickFirstModelPair(
804
+ {
805
+ provider: settings?.titleSummarizerProvider,
806
+ modelId: settings?.titleSummarizerModelId
807
+ },
808
+ {
809
+ provider: settings?.titleSummarizerGlobalProvider,
810
+ modelId: settings?.titleSummarizerGlobalModelId
811
+ },
812
+ {
813
+ provider: settings?.planningProvider,
814
+ modelId: settings?.planningModelId
815
+ },
816
+ resolveProjectDefaultModel(settings)
817
+ );
818
+ }
819
+ function resolveTaskExecutionModel(task, settings) {
820
+ return pickFirstModelPair(
821
+ {
822
+ provider: task.modelProvider,
823
+ modelId: task.modelId
824
+ },
825
+ resolveExecutionSettingsModel(settings)
826
+ );
827
+ }
828
+ function resolveTaskValidatorModel(task, settings) {
829
+ return pickFirstModelPair(
830
+ {
831
+ provider: task.validatorModelProvider,
832
+ modelId: task.validatorModelId
833
+ },
834
+ resolveValidatorSettingsModel(settings)
835
+ );
836
+ }
837
+ function resolveTaskPlanningModel(task, settings) {
838
+ return pickFirstModelPair(
839
+ {
840
+ provider: task.planningModelProvider,
841
+ modelId: task.planningModelId
842
+ },
843
+ resolvePlanningSettingsModel(settings)
844
+ );
845
+ }
846
+ var init_model_resolution = __esm({
847
+ "../core/src/model-resolution.ts"() {
848
+ "use strict";
849
+ }
850
+ });
851
+
731
852
  // ../core/src/types.ts
853
+ function normalizeMergeConflictStrategy(value) {
854
+ switch (value) {
855
+ case "smart":
856
+ return "smart-prefer-branch";
857
+ case "prefer-main":
858
+ return "smart-prefer-main";
859
+ case void 0:
860
+ return "smart-prefer-main";
861
+ default:
862
+ return value;
863
+ }
864
+ }
732
865
  function validateDocumentKey(key) {
733
866
  if (!DOCUMENT_KEY_RE.test(key)) {
734
867
  throw new Error(
@@ -812,13 +945,14 @@ function validateMessageMetadata(metadata) {
812
945
  throw new Error("metadata.replyTo.messageId must be a non-empty string");
813
946
  }
814
947
  }
815
- var THINKING_LEVELS, COLUMNS, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, DOCUMENT_KEY_RE, CheckoutConflictError, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, AGENT_VALID_TRANSITIONS, AGENT_PERMISSIONS;
948
+ var THINKING_LEVELS, COLUMNS, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, THEME_MODES, COLOR_THEMES, NOTIFICATION_EVENTS, WORKFLOW_STEP_TEMPLATES, DOCUMENT_KEY_RE, CheckoutConflictError, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, AGENT_VALID_TRANSITIONS, AGENT_PERMISSIONS;
816
949
  var init_types = __esm({
817
950
  "../core/src/types.ts"() {
818
951
  "use strict";
819
952
  init_settings_schema();
820
953
  init_prompt_overrides();
821
954
  init_error_message();
955
+ init_model_resolution();
822
956
  THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"];
823
957
  COLUMNS = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
824
958
  TASK_PRIORITIES = ["low", "normal", "high", "urgent"];
@@ -882,6 +1016,15 @@ var init_types = __esm({
882
1016
  "neon-bloom",
883
1017
  "sepia"
884
1018
  ];
1019
+ NOTIFICATION_EVENTS = [
1020
+ "in-review",
1021
+ "merged",
1022
+ "failed",
1023
+ "awaiting-approval",
1024
+ "awaiting-user-review",
1025
+ "planning-awaiting-input",
1026
+ "gridlock"
1027
+ ];
885
1028
  WORKFLOW_STEP_TEMPLATES = [
886
1029
  {
887
1030
  id: "documentation-review",
@@ -2289,7 +2432,7 @@ var init_db = __esm({
2289
2432
  "use strict";
2290
2433
  init_sqlite_adapter();
2291
2434
  init_types();
2292
- SCHEMA_VERSION = 49;
2435
+ SCHEMA_VERSION = 51;
2293
2436
  SCHEMA_SQL = `
2294
2437
  -- Tasks table with JSON columns for nested data
2295
2438
  CREATE TABLE IF NOT EXISTS tasks (
@@ -2823,6 +2966,74 @@ CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder
2823
2966
  get fts5Available() {
2824
2967
  return this._fts5Available;
2825
2968
  }
2969
+ /**
2970
+ * Rebuild the task FTS5 index and maintenance triggers from scratch.
2971
+ * Returns false when FTS5 is unavailable in this runtime.
2972
+ */
2973
+ rebuildFts5Index() {
2974
+ if (!this._fts5Available) {
2975
+ return false;
2976
+ }
2977
+ try {
2978
+ this.db.exec("DROP TRIGGER IF EXISTS tasks_fts_ai");
2979
+ this.db.exec("DROP TRIGGER IF EXISTS tasks_fts_au");
2980
+ this.db.exec("DROP TRIGGER IF EXISTS tasks_fts_ad");
2981
+ this.db.exec("DROP TABLE IF EXISTS tasks_fts");
2982
+ this.db.exec(`
2983
+ CREATE VIRTUAL TABLE IF NOT EXISTS tasks_fts USING fts5(
2984
+ id,
2985
+ title,
2986
+ description,
2987
+ comments,
2988
+ content='tasks',
2989
+ content_rowid='rowid'
2990
+ )
2991
+ `);
2992
+ this.db.exec(`
2993
+ CREATE TRIGGER IF NOT EXISTS tasks_fts_ai AFTER INSERT ON tasks BEGIN
2994
+ INSERT INTO tasks_fts(rowid, id, title, description, comments)
2995
+ VALUES (new.rowid, new.id, COALESCE(new.title, ''), new.description, COALESCE(new.comments, '[]'));
2996
+ END
2997
+ `);
2998
+ const hasTaskTitle = this.hasColumn("tasks", "title");
2999
+ const updateColumns = hasTaskTitle ? "id, title, description, comments" : "id, description, comments";
3000
+ const oldTitle = hasTaskTitle ? "COALESCE(old.title, '')" : "''";
3001
+ const newTitle = hasTaskTitle ? "COALESCE(new.title, '')" : "''";
3002
+ this.db.exec(`
3003
+ CREATE TRIGGER IF NOT EXISTS tasks_fts_au AFTER UPDATE OF ${updateColumns} ON tasks BEGIN
3004
+ INSERT INTO tasks_fts(tasks_fts, rowid, id, title, description, comments)
3005
+ VALUES('delete', old.rowid, old.id, ${oldTitle}, old.description, COALESCE(old.comments, '[]'));
3006
+ INSERT INTO tasks_fts(rowid, id, title, description, comments)
3007
+ VALUES (new.rowid, new.id, ${newTitle}, new.description, COALESCE(new.comments, '[]'));
3008
+ END
3009
+ `);
3010
+ this.db.exec(`
3011
+ CREATE TRIGGER IF NOT EXISTS tasks_fts_ad AFTER DELETE ON tasks BEGIN
3012
+ INSERT INTO tasks_fts(tasks_fts, rowid, id, title, description, comments)
3013
+ VALUES('delete', old.rowid, old.id, COALESCE(old.title, ''), old.description, COALESCE(old.comments, '[]'));
3014
+ END
3015
+ `);
3016
+ this.db.exec("INSERT INTO tasks_fts(tasks_fts) VALUES('rebuild')");
3017
+ return true;
3018
+ } catch (error) {
3019
+ console.warn("[fusion:db] Failed to rebuild FTS5 index", error);
3020
+ throw error;
3021
+ }
3022
+ }
3023
+ /**
3024
+ * Run FTS5 integrity check. Returns true when healthy or unavailable.
3025
+ */
3026
+ checkFts5Integrity() {
3027
+ if (!this._fts5Available) {
3028
+ return true;
3029
+ }
3030
+ try {
3031
+ this.db.exec("INSERT INTO tasks_fts(tasks_fts) VALUES('integrity-check')");
3032
+ return true;
3033
+ } catch {
3034
+ return false;
3035
+ }
3036
+ }
2826
3037
  /**
2827
3038
  * Initialize the database: create tables if they don't exist
2828
3039
  * and seed meta values.
@@ -3748,6 +3959,19 @@ CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder
3748
3959
  this.addColumnIfMissing("tasks", "nodeId", "TEXT");
3749
3960
  });
3750
3961
  }
3962
+ if (version < 50) {
3963
+ this.applyMigration(50, () => {
3964
+ this.addColumnIfMissing("tasks", "effectiveNodeId", "TEXT");
3965
+ this.addColumnIfMissing("tasks", "effectiveNodeSource", "TEXT");
3966
+ });
3967
+ }
3968
+ if (version < 51) {
3969
+ this.applyMigration(51, () => {
3970
+ if (this.hasTable("chat_messages")) {
3971
+ this.addColumnIfMissing("chat_messages", "attachments", "TEXT");
3972
+ }
3973
+ });
3974
+ }
3751
3975
  }
3752
3976
  /**
3753
3977
  * Run a single migration step inside a transaction and bump the version.
@@ -3763,6 +3987,14 @@ CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder
3763
3987
  const row = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
3764
3988
  return Boolean(row);
3765
3989
  }
3990
+ /**
3991
+ * Check whether an error appears to be an FTS5 corruption/integrity failure.
3992
+ */
3993
+ isFts5CorruptionError(error) {
3994
+ const message = error instanceof Error ? error.message : String(error ?? "");
3995
+ const lower = message.toLowerCase();
3996
+ return lower.includes("corruption found reading blob") || lower.includes("database disk image is malformed") || lower.includes("fts5") && lower.includes("corrupt");
3997
+ }
3766
3998
  /**
3767
3999
  * Check whether a table has a given column.
3768
4000
  */
@@ -18238,10 +18470,31 @@ var init_task_merge = __esm({
18238
18470
  "use strict";
18239
18471
  BLOCKING_TASK_STATUSES = /* @__PURE__ */ new Set([
18240
18472
  "failed",
18473
+ // ── User-attention / awaiting-handoff states ─────────────────────────
18241
18474
  "awaiting-inspection",
18242
18475
  "awaiting-user-review",
18476
+ "awaiting-approval",
18477
+ // triage spec awaiting user approval
18478
+ // ── Active merge in-flight ───────────────────────────────────────────
18243
18479
  "merging",
18244
- "merging-pr"
18480
+ "merging-pr",
18481
+ // ── Re-planning / triage states (scope not finalized) ────────────────
18482
+ // A task in planning/triage hasn't finalized its scope yet — letting it
18483
+ // merge skips the work the user moved it back to plan. Same for the legacy
18484
+ // "specifying" alias migrated to "planning" in db.ts.
18485
+ "planning",
18486
+ "specifying",
18487
+ "needs-replan",
18488
+ // scheduler/executor/triage signaled re-plan
18489
+ // ── Mission-level validation in flight ───────────────────────────────
18490
+ "mission-validation",
18491
+ // ── Scheduler-side transient state ───────────────────────────────────
18492
+ "queued",
18493
+ // scheduler placed the task in line; not finalized
18494
+ // ── Abnormal termination — defensive guard ───────────────────────────
18495
+ // Task was killed by the stuck detector. If it surfaces in in-review,
18496
+ // it needs investigation, not auto-merge.
18497
+ "stuck-killed"
18245
18498
  ]);
18246
18499
  NON_TERMINAL_STEP_STATUSES = /* @__PURE__ */ new Set([
18247
18500
  "pending",
@@ -29462,6 +29715,26 @@ var init_logger = __esm({
29462
29715
  }
29463
29716
  });
29464
29717
 
29718
+ // ../core/src/node-override-guard.ts
29719
+ function validateNodeOverrideChange(task, newNodeId) {
29720
+ if (newNodeId === void 0) {
29721
+ return { allowed: true };
29722
+ }
29723
+ if (task.column === "in-progress") {
29724
+ return {
29725
+ allowed: false,
29726
+ reason: "task-in-progress",
29727
+ message: `Cannot change node override for ${task.id} while it is in progress. The task is currently executing and routing cannot be changed mid-flight. Wait for the task to complete, or pause/stop it first before changing the node assignment.`
29728
+ };
29729
+ }
29730
+ return { allowed: true };
29731
+ }
29732
+ var init_node_override_guard = __esm({
29733
+ "../core/src/node-override-guard.ts"() {
29734
+ "use strict";
29735
+ }
29736
+ });
29737
+
29465
29738
  // ../core/src/store.ts
29466
29739
  import { EventEmitter as EventEmitter12 } from "node:events";
29467
29740
  import { randomUUID as randomUUID6 } from "node:crypto";
@@ -29549,6 +29822,7 @@ var init_store = __esm({
29549
29822
  init_project_memory();
29550
29823
  init_run_command();
29551
29824
  init_logger();
29825
+ init_node_override_guard();
29552
29826
  TASK_ACTIVITY_LOG_ENTRY_LIMIT = 1e3;
29553
29827
  TASK_ACTIVITY_LOG_OUTCOME_LIMIT = 4e3;
29554
29828
  ARCHIVE_AGENT_LOG_SNAPSHOT_LIMIT = 25;
@@ -29835,6 +30109,8 @@ var init_store = __esm({
29835
30109
  assignedAgentId: row.assignedAgentId || void 0,
29836
30110
  assigneeUserId: row.assigneeUserId || void 0,
29837
30111
  nodeId: row.nodeId || void 0,
30112
+ effectiveNodeId: row.effectiveNodeId || void 0,
30113
+ effectiveNodeSource: row.effectiveNodeSource || void 0,
29838
30114
  checkedOutBy: row.checkedOutBy || void 0,
29839
30115
  checkedOutAt: row.checkedOutAt || void 0
29840
30116
  };
@@ -30083,6 +30359,8 @@ ${recentText}` : void 0
30083
30359
  "assignedAgentId",
30084
30360
  "assigneeUserId",
30085
30361
  "nodeId",
30362
+ "effectiveNodeId",
30363
+ "effectiveNodeSource",
30086
30364
  "checkedOutBy",
30087
30365
  "checkedOutAt",
30088
30366
  // `log` is fetched in slim mode so the server can aggregate
@@ -30183,6 +30461,8 @@ ${outcome}`;
30183
30461
  "assignedAgentId",
30184
30462
  "assigneeUserId",
30185
30463
  "nodeId",
30464
+ "effectiveNodeId",
30465
+ "effectiveNodeSource",
30186
30466
  "checkedOutBy",
30187
30467
  "checkedOutAt"
30188
30468
  ];
@@ -30221,9 +30501,9 @@ ${outcome}`;
30221
30501
  dependencies, steps, log, attachments, steeringComments,
30222
30502
  comments, workflowStepResults, prInfo, issueInfo,
30223
30503
  sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
30224
- mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, nodeId, checkedOutBy, checkedOutAt
30504
+ mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, checkedOutBy, checkedOutAt
30225
30505
  ) VALUES (
30226
- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
30506
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
30227
30507
  )
30228
30508
  ON CONFLICT(id) DO UPDATE SET
30229
30509
  title = excluded.title,
@@ -30291,6 +30571,8 @@ ${outcome}`;
30291
30571
  assignedAgentId = excluded.assignedAgentId,
30292
30572
  assigneeUserId = excluded.assigneeUserId,
30293
30573
  nodeId = excluded.nodeId,
30574
+ effectiveNodeId = excluded.effectiveNodeId,
30575
+ effectiveNodeSource = excluded.effectiveNodeSource,
30294
30576
  checkedOutBy = excluded.checkedOutBy,
30295
30577
  checkedOutAt = excluded.checkedOutAt
30296
30578
  `).run(
@@ -30360,11 +30642,36 @@ ${outcome}`;
30360
30642
  task.assignedAgentId ?? null,
30361
30643
  task.assigneeUserId ?? null,
30362
30644
  task.nodeId ?? null,
30645
+ task.effectiveNodeId ?? null,
30646
+ task.effectiveNodeSource ?? null,
30363
30647
  task.checkedOutBy ?? null,
30364
30648
  task.checkedOutAt ?? null
30365
30649
  );
30366
30650
  this.db.bumpLastModified();
30367
30651
  }
30652
+ upsertTaskWithFtsRecovery(task) {
30653
+ try {
30654
+ this.upsertTask(task);
30655
+ return;
30656
+ } catch (error) {
30657
+ if (!this.db.isFts5CorruptionError(error)) {
30658
+ throw error;
30659
+ }
30660
+ console.warn(`[fusion:store] FTS5 corruption detected during upsert for task ${task.id}; rebuilding index and retrying once`);
30661
+ try {
30662
+ this.db.rebuildFts5Index();
30663
+ } catch (rebuildError) {
30664
+ console.warn("[fusion:store] FTS5 rebuild failed; propagating original upsert error", rebuildError);
30665
+ throw error;
30666
+ }
30667
+ try {
30668
+ this.upsertTask(task);
30669
+ } catch (retryError) {
30670
+ console.warn("[fusion:store] Upsert retry after FTS5 rebuild failed; propagating original upsert error", retryError);
30671
+ throw error;
30672
+ }
30673
+ }
30674
+ }
30368
30675
  /**
30369
30676
  * Read a task from SQLite by ID.
30370
30677
  */
@@ -30583,7 +30890,7 @@ ${outcome}`;
30583
30890
  * for backward compatibility and debugging.
30584
30891
  */
30585
30892
  async atomicWriteTaskJson(dir, task) {
30586
- this.upsertTask(task);
30893
+ this.upsertTaskWithFtsRecovery(task);
30587
30894
  await this.writeTaskJsonFile(dir, task);
30588
30895
  }
30589
30896
  /**
@@ -30596,7 +30903,7 @@ ${outcome}`;
30596
30903
  */
30597
30904
  async atomicWriteTaskJsonWithAudit(dir, task, auditInput) {
30598
30905
  this.db.transaction(() => {
30599
- this.upsertTask(task);
30906
+ this.upsertTaskWithFtsRecovery(task);
30600
30907
  if (auditInput) {
30601
30908
  const eventId = randomUUID6();
30602
30909
  const timestamp = auditInput.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
@@ -31544,6 +31851,14 @@ ${newTask.description}
31544
31851
  if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress") || fromColumn === "done" && (toColumn === "todo" || toColumn === "triage")) {
31545
31852
  task.workflowStepResults = void 0;
31546
31853
  }
31854
+ if (fromColumn === "in-review" && toColumn === "todo") {
31855
+ task.branch = void 0;
31856
+ task.baseBranch = void 0;
31857
+ task.baseCommitSha = void 0;
31858
+ task.summary = void 0;
31859
+ task.recoveryRetryCount = void 0;
31860
+ task.nextRecoveryAt = void 0;
31861
+ }
31547
31862
  await this.atomicWriteTaskJson(dir, task);
31548
31863
  if (this.isWatching) this.taskCache.set(id, { ...task });
31549
31864
  this.emit("task:moved", { task, from: fromColumn, to: toColumn });
@@ -31557,6 +31872,12 @@ ${newTask.description}
31557
31872
  }
31558
31873
  const dir = this.taskDir(id);
31559
31874
  const task = await this.readTaskJson(dir);
31875
+ if (updates.nodeId !== void 0) {
31876
+ const validation = validateNodeOverrideChange(task, updates.nodeId ?? null);
31877
+ if (!validation.allowed) {
31878
+ throw new Error(validation.message);
31879
+ }
31880
+ }
31560
31881
  if (!task.log) {
31561
31882
  task.log = [];
31562
31883
  }
@@ -31619,6 +31940,16 @@ ${newTask.description}
31619
31940
  } else if (updates.nodeId !== void 0) {
31620
31941
  task.nodeId = updates.nodeId;
31621
31942
  }
31943
+ if (updates.effectiveNodeId === null) {
31944
+ task.effectiveNodeId = void 0;
31945
+ } else if (updates.effectiveNodeId !== void 0) {
31946
+ task.effectiveNodeId = updates.effectiveNodeId;
31947
+ }
31948
+ if (updates.effectiveNodeSource === null) {
31949
+ task.effectiveNodeSource = void 0;
31950
+ } else if (updates.effectiveNodeSource !== void 0) {
31951
+ task.effectiveNodeSource = updates.effectiveNodeSource;
31952
+ }
31622
31953
  if (updates.checkedOutBy === null) {
31623
31954
  task.checkedOutBy = void 0;
31624
31955
  task.checkedOutAt = void 0;
@@ -33852,7 +34183,7 @@ ${stepsSection}`;
33852
34183
  healthCheck() {
33853
34184
  try {
33854
34185
  this.db.prepare("SELECT 1").get();
33855
- return true;
34186
+ return this.db.checkFts5Integrity();
33856
34187
  } catch {
33857
34188
  return false;
33858
34189
  }
@@ -34474,6 +34805,10 @@ var init_board = __esm({
34474
34805
 
34475
34806
  // ../core/src/gh-cli.ts
34476
34807
  import { execFileSync, execFile as execFile2 } from "node:child_process";
34808
+ function normalizeRunGhOptions(opts) {
34809
+ if (typeof opts === "string") return { cwd: opts };
34810
+ return opts ?? {};
34811
+ }
34477
34812
  function isGhAvailable() {
34478
34813
  try {
34479
34814
  execFileSync("gh", ["--version"], {
@@ -34513,19 +34848,55 @@ function runGh(args, cwd) {
34513
34848
  throw error;
34514
34849
  }
34515
34850
  }
34516
- function runGhAsync(args, cwd) {
34851
+ function runGhAsync(args, cwdOrOptions) {
34852
+ const { cwd, signal: externalSignal, timeoutMs = DEFAULT_GH_TIMEOUT_MS } = normalizeRunGhOptions(cwdOrOptions);
34517
34853
  return new Promise((resolve17, reject) => {
34854
+ if (externalSignal?.aborted) {
34855
+ reject(makeGhError(`gh command aborted: ${describeAbortReason(externalSignal.reason)}`, "ABORT_ERR"));
34856
+ return;
34857
+ }
34858
+ const controller = new AbortController();
34859
+ let timedOut = false;
34860
+ let externalAborted = false;
34861
+ const onExternalAbort = () => {
34862
+ externalAborted = true;
34863
+ controller.abort();
34864
+ };
34865
+ if (externalSignal) {
34866
+ externalSignal.addEventListener("abort", onExternalAbort, { once: true });
34867
+ }
34868
+ const timer = timeoutMs > 0 ? setTimeout(() => {
34869
+ timedOut = true;
34870
+ controller.abort();
34871
+ }, timeoutMs) : void 0;
34872
+ const cleanup = () => {
34873
+ if (timer) clearTimeout(timer);
34874
+ if (externalSignal) externalSignal.removeEventListener("abort", onExternalAbort);
34875
+ };
34518
34876
  execFile2(
34519
34877
  "gh",
34520
34878
  args,
34521
34879
  {
34522
34880
  encoding: "utf-8",
34523
- cwd
34881
+ cwd,
34882
+ signal: controller.signal
34524
34883
  },
34525
34884
  (error, stdout, stderr) => {
34885
+ cleanup();
34526
34886
  if (error) {
34527
- const ghError = new Error(`gh command failed: ${error.message}`);
34528
- ghError.code = error.code ?? null;
34887
+ const isAbort = error.code === "ABORT_ERR" || error.name === "AbortError";
34888
+ let message;
34889
+ if (timedOut) {
34890
+ message = `gh command timed out after ${timeoutMs}ms`;
34891
+ } else if (isAbort && externalAborted) {
34892
+ message = `gh command aborted: ${describeAbortReason(externalSignal?.reason)}`;
34893
+ } else if (isAbort) {
34894
+ message = "gh command aborted";
34895
+ } else {
34896
+ message = `gh command failed: ${error.message}`;
34897
+ }
34898
+ const ghError = new Error(message);
34899
+ ghError.code = error.code ?? (isAbort ? "ABORT_ERR" : null);
34529
34900
  ghError.stdout = stdout ?? "";
34530
34901
  ghError.stderr = stderr ?? "";
34531
34902
  reject(ghError);
@@ -34536,6 +34907,18 @@ function runGhAsync(args, cwd) {
34536
34907
  );
34537
34908
  });
34538
34909
  }
34910
+ function makeGhError(message, code) {
34911
+ const err = new Error(message);
34912
+ err.code = code;
34913
+ err.stdout = "";
34914
+ err.stderr = "";
34915
+ return err;
34916
+ }
34917
+ function describeAbortReason(reason) {
34918
+ if (reason instanceof Error) return reason.message;
34919
+ if (typeof reason === "string") return reason;
34920
+ return "aborted";
34921
+ }
34539
34922
  function runGhJson(args, cwd) {
34540
34923
  const jsonArgs = args.includes("--json") ? args : [...args, "--json"];
34541
34924
  const output = runGh(jsonArgs, cwd);
@@ -34545,9 +34928,9 @@ function runGhJson(args, cwd) {
34545
34928
  throw new Error(`Failed to parse gh JSON output: ${err instanceof Error ? err.message : String(err)}`);
34546
34929
  }
34547
34930
  }
34548
- async function runGhJsonAsync(args, cwd) {
34931
+ async function runGhJsonAsync(args, cwdOrOptions) {
34549
34932
  const jsonArgs = args.includes("--json") ? args : [...args, "--json"];
34550
- const output = await runGhAsync(jsonArgs, cwd);
34933
+ const output = await runGhAsync(jsonArgs, cwdOrOptions);
34551
34934
  try {
34552
34935
  return JSON.parse(output);
34553
34936
  } catch (err) {
@@ -34605,9 +34988,11 @@ function getCurrentRepo(cwd) {
34605
34988
  return null;
34606
34989
  }
34607
34990
  }
34991
+ var DEFAULT_GH_TIMEOUT_MS;
34608
34992
  var init_gh_cli = __esm({
34609
34993
  "../core/src/gh-cli.ts"() {
34610
34994
  "use strict";
34995
+ DEFAULT_GH_TIMEOUT_MS = 3e4;
34611
34996
  }
34612
34997
  });
34613
34998
 
@@ -35071,6 +35456,83 @@ var init_routine_store = __esm({
35071
35456
  }
35072
35457
  });
35073
35458
 
35459
+ // ../core/src/notification/dispatcher.ts
35460
+ var NotificationDispatcher;
35461
+ var init_dispatcher = __esm({
35462
+ "../core/src/notification/dispatcher.ts"() {
35463
+ "use strict";
35464
+ NotificationDispatcher = class {
35465
+ constructor(config = {}) {
35466
+ this.config = config;
35467
+ }
35468
+ providers = /* @__PURE__ */ new Map();
35469
+ registerProvider(provider) {
35470
+ this.providers.set(provider.getProviderId(), provider);
35471
+ }
35472
+ unregisterProvider(providerId) {
35473
+ this.providers.delete(providerId);
35474
+ }
35475
+ getProviders() {
35476
+ return [...this.providers.values()];
35477
+ }
35478
+ async dispatch(event, payload) {
35479
+ const providers = this.getProviders().filter(
35480
+ (provider) => provider.isEventSupported(event)
35481
+ );
35482
+ const results = await Promise.all(
35483
+ providers.map(async (provider) => {
35484
+ const providerId = provider.getProviderId();
35485
+ try {
35486
+ return await provider.sendNotification(event, payload);
35487
+ } catch (error) {
35488
+ const message = error instanceof Error ? error.message : String(error);
35489
+ console.warn(
35490
+ `[notification-dispatcher] Provider ${providerId} failed for event ${event}: ${message}`
35491
+ );
35492
+ return { success: false, providerId, error: message };
35493
+ }
35494
+ })
35495
+ );
35496
+ return results;
35497
+ }
35498
+ async initializeAll() {
35499
+ await Promise.all(
35500
+ this.getProviders().map(async (provider) => {
35501
+ if (!provider.initialize) {
35502
+ return;
35503
+ }
35504
+ try {
35505
+ await provider.initialize(this.config);
35506
+ } catch (error) {
35507
+ const message = error instanceof Error ? error.message : String(error);
35508
+ console.warn(
35509
+ `[notification-dispatcher] Provider ${provider.getProviderId()} initialization failed: ${message}`
35510
+ );
35511
+ }
35512
+ })
35513
+ );
35514
+ }
35515
+ async shutdownAll() {
35516
+ await Promise.all(
35517
+ this.getProviders().map(async (provider) => {
35518
+ if (!provider.shutdown) {
35519
+ return;
35520
+ }
35521
+ try {
35522
+ await provider.shutdown();
35523
+ } catch (error) {
35524
+ const message = error instanceof Error ? error.message : String(error);
35525
+ console.warn(
35526
+ `[notification-dispatcher] Provider ${provider.getProviderId()} shutdown failed: ${message}`
35527
+ );
35528
+ }
35529
+ })
35530
+ );
35531
+ }
35532
+ };
35533
+ }
35534
+ });
35535
+
35074
35536
  // ../core/src/plugin-loader.ts
35075
35537
  import { basename as basename6, dirname as dirname5, extname, isAbsolute as isAbsolute5, resolve as resolve7 } from "node:path";
35076
35538
  import { copyFile, rm } from "node:fs/promises";
@@ -48046,6 +48508,7 @@ var init_chat_store = __esm({
48046
48508
  content: row.content,
48047
48509
  thinkingOutput: row.thinkingOutput ?? null,
48048
48510
  metadata: fromJson(row.metadata) ?? null,
48511
+ attachments: fromJson(row.attachments) ?? void 0,
48049
48512
  createdAt: row.createdAt
48050
48513
  };
48051
48514
  }
@@ -48264,11 +48727,12 @@ var init_chat_store = __esm({
48264
48727
  content: input.content,
48265
48728
  thinkingOutput: input.thinkingOutput ?? null,
48266
48729
  metadata: input.metadata ?? null,
48730
+ attachments: input.attachments,
48267
48731
  createdAt: now
48268
48732
  };
48269
48733
  this.db.prepare(`
48270
- INSERT INTO chat_messages (id, sessionId, role, content, thinkingOutput, metadata, createdAt)
48271
- VALUES (?, ?, ?, ?, ?, ?, ?)
48734
+ INSERT INTO chat_messages (id, sessionId, role, content, thinkingOutput, metadata, attachments, createdAt)
48735
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
48272
48736
  `).run(
48273
48737
  message.id,
48274
48738
  message.sessionId,
@@ -48276,6 +48740,7 @@ var init_chat_store = __esm({
48276
48740
  message.content,
48277
48741
  message.thinkingOutput,
48278
48742
  toJsonNullable(message.metadata),
48743
+ toJsonNullable(message.attachments),
48279
48744
  message.createdAt
48280
48745
  );
48281
48746
  this.db.prepare("UPDATE chat_sessions SET updatedAt = ? WHERE id = ?").run(now, sessionId);
@@ -48283,6 +48748,28 @@ var init_chat_store = __esm({
48283
48748
  this.emit("chat:message:added", message);
48284
48749
  return message;
48285
48750
  }
48751
+ /**
48752
+ * Append a file attachment metadata record to an existing message.
48753
+ */
48754
+ addMessageAttachment(sessionId, messageId, attachment) {
48755
+ const message = this.getMessage(messageId);
48756
+ if (!message || message.sessionId !== sessionId) {
48757
+ throw new Error(`Message ${messageId} not found in session ${sessionId}`);
48758
+ }
48759
+ const updatedAttachments = [...message.attachments ?? [], attachment];
48760
+ this.db.prepare(`
48761
+ UPDATE chat_messages
48762
+ SET attachments = ?
48763
+ WHERE id = ?
48764
+ `).run(toJsonNullable(updatedAttachments), messageId);
48765
+ const updated = this.getMessage(messageId);
48766
+ if (!updated) {
48767
+ throw new Error(`Failed to update message ${messageId}`);
48768
+ }
48769
+ this.db.bumpLastModified();
48770
+ this.emit("chat:message:updated", updated);
48771
+ return updated;
48772
+ }
48286
48773
  /**
48287
48774
  * Get messages for a chat session with optional filtering.
48288
48775
  *
@@ -48447,8 +48934,10 @@ __export(src_exports, {
48447
48934
  MessageStore: () => MessageStore,
48448
48935
  MigrationCoordinator: () => MigrationCoordinator,
48449
48936
  MissionStore: () => MissionStore,
48937
+ NOTIFICATION_EVENTS: () => NOTIFICATION_EVENTS,
48450
48938
  NodeConnection: () => NodeConnection,
48451
48939
  NodeDiscovery: () => NodeDiscovery,
48940
+ NotificationDispatcher: () => NotificationDispatcher,
48452
48941
  PROJECT_SETTINGS_KEYS: () => PROJECT_SETTINGS_KEYS,
48453
48942
  PROMPT_KEY_CATALOG: () => PROMPT_KEY_CATALOG,
48454
48943
  PluginLoader: () => PluginLoader,
@@ -48596,6 +49085,7 @@ __export(src_exports, {
48596
49085
  mergeInsights: () => mergeInsights,
48597
49086
  migrateFromLegacy: () => migrateFromLegacy,
48598
49087
  moveRoadmapFeature: () => moveRoadmapFeature,
49088
+ normalizeMergeConflictStrategy: () => normalizeMergeConflictStrategy,
48599
49089
  normalizePermissions: () => normalizePermissions,
48600
49090
  normalizeRoadmapFeatureOrder: () => normalizeRoadmapFeatureOrder,
48601
49091
  normalizeRoadmapMilestoneOrder: () => normalizeRoadmapMilestoneOrder,
@@ -48635,12 +49125,20 @@ __export(src_exports, {
48635
49125
  renderMemoryAuditMarkdown: () => renderMemoryAuditMarkdown,
48636
49126
  resolveAgentPrompt: () => resolveAgentPrompt,
48637
49127
  resolveDependencyOrder: () => resolveDependencyOrder,
49128
+ resolveExecutionSettingsModel: () => resolveExecutionSettingsModel,
48638
49129
  resolveGlobalDir: () => resolveGlobalDir,
48639
49130
  resolveMemoryBackend: () => resolveMemoryBackend,
48640
49131
  resolveMemoryInstructionContext: () => resolveMemoryInstructionContext,
48641
49132
  resolvePiExtensionProjectRoot: () => resolvePiExtensionProjectRoot,
49133
+ resolvePlanningSettingsModel: () => resolvePlanningSettingsModel,
49134
+ resolveProjectDefaultModel: () => resolveProjectDefaultModel,
48642
49135
  resolvePrompt: () => resolvePrompt,
48643
49136
  resolveRolePrompts: () => resolveRolePrompts,
49137
+ resolveTaskExecutionModel: () => resolveTaskExecutionModel,
49138
+ resolveTaskPlanningModel: () => resolveTaskPlanningModel,
49139
+ resolveTaskValidatorModel: () => resolveTaskValidatorModel,
49140
+ resolveTitleSummarizerSettingsModel: () => resolveTitleSummarizerSettingsModel,
49141
+ resolveValidatorSettingsModel: () => resolveValidatorSettingsModel,
48644
49142
  runBackupCommand: () => runBackupCommand,
48645
49143
  runCommandAsync: () => runCommandAsync,
48646
49144
  runGh: () => runGh,
@@ -48670,6 +49168,7 @@ __export(src_exports, {
48670
49168
  validateDescription: () => validateDescription,
48671
49169
  validateImportData: () => validateImportData,
48672
49170
  validateMessageMetadata: () => validateMessageMetadata,
49171
+ validateNodeOverrideChange: () => validateNodeOverrideChange,
48673
49172
  validatePluginManifest: () => validatePluginManifest,
48674
49173
  validateUnavailableNodePolicy: () => validateUnavailableNodePolicy,
48675
49174
  writeAgentMemoryFile: () => writeAgentMemoryFile,
@@ -48704,15 +49203,19 @@ var init_src = __esm({
48704
49203
  init_automation();
48705
49204
  init_automation_store();
48706
49205
  init_run_command();
49206
+ init_node_override_guard();
48707
49207
  init_settings_validation();
48708
49208
  init_routine();
48709
49209
  init_routine_store();
49210
+ init_dispatcher();
49211
+ init_types();
48710
49212
  init_plugin_types();
48711
49213
  init_plugin_store();
48712
49214
  init_plugin_loader();
48713
49215
  init_backup();
48714
49216
  init_settings_export();
48715
49217
  init_ai_summarize();
49218
+ init_model_resolution();
48716
49219
  init_memory_compaction();
48717
49220
  init_roadmap_ordering();
48718
49221
  init_task_priority();
@@ -50291,17 +50794,6 @@ var init_auth_storage = __esm({
50291
50794
  });
50292
50795
 
50293
50796
  // ../engine/src/pi.ts
50294
- var pi_exports = {};
50295
- __export(pi_exports, {
50296
- COMPACTION_FALLBACK_INSTRUCTIONS: () => COMPACTION_FALLBACK_INSTRUCTIONS,
50297
- compactSessionContext: () => compactSessionContext,
50298
- createFnAgent: () => createFnAgent2,
50299
- describeModel: () => describeModel,
50300
- getHostExtensionPaths: () => getHostExtensionPaths,
50301
- promptWithFallback: () => promptWithFallback,
50302
- setHostExtensionPaths: () => setHostExtensionPaths,
50303
- wrapToolsWithBoundary: () => wrapToolsWithBoundary
50304
- });
50305
50797
  import { existsSync as existsSync20, readFileSync as readFileSync6 } from "node:fs";
50306
50798
  import { exec } from "node:child_process";
50307
50799
  import { promisify as promisify2 } from "node:util";
@@ -51225,16 +51717,14 @@ function wrapPluginRuntime(instance, runtimeId, runtimeName) {
51225
51717
  if (typeof adapter.promptWithFallback === "function") {
51226
51718
  return adapter.promptWithFallback(session, prompt, options);
51227
51719
  }
51228
- const { promptWithFallback: pwf } = await Promise.resolve().then(() => (init_pi(), pi_exports));
51229
- return pwf(session, prompt, options);
51720
+ return promptWithFallback(session, prompt, options);
51230
51721
  },
51231
51722
  describeModel: (session) => {
51232
51723
  const adapter = instance;
51233
51724
  if (typeof adapter.describeModel === "function") {
51234
51725
  return adapter.describeModel(session);
51235
51726
  }
51236
- const { describeModel: dm } = (init_pi(), __toCommonJS(pi_exports));
51237
- return dm(session);
51727
+ return describeModel(session);
51238
51728
  }
51239
51729
  };
51240
51730
  }
@@ -51305,36 +51795,19 @@ var init_runtime_resolution = __esm({
51305
51795
  "../engine/src/runtime-resolution.ts"() {
51306
51796
  "use strict";
51307
51797
  init_logger2();
51798
+ init_pi();
51308
51799
  runtimeLog2 = createLogger2("runtime-resolver");
51309
- DefaultPiRuntime = class _DefaultPiRuntime {
51800
+ DefaultPiRuntime = class {
51310
51801
  id = "pi";
51311
51802
  name = "Default PI Runtime";
51312
- // Synchronous cached describeModel function
51313
- static describeModelFn = null;
51314
- /**
51315
- * Create an agent session using the default pi implementation.
51316
- */
51317
51803
  async createSession(options) {
51318
- const { createFnAgent: createFnAgent5 } = await Promise.resolve().then(() => (init_pi(), pi_exports));
51319
- return createFnAgent5(options);
51804
+ return createFnAgent2(options);
51320
51805
  }
51321
- /**
51322
- * Prompt with automatic retry and compaction.
51323
- * Delegates to the existing promptWithFallback implementation.
51324
- */
51325
51806
  async promptWithFallback(session, prompt, options) {
51326
- const { promptWithFallback: pwf } = await Promise.resolve().then(() => (init_pi(), pi_exports));
51327
- return pwf(session, prompt, options);
51807
+ return promptWithFallback(session, prompt, options);
51328
51808
  }
51329
- /**
51330
- * Get model description from session.
51331
- */
51332
51809
  describeModel(session) {
51333
- if (!_DefaultPiRuntime.describeModelFn) {
51334
- const { describeModel: describeModel2 } = (init_pi(), __toCommonJS(pi_exports));
51335
- _DefaultPiRuntime.describeModelFn = describeModel2;
51336
- }
51337
- return _DefaultPiRuntime.describeModelFn(session);
51810
+ return describeModel(session);
51338
51811
  }
51339
51812
  };
51340
51813
  defaultPiRuntimeInstance = null;
@@ -51378,12 +51851,10 @@ async function createResolvedAgentSession(options) {
51378
51851
  };
51379
51852
  }
51380
51853
  async function promptWithAutoRetry(session, prompt, options) {
51381
- const { promptWithFallback: pwf } = await Promise.resolve().then(() => (init_pi(), pi_exports));
51382
- return pwf(session, prompt, options);
51854
+ return promptWithFallback(session, prompt, options);
51383
51855
  }
51384
51856
  async function describeAgentModel(session) {
51385
- const { describeModel: dm } = await Promise.resolve().then(() => (init_pi(), pi_exports));
51386
- return dm(session);
51857
+ return describeModel(session);
51387
51858
  }
51388
51859
  var sessionLog;
51389
51860
  var init_agent_session_helpers = __esm({
@@ -51391,6 +51862,7 @@ var init_agent_session_helpers = __esm({
51391
51862
  "use strict";
51392
51863
  init_runtime_resolution();
51393
51864
  init_logger2();
51865
+ init_pi();
51394
51866
  sessionLog = createLogger2("agent-session");
51395
51867
  }
51396
51868
  });
@@ -54967,21 +55439,31 @@ ${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
54967
55439
  return false;
54968
55440
  }
54969
55441
  }
54970
- async function amendMergeCommitWithFixes(rootDir, taskId, authorArg) {
55442
+ function resetMergeWithWarn(rootDir, taskId, label) {
55443
+ try {
55444
+ execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
55445
+ } catch (err) {
55446
+ const msg = err instanceof Error ? err.message : String(err);
55447
+ mergerLog.warn(`${taskId}: git reset --merge cleanup failed during ${label}: ${msg}`);
55448
+ }
55449
+ }
55450
+ function buildDeterministicMergeMessage(params) {
55451
+ const { taskId, branch, commitLog, includeTaskId } = params;
55452
+ const prefix = includeTaskId ? `feat(${taskId})` : "feat";
55453
+ const subject = `${prefix}: merge ${branch}`;
55454
+ const body = commitLog && commitLog.trim().length > 0 ? commitLog.trim() : `- merge ${branch}`;
55455
+ const escape = (s) => s.replace(/(["\\$`])/g, "\\$1");
55456
+ return {
55457
+ subjectArg: `-m "${escape(subject)}"`,
55458
+ bodyArg: `-m "${escape(body)}"`
55459
+ };
55460
+ }
55461
+ async function commitOrAmendMergeWithFixes(rootDir, taskId, branch, commitLog, includeTaskId, preAttemptHeadSha, authorArg) {
54971
55462
  try {
54972
- const { stdout: stagedFiles } = await execAsync2("git diff --cached --name-only", {
54973
- cwd: rootDir,
54974
- encoding: "utf-8"
54975
- });
54976
55463
  const { stdout: unstagedFiles } = await execAsync2("git diff --name-only", {
54977
55464
  cwd: rootDir,
54978
55465
  encoding: "utf-8"
54979
55466
  });
54980
- const hasChanges = stagedFiles.trim().length > 0 || unstagedFiles.trim().length > 0;
54981
- if (!hasChanges) {
54982
- mergerLog.log(`${taskId}: no changes to amend after verification fix`);
54983
- return false;
54984
- }
54985
55467
  if (unstagedFiles.trim().length > 0) {
54986
55468
  await execAsync2("git add -A", { cwd: rootDir });
54987
55469
  }
@@ -54989,12 +55471,10 @@ async function amendMergeCommitWithFixes(rootDir, taskId, authorArg) {
54989
55471
  cwd: rootDir,
54990
55472
  encoding: "utf-8"
54991
55473
  });
54992
- const gitlinkPaths = [];
54993
55474
  for (const line of staged.split("\n")) {
54994
55475
  const match = line.match(/^:\d{6} 160000 [^\t]+\t(.+)$/);
54995
- if (match) gitlinkPaths.push(match[1]);
54996
- }
54997
- for (const path2 of gitlinkPaths) {
55476
+ if (!match) continue;
55477
+ const path2 = match[1];
54998
55478
  mergerLog.warn(`${taskId}: refusing to stage gitlink "${path2}" (project uses no submodules \u2014 likely a nested worktree). Unstaging.`);
54999
55479
  try {
55000
55480
  await execAsync2(`git reset HEAD -- "${path2}"`, { cwd: rootDir });
@@ -55007,15 +55487,43 @@ async function amendMergeCommitWithFixes(rootDir, taskId, authorArg) {
55007
55487
  cwd: rootDir,
55008
55488
  encoding: "utf-8"
55009
55489
  });
55010
- if (finalStaged.trim().length > 0) {
55011
- await execAsync2(`git commit --amend --no-edit${authorArg}`, { cwd: rootDir });
55012
- mergerLog.log(`${taskId}: amended merge commit with verification fixes`);
55490
+ const hasStaged = finalStaged.trim().length > 0;
55491
+ const { stdout: currentHeadOut } = await execAsync2("git rev-parse HEAD", {
55492
+ cwd: rootDir,
55493
+ encoding: "utf-8"
55494
+ });
55495
+ const currentHead = currentHeadOut.trim();
55496
+ const headMoved = currentHead !== preAttemptHeadSha;
55497
+ if (!hasStaged && !headMoved) {
55498
+ mergerLog.warn(
55499
+ `${taskId}: refusing to record merge \u2014 no commit was created and no changes are staged. This usually means the AI agent never ran git commit and the in-merge fix had nothing to add.`
55500
+ );
55501
+ return false;
55502
+ }
55503
+ const { subjectArg, bodyArg } = buildDeterministicMergeMessage({
55504
+ taskId,
55505
+ branch,
55506
+ commitLog,
55507
+ includeTaskId
55508
+ });
55509
+ const trailerArg = buildTaskIdTrailerArg(taskId);
55510
+ if (!headMoved) {
55511
+ await execAsync2(
55512
+ `git commit ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
55513
+ { cwd: rootDir }
55514
+ );
55515
+ mergerLog.log(`${taskId}: created fresh merge commit after verification fix (no prior commit to amend)`);
55013
55516
  return true;
55014
55517
  }
55015
- return false;
55518
+ await execAsync2(
55519
+ `git commit --amend ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
55520
+ { cwd: rootDir }
55521
+ );
55522
+ mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`);
55523
+ return true;
55016
55524
  } catch (err) {
55017
55525
  const errorMessage = err instanceof Error ? err.message : String(err);
55018
- mergerLog.warn(`${taskId}: failed to amend merge commit: ${errorMessage}`);
55526
+ mergerLog.warn(`${taskId}: failed to finalize merge commit: ${errorMessage}`);
55019
55527
  return false;
55020
55528
  }
55021
55529
  }
@@ -55114,24 +55622,26 @@ async function resolveTaskDiffBaseRef({
55114
55622
  baseBranch,
55115
55623
  baseCommitSha
55116
55624
  }) {
55117
- const resolvedBaseBranch = baseBranch?.trim() || "main";
55625
+ const resolvedBaseBranch = baseBranch?.trim() || (baseCommitSha ? void 0 : "main");
55118
55626
  const quotedHeadRef = quoteArg(headRef);
55119
55627
  let mergeBase;
55120
- try {
55628
+ if (resolvedBaseBranch) {
55121
55629
  try {
55122
- const { stdout } = await execAsync2(`git merge-base ${quotedHeadRef} ${quoteArg(resolvedBaseBranch)}`, {
55123
- cwd,
55124
- encoding: "utf-8"
55125
- });
55126
- mergeBase = stdout.trim() || void 0;
55630
+ try {
55631
+ const { stdout } = await execAsync2(`git merge-base ${quotedHeadRef} ${quoteArg(resolvedBaseBranch)}`, {
55632
+ cwd,
55633
+ encoding: "utf-8"
55634
+ });
55635
+ mergeBase = stdout.trim() || void 0;
55636
+ } catch {
55637
+ const { stdout } = await execAsync2(`git merge-base ${quotedHeadRef} ${quoteArg(`origin/${resolvedBaseBranch}`)}`, {
55638
+ cwd,
55639
+ encoding: "utf-8"
55640
+ });
55641
+ mergeBase = stdout.trim() || void 0;
55642
+ }
55127
55643
  } catch {
55128
- const { stdout } = await execAsync2(`git merge-base ${quotedHeadRef} ${quoteArg(`origin/${resolvedBaseBranch}`)}`, {
55129
- cwd,
55130
- encoding: "utf-8"
55131
- });
55132
- mergeBase = stdout.trim() || void 0;
55133
55644
  }
55134
- } catch {
55135
55645
  }
55136
55646
  if (mergeBase) {
55137
55647
  try {
@@ -55238,12 +55748,37 @@ async function resolveTrivialWhitespace(filePath, cwd) {
55238
55748
  throw new Error(`Failed to auto-resolve ${filePath} trivial conflict: ${error}`);
55239
55749
  }
55240
55750
  }
55751
+ function buildTaskIdTrailerArg(taskId) {
55752
+ return ` -m "${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}"`;
55753
+ }
55754
+ async function ensureTaskIdTrailerOnHead(rootDir, taskId) {
55755
+ try {
55756
+ const { stdout: existingMessage } = await execAsync2("git log -1 --pretty=%B", {
55757
+ cwd: rootDir,
55758
+ encoding: "utf-8"
55759
+ });
55760
+ const trailerLine = `${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`;
55761
+ if (existingMessage.includes(trailerLine)) return;
55762
+ await execAsync2(
55763
+ `git -c trailer.ifExists=addIfDifferent commit --amend --no-edit --trailer "${trailerLine}"`,
55764
+ { cwd: rootDir }
55765
+ );
55766
+ } catch (err) {
55767
+ const msg = err instanceof Error ? err.message : String(err);
55768
+ mergerLog.warn(`${taskId}: failed to add ${FUSION_TASK_ID_TRAILER_KEY} trailer to HEAD (${msg}) \u2014 relying on subject grep for recovery`);
55769
+ }
55770
+ }
55241
55771
  function getCommitAuthorArg(settings) {
55242
55772
  if (settings.commitAuthorEnabled === false) return "";
55243
55773
  const name = settings.commitAuthorName || "Fusion";
55244
55774
  const email = settings.commitAuthorEmail || "noreply@runfusion.ai";
55245
55775
  return ` --author="${name} <${email}>"`;
55246
55776
  }
55777
+ function buildSourceIssueRef(sourceIssue) {
55778
+ if (!sourceIssue || sourceIssue.provider !== "github") return "";
55779
+ if (!sourceIssue.repository || !sourceIssue.issueNumber) return "";
55780
+ return `${sourceIssue.repository}#${sourceIssue.issueNumber}`;
55781
+ }
55247
55782
  function buildMergeSystemPrompt(includeTaskId, agentPrompts, authorArg) {
55248
55783
  const commitFormat = includeTaskId ? `\`\`\`
55249
55784
  git commit -m "<type>(<scope>): <summary>" -m "<body>"${authorArg || ""}
@@ -55254,6 +55789,7 @@ Message format:
55254
55789
  - **Scope:** the task ID (e.g., KB-001)
55255
55790
  - **Summary:** one line describing what the squash brings in (imperative mood)
55256
55791
  - **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
55792
+ - **GitHub reference:** when the prompt includes a source issue reference, add \`Ref: owner/repo#N\` to the commit body
55257
55793
  ${authorArg ? `- **Author:** Always include the --author flag as shown in the example above.` : ""}
55258
55794
 
55259
55795
  Example:
@@ -55271,6 +55807,7 @@ Message format:
55271
55807
  - **Type:** feat, fix, refactor, docs, test, chore
55272
55808
  - **Summary:** one line describing what the squash brings in (imperative mood)
55273
55809
  - **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
55810
+ - **GitHub reference:** when the prompt includes a source issue reference, add \`Ref: owner/repo#N\` to the commit body
55274
55811
  ${authorArg ? `- **Author:** Always include the --author flag as shown in the example above.` : ""}
55275
55812
  Do NOT include a scope in the commit message type.
55276
55813
 
@@ -55654,6 +56191,7 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55654
56191
  throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`);
55655
56192
  }
55656
56193
  const branch = task.branch || `fusion/${taskId.toLowerCase()}`;
56194
+ const sourceIssueRef = buildSourceIssueRef(task.sourceIssue);
55657
56195
  const worktreePath = task.worktree;
55658
56196
  const result = {
55659
56197
  task,
@@ -55676,6 +56214,13 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55676
56214
  const settings = await store.getSettings();
55677
56215
  const includeTaskId = settings.includeTaskIdInCommit !== false;
55678
56216
  const smartConflictResolution = (settings.smartConflictResolution ?? settings.autoResolveConflicts) !== false;
56217
+ const mergeConflictStrategy = normalizeMergeConflictStrategy(
56218
+ settings.mergeConflictStrategy
56219
+ );
56220
+ if (mergeConflictStrategy === "smart-prefer-main" || mergeConflictStrategy === "smart-prefer-branch") {
56221
+ await tryFastForwardFromOrigin(rootDir, taskId);
56222
+ }
56223
+ let mergeWasEmpty = false;
55679
56224
  try {
55680
56225
  execSync(`git rev-parse --verify "${branch}"`, {
55681
56226
  cwd: rootDir,
@@ -55735,6 +56280,57 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55735
56280
  mergerLog.warn(`${taskId}: unable to verify/checkout main branch \u2014 proceeding on current HEAD`);
55736
56281
  }
55737
56282
  }
56283
+ let rebaseHappened = false;
56284
+ let preferMainRebaseFailureMessage;
56285
+ if (settings.worktreeRebaseBeforeMerge === false && settings.worktreeRebaseLocalBase === false && mergeConflictStrategy === "smart-prefer-main") {
56286
+ throw new Error(
56287
+ `Incompatible settings for ${taskId}: mergeConflictStrategy="smart-prefer-main" requires at least one of worktreeRebaseBeforeMerge or worktreeRebaseLocalBase to remain enabled. The strategy relies on rebasing the branch onto current main to preserve main's deletions; with both disabled it can silently re-introduce branch-only content. Re-enable a rebase stage or switch to "smart-prefer-branch" / "ai-only".`
56288
+ );
56289
+ }
56290
+ async function runLocalBaseRebase(label) {
56291
+ if (!worktreePath) return;
56292
+ try {
56293
+ const { stdout: localHeadOut } = await execAsync2("git rev-parse HEAD", {
56294
+ cwd: rootDir,
56295
+ encoding: "utf-8"
56296
+ });
56297
+ const localHead = localHeadOut.trim();
56298
+ if (!localHead) return;
56299
+ let alreadyContains = false;
56300
+ try {
56301
+ await execAsync2(`git merge-base --is-ancestor "${localHead}" HEAD`, { cwd: worktreePath });
56302
+ alreadyContains = true;
56303
+ } catch {
56304
+ }
56305
+ if (alreadyContains) {
56306
+ rebaseHappened = true;
56307
+ return;
56308
+ }
56309
+ throwIfAborted(options.signal, taskId);
56310
+ await execAsync2(`git rebase "${localHead}"`, { cwd: worktreePath });
56311
+ rebaseHappened = true;
56312
+ mergerLog.log(`${taskId}: rebased ${branch} onto local HEAD ${localHead.slice(0, 8)}${label ? ` (${label})` : ""}`);
56313
+ await store.appendAgentLog(
56314
+ taskId,
56315
+ `Pre-merge rebase: ${branch} \u2192 local HEAD ${localHead.slice(0, 8)}${label ? ` (${label})` : ""}`,
56316
+ "text",
56317
+ void 0,
56318
+ "merger"
56319
+ );
56320
+ } catch (localRebaseErr) {
56321
+ rethrowIfMergeAborted(localRebaseErr);
56322
+ const lmsg = localRebaseErr instanceof Error ? localRebaseErr.message : String(localRebaseErr);
56323
+ mergerLog.warn(`${taskId}: pre-merge rebase onto local HEAD failed (${lmsg}) \u2014 aborting and falling through`);
56324
+ try {
56325
+ await execAsync2("git rebase --abort", { cwd: worktreePath });
56326
+ } catch (abortError) {
56327
+ mergerLog.warn(`${taskId}: failed to abort local-HEAD rebase: ${getCommandErrorMessage(abortError)}`);
56328
+ }
56329
+ if (mergeConflictStrategy === "smart-prefer-main" && !preferMainRebaseFailureMessage) {
56330
+ preferMainRebaseFailureMessage = `Pre-merge rebase onto local HEAD aborted (${lmsg})`;
56331
+ }
56332
+ }
56333
+ }
55738
56334
  if (settings.worktreeRebaseBeforeMerge !== false) {
55739
56335
  try {
55740
56336
  let remote = settings.worktreeRebaseRemote?.trim();
@@ -55769,7 +56365,9 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55769
56365
  }
55770
56366
  }
55771
56367
  if (!remote) {
55772
- mergerLog.log(`${taskId}: no remote resolvable \u2014 skipping pre-merge rebase`);
56368
+ mergerLog.log(`${taskId}: no remote resolvable \u2014 skipping remote rebase stage (local-base stage may still run)`);
56369
+ } else if (!worktreePath) {
56370
+ mergerLog.warn(`${taskId}: no worktreePath \u2014 skipping remote rebase stage`);
55773
56371
  } else {
55774
56372
  throwIfAborted(options.signal, taskId);
55775
56373
  mergerLog.log(`${taskId}: fetching ${remote} before merge`);
@@ -55781,17 +56379,21 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55781
56379
  );
55782
56380
  const mainBranch = mainBranchOut.trim();
55783
56381
  const remoteRef = `${remote}/${mainBranch}`;
55784
- if (worktreePath) {
55785
- throwIfAborted(options.signal, taskId);
55786
- await execAsync2(`git rebase "${remoteRef}"`, { cwd: worktreePath });
55787
- mergerLog.log(`${taskId}: rebased ${branch} onto ${remoteRef}`);
55788
- } else {
55789
- mergerLog.warn(`${taskId}: no worktreePath \u2014 skipping task branch rebase`);
55790
- }
56382
+ throwIfAborted(options.signal, taskId);
56383
+ await execAsync2(`git rebase "${remoteRef}"`, { cwd: worktreePath });
56384
+ rebaseHappened = true;
56385
+ mergerLog.log(`${taskId}: rebased ${branch} onto ${remoteRef}`);
56386
+ await store.appendAgentLog(
56387
+ taskId,
56388
+ `Pre-merge rebase: ${branch} \u2192 ${remoteRef}`,
56389
+ "text",
56390
+ void 0,
56391
+ "merger"
56392
+ );
55791
56393
  } catch (rebaseErr) {
55792
56394
  rethrowIfMergeAborted(rebaseErr);
55793
56395
  const msg = rebaseErr instanceof Error ? rebaseErr.message : String(rebaseErr);
55794
- mergerLog.warn(`${taskId}: pre-merge rebase failed (${msg}) \u2014 aborting rebase and falling through to smart/AI merge`);
56396
+ mergerLog.warn(`${taskId}: pre-merge rebase failed (${msg}) \u2014 aborting rebase and falling through`);
55795
56397
  if (worktreePath) {
55796
56398
  try {
55797
56399
  await execAsync2("git rebase --abort", { cwd: worktreePath });
@@ -55799,14 +56401,32 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55799
56401
  mergerLog.warn(`${taskId}: failed to abort pre-merge rebase: ${getCommandErrorMessage(abortError)}`);
55800
56402
  }
55801
56403
  }
56404
+ if (mergeConflictStrategy === "smart-prefer-main") {
56405
+ preferMainRebaseFailureMessage = `Pre-merge rebase onto remote main aborted (${msg})`;
56406
+ }
55802
56407
  }
55803
56408
  }
55804
56409
  } catch (err) {
55805
56410
  rethrowIfMergeAborted(err);
55806
56411
  const msg = err instanceof Error ? err.message : String(err);
55807
- mergerLog.warn(`${taskId}: pre-merge rebase pipeline failed (${msg}) \u2014 proceeding without rebase`);
56412
+ mergerLog.warn(`${taskId}: pre-merge remote rebase pipeline failed (${msg}) \u2014 proceeding without remote rebase`);
55808
56413
  }
55809
56414
  }
56415
+ if (settings.worktreeRebaseLocalBase !== false && !preferMainRebaseFailureMessage) {
56416
+ await runLocalBaseRebase(
56417
+ settings.worktreeRebaseBeforeMerge === false ? "remote rebase disabled" : ""
56418
+ );
56419
+ }
56420
+ if (preferMainRebaseFailureMessage) {
56421
+ throw new Error(
56422
+ `${preferMainRebaseFailureMessage} for ${taskId}. Strategy "smart-prefer-main" requires a successful rebase to preserve main's deletions; falling through to a -X ours merge would silently re-introduce branch-only content. Resolve the rebase conflict manually, or switch mergeConflictStrategy to "smart-prefer-branch" / "ai-only".`
56423
+ );
56424
+ }
56425
+ if (mergeConflictStrategy === "smart-prefer-main" && !rebaseHappened) {
56426
+ mergerLog.warn(
56427
+ `${taskId}: smart-prefer-main ran without a successful pre-merge rebase (${worktreePath ? "no remote resolvable or rebase disabled" : "no worktreePath"}). Main's deletions may not be preserved if the branch re-introduces them.`
56428
+ );
56429
+ }
55810
56430
  const diffBaseRef = await resolveTaskDiffBaseRef({
55811
56431
  cwd: rootDir,
55812
56432
  headRef: branch,
@@ -55867,6 +56487,25 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55867
56487
  }
55868
56488
  const mergeAttempt = async (attemptNum) => {
55869
56489
  mergerLog.log(`${taskId}: merge attempt ${attemptNum}/3...`);
56490
+ const attemptLabel = attemptNum === 1 ? "Attempt 1: AI merge" : attemptNum === 2 ? "Attempt 2: auto-resolve known conflicts, then AI" : `Attempt 3: ${mergeConflictStrategy === "smart-prefer-main" ? "-X ours" : "-X theirs"} fallback`;
56491
+ await store.appendAgentLog(
56492
+ taskId,
56493
+ `Starting merge ${attemptLabel}`,
56494
+ "text",
56495
+ void 0,
56496
+ "merger"
56497
+ );
56498
+ let preAttemptHeadSha = "";
56499
+ try {
56500
+ const { stdout } = await execAsync2("git rev-parse HEAD", {
56501
+ cwd: rootDir,
56502
+ encoding: "utf-8"
56503
+ });
56504
+ preAttemptHeadSha = stdout.trim();
56505
+ } catch (err) {
56506
+ const msg = err instanceof Error ? err.message : String(err);
56507
+ mergerLog.warn(`${taskId}: failed to capture pre-attempt HEAD (${msg}) \u2014 verification-fix finalizer will fall back to amend`);
56508
+ }
55870
56509
  try {
55871
56510
  const success = await executeMergeAttempt({
55872
56511
  store,
@@ -55876,7 +56515,9 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55876
56515
  commitLog,
55877
56516
  diffStat,
55878
56517
  includeTaskId,
56518
+ sourceIssueRef,
55879
56519
  smartConflictResolution,
56520
+ mergeConflictStrategy,
55880
56521
  attemptNum,
55881
56522
  options,
55882
56523
  result,
@@ -55888,7 +56529,7 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55888
56529
  }, aiTracker);
55889
56530
  if (success) {
55890
56531
  result.attemptsMade = attemptNum;
55891
- result.resolutionStrategy = getResolutionStrategy(attemptNum, smartConflictResolution);
56532
+ result.resolutionStrategy = getResolutionStrategy(attemptNum, smartConflictResolution, mergeConflictStrategy);
55892
56533
  result.resolutionMethod = getResolutionMethod(result.resolutionStrategy, result.autoResolvedCount, aiTracker.aiWasInvoked);
55893
56534
  result.merged = true;
55894
56535
  return true;
@@ -55983,12 +56624,27 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55983
56624
  }
55984
56625
  if (fixSuccess) {
55985
56626
  const authorArg = getCommitAuthorArg(settings);
55986
- await amendMergeCommitWithFixes(rootDir, taskId, authorArg);
56627
+ const finalized = await commitOrAmendMergeWithFixes(
56628
+ rootDir,
56629
+ taskId,
56630
+ branch,
56631
+ commitLog,
56632
+ includeTaskId,
56633
+ preAttemptHeadSha,
56634
+ authorArg
56635
+ );
56636
+ if (!finalized) {
56637
+ resetMergeWithWarn(rootDir, taskId, "verification-fix finalize");
56638
+ throw new Error(
56639
+ `${taskId}: verification fix succeeded but no merge commit could be created \u2014 refusing to mark merge complete.`
56640
+ );
56641
+ }
55987
56642
  return true;
55988
56643
  }
55989
56644
  }
55990
56645
  }
55991
56646
  mergerLog.error(`${taskId}: deterministic verification failed \u2014 aborting merge (in-merge fix exhausted or disabled)`);
56647
+ resetMergeWithWarn(rootDir, taskId, "deterministic-verification rollback");
55992
56648
  throw error;
55993
56649
  }
55994
56650
  if (error.message?.includes("Build verification failed")) {
@@ -56059,7 +56715,21 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
56059
56715
  }
56060
56716
  if (fixSuccess) {
56061
56717
  const authorArg = getCommitAuthorArg(settings);
56062
- await amendMergeCommitWithFixes(rootDir, taskId, authorArg);
56718
+ const finalized = await commitOrAmendMergeWithFixes(
56719
+ rootDir,
56720
+ taskId,
56721
+ branch,
56722
+ commitLog,
56723
+ includeTaskId,
56724
+ preAttemptHeadSha,
56725
+ authorArg
56726
+ );
56727
+ if (!finalized) {
56728
+ resetMergeWithWarn(rootDir, taskId, "build-verification fix finalize");
56729
+ throw new Error(
56730
+ `${taskId}: build verification fix succeeded but no merge commit could be created \u2014 refusing to mark merge complete.`
56731
+ );
56732
+ }
56063
56733
  return true;
56064
56734
  }
56065
56735
  }
@@ -56073,10 +56743,18 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
56073
56743
  await audit.git({ type: "reset:hard", target: branch, metadata: { purpose: "build-retry" } });
56074
56744
  } catch (err) {
56075
56745
  const msg = err instanceof Error ? err.message : String(err);
56076
- mergerLog.warn(`${taskId}: git reset --merge cleanup failed (build-retry): ${msg}`);
56746
+ mergerLog.warn(`${taskId}: git reset --merge cleanup failed during build-verification rollback (build-retry): ${msg}`);
56077
56747
  }
56078
56748
  return false;
56079
56749
  }
56750
+ resetMergeWithWarn(rootDir, taskId, "build-verification rollback (no retries left)");
56751
+ throw error;
56752
+ }
56753
+ if (error.name === "MergeNonConflictError") {
56754
+ try {
56755
+ execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
56756
+ } catch {
56757
+ }
56080
56758
  throw error;
56081
56759
  }
56082
56760
  if (attemptNum < 3 && smartConflictResolution) {
@@ -56096,12 +56774,15 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
56096
56774
  const aiTracker = { aiWasInvoked: false };
56097
56775
  let merged = false;
56098
56776
  merged = await mergeAttempt(1);
56099
- if (!merged && smartConflictResolution) {
56777
+ if (!merged && smartConflictResolution && mergeConflictStrategy !== "abort") {
56100
56778
  merged = await mergeAttempt(2);
56101
56779
  }
56102
- if (!merged && smartConflictResolution) {
56780
+ if (!merged && smartConflictResolution && mergeConflictStrategy !== "ai-only" && mergeConflictStrategy !== "abort") {
56103
56781
  merged = await mergeAttempt(3);
56104
56782
  }
56783
+ if (aiTracker.mergeWasEmpty) {
56784
+ mergeWasEmpty = true;
56785
+ }
56105
56786
  if (!merged) {
56106
56787
  try {
56107
56788
  execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
@@ -56109,6 +56790,10 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
56109
56790
  const errorMessage = err instanceof Error ? err.message : String(err);
56110
56791
  mergerLog.warn(`${taskId}: git reset --merge cleanup failed: ${errorMessage}`);
56111
56792
  }
56793
+ if (mergeConflictStrategy === "abort") {
56794
+ result.resolutionStrategy = "abort";
56795
+ throw new Error(`Merge conflict for ${taskId}: aborted per mergeConflictStrategy="abort" \u2014 manual resolution required`);
56796
+ }
56112
56797
  throw new Error(`AI merge failed for ${taskId}: all 3 attempts exhausted`);
56113
56798
  }
56114
56799
  try {
@@ -56135,17 +56820,24 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
56135
56820
  } catch {
56136
56821
  }
56137
56822
  const isEmptyCommit = filesChanged === 0;
56138
- const recordedSha = isEmptyCommit ? void 0 : commitSha;
56823
+ const recordedSha = isEmptyCommit || mergeWasEmpty ? void 0 : commitSha;
56139
56824
  if (isEmptyCommit) {
56140
56825
  mergerLog.warn(
56141
56826
  `${taskId}: local squash produced an empty commit (${commitSha?.slice(0, 8)}) \u2014 branch likely contained dupes of main. Skipping commitSha; recovery will backfill when real commit lands.`
56142
56827
  );
56828
+ } else if (mergeWasEmpty) {
56829
+ mergerLog.warn(
56830
+ `${taskId}: merge succeeded without committing (branch already on main). Skipping commitSha; nothing new landed locally.`
56831
+ );
56143
56832
  }
56833
+ const recordedFilesChanged = mergeWasEmpty ? 0 : filesChanged;
56834
+ const recordedInsertions = mergeWasEmpty ? 0 : insertions;
56835
+ const recordedDeletions = mergeWasEmpty ? 0 : deletions;
56144
56836
  const mergeDetails = {
56145
56837
  commitSha: recordedSha,
56146
- filesChanged,
56147
- insertions,
56148
- deletions,
56838
+ filesChanged: recordedFilesChanged,
56839
+ insertions: recordedInsertions,
56840
+ deletions: recordedDeletions,
56149
56841
  mergeCommitMessage: commitLog,
56150
56842
  mergedAt: (/* @__PURE__ */ new Date()).toISOString(),
56151
56843
  mergeConfirmed: true,
@@ -56156,6 +56848,26 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
56156
56848
  };
56157
56849
  await store.updateTask(taskId, { mergeDetails });
56158
56850
  mergerLog.log(`${taskId}: merge details stored (commitSha: ${recordedSha?.slice(0, 8) ?? "<deferred>"})`);
56851
+ const summaryParts = [
56852
+ `Merge completed via ${result.resolutionStrategy ?? "unknown"} (attempt ${result.attemptsMade ?? "?"}/3)`
56853
+ ];
56854
+ if (recordedSha) {
56855
+ summaryParts.push(`commit ${recordedSha.slice(0, 8)}`);
56856
+ } else if (mergeWasEmpty) {
56857
+ summaryParts.push("no commit landed (branch already on main)");
56858
+ } else if (isEmptyCommit) {
56859
+ summaryParts.push("squash collapsed to empty (sha deferred)");
56860
+ }
56861
+ if (!mergeWasEmpty && filesChanged !== void 0) {
56862
+ summaryParts.push(`${filesChanged} file${filesChanged === 1 ? "" : "s"} changed (+${insertions ?? 0}/-${deletions ?? 0})`);
56863
+ }
56864
+ await store.appendAgentLog(
56865
+ taskId,
56866
+ summaryParts.join(" \xB7 "),
56867
+ "text",
56868
+ void 0,
56869
+ "merger"
56870
+ );
56159
56871
  } catch (err) {
56160
56872
  mergerLog.warn(`${taskId}: failed to collect/store merge details: ${err.message}`);
56161
56873
  }
@@ -56250,6 +56962,27 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
56250
56962
  });
56251
56963
  if (pushResult.pushed) {
56252
56964
  mergerLog.log(`${taskId}: pushed merged result to remote`);
56965
+ try {
56966
+ const postPushSha = execSync("git rev-parse HEAD", {
56967
+ cwd: rootDir,
56968
+ stdio: "pipe",
56969
+ encoding: "utf-8"
56970
+ }).trim() || void 0;
56971
+ if (postPushSha) {
56972
+ const existingTask = await store.getTask(taskId).catch(() => null);
56973
+ const existingDetails = existingTask?.mergeDetails;
56974
+ if (existingDetails?.commitSha && existingDetails.commitSha !== postPushSha) {
56975
+ await store.updateTask(taskId, {
56976
+ mergeDetails: { ...existingDetails, commitSha: postPushSha }
56977
+ });
56978
+ mergerLog.log(
56979
+ `${taskId}: post-push HEAD changed from ${existingDetails.commitSha.slice(0, 8)} to ${postPushSha.slice(0, 8)} \u2014 refreshed mergeDetails.commitSha`
56980
+ );
56981
+ }
56982
+ }
56983
+ } catch (refreshErr) {
56984
+ mergerLog.warn(`${taskId}: failed to refresh mergeDetails after push: ${refreshErr.message}`);
56985
+ }
56253
56986
  } else {
56254
56987
  mergerLog.warn(`${taskId}: push to remote failed: ${pushResult.error}`);
56255
56988
  }
@@ -56277,18 +57010,74 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
56277
57010
  await completeTask(store, taskId, result);
56278
57011
  return result;
56279
57012
  }
56280
- function getResolutionStrategy(attemptNum, smartConflictResolution) {
57013
+ async function tryFastForwardFromOrigin(rootDir, taskId) {
57014
+ let currentBranch;
57015
+ try {
57016
+ currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
57017
+ cwd: rootDir,
57018
+ encoding: "utf-8",
57019
+ stdio: "pipe"
57020
+ }).trim();
57021
+ } catch {
57022
+ return;
57023
+ }
57024
+ if (!currentBranch || currentBranch === "HEAD") return;
57025
+ try {
57026
+ await execAsync2(`git fetch origin "${currentBranch}"`, { cwd: rootDir });
57027
+ } catch (err) {
57028
+ mergerLog.log(`${taskId}: pre-merge fetch failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
57029
+ return;
57030
+ }
57031
+ let behind = 0;
57032
+ let ahead = 0;
57033
+ try {
57034
+ const counts = execSync(`git rev-list --left-right --count "origin/${currentBranch}...HEAD"`, {
57035
+ cwd: rootDir,
57036
+ encoding: "utf-8",
57037
+ stdio: "pipe"
57038
+ }).trim();
57039
+ const [b, a] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
57040
+ behind = b;
57041
+ ahead = a;
57042
+ } catch {
57043
+ return;
57044
+ }
57045
+ if (behind === 0) return;
57046
+ if (ahead > 0) {
57047
+ mergerLog.log(`${taskId}: local ${currentBranch} has ${ahead} unpushed commit(s); skipping fast-forward`);
57048
+ return;
57049
+ }
57050
+ try {
57051
+ await execAsync2(`git merge --ff-only "origin/${currentBranch}"`, { cwd: rootDir });
57052
+ mergerLog.log(`${taskId}: fast-forwarded ${currentBranch} by ${behind} commit(s) from origin`);
57053
+ } catch (err) {
57054
+ mergerLog.log(`${taskId}: fast-forward failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
57055
+ }
57056
+ }
57057
+ function getResolutionStrategy(attemptNum, smartConflictResolution, mergeConflictStrategy = "smart-prefer-main") {
56281
57058
  if (!smartConflictResolution || attemptNum === 1) {
56282
57059
  return "ai";
56283
57060
  }
56284
57061
  if (attemptNum === 2) {
56285
57062
  return "auto-resolve";
56286
57063
  }
56287
- return "theirs";
57064
+ switch (mergeConflictStrategy) {
57065
+ case "ai-only":
57066
+ return "ai";
57067
+ case "smart-prefer-main":
57068
+ return "ours";
57069
+ case "abort":
57070
+ return "abort";
57071
+ case "smart-prefer-branch":
57072
+ default:
57073
+ return "theirs";
57074
+ }
56288
57075
  }
56289
57076
  function getResolutionMethod(strategy, autoResolvedCount, aiWasUsed) {
56290
57077
  if (strategy === "ai") return "ai";
56291
57078
  if (strategy === "theirs") return "theirs";
57079
+ if (strategy === "ours") return "ours";
57080
+ if (strategy === "abort") return "abort";
56292
57081
  if (strategy === "auto-resolve") {
56293
57082
  if (autoResolvedCount && autoResolvedCount > 0) {
56294
57083
  return aiWasUsed ? "mixed" : "auto";
@@ -56306,6 +57095,7 @@ async function executeMergeAttempt(params, aiTracker) {
56306
57095
  commitLog,
56307
57096
  diffStat,
56308
57097
  includeTaskId,
57098
+ sourceIssueRef,
56309
57099
  smartConflictResolution,
56310
57100
  attemptNum,
56311
57101
  options,
@@ -56317,12 +57107,15 @@ async function executeMergeAttempt(params, aiTracker) {
56317
57107
  buildSource
56318
57108
  } = params;
56319
57109
  if (attemptNum === 3) {
56320
- return attemptWithTheirsStrategy(params);
57110
+ if (params.mergeConflictStrategy === "smart-prefer-main") {
57111
+ return attemptWithSideStrategy(params, "ours", aiTracker);
57112
+ }
57113
+ return attemptWithSideStrategy(params, "theirs", aiTracker);
56321
57114
  }
56322
57115
  let hasConflicts = false;
56323
57116
  try {
56324
57117
  if (attemptNum === 2 && smartConflictResolution) {
56325
- let mergeExitedWithConflicts = false;
57118
+ let mergeError;
56326
57119
  try {
56327
57120
  await execAsync2(`git merge --squash "${branch}"`, {
56328
57121
  cwd: rootDir
@@ -56330,9 +57123,18 @@ async function executeMergeAttempt(params, aiTracker) {
56330
57123
  throwIfAborted(options.signal, taskId);
56331
57124
  } catch (error) {
56332
57125
  rethrowIfMergeAborted(error);
56333
- mergeExitedWithConflicts = true;
57126
+ mergeError = error;
56334
57127
  }
56335
57128
  const conflictedFiles = await getConflictedFiles(rootDir);
57129
+ if (mergeError && conflictedFiles.length === 0) {
57130
+ const cause = mergeError instanceof Error ? mergeError.message : String(mergeError);
57131
+ const fatal = new Error(
57132
+ `${taskId}: git merge --squash failed without producing conflicts (${cause}) \u2014 refusing to treat as a no-op merge.`
57133
+ );
57134
+ fatal.name = "MergeNonConflictError";
57135
+ throw fatal;
57136
+ }
57137
+ const mergeExitedWithConflicts = mergeError !== void 0;
56336
57138
  if (conflictedFiles.length > 0 || mergeExitedWithConflicts) {
56337
57139
  const classified = [];
56338
57140
  for (const file of conflictedFiles) {
@@ -56375,11 +57177,14 @@ async function executeMergeAttempt(params, aiTracker) {
56375
57177
  const escapedLog = commitLog.replace(/"/g, '\\"');
56376
57178
  const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
56377
57179
  const authorArg = getCommitAuthorArg(settings);
57180
+ const trailerArg = buildTaskIdTrailerArg(taskId);
56378
57181
  await execAsync2(
56379
- `git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"${authorArg}`,
57182
+ `git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"${trailerArg}${authorArg}`,
56380
57183
  { cwd: rootDir }
56381
57184
  );
56382
57185
  mergerLog.log(`${taskId}: committed after auto-resolving all conflicts`);
57186
+ } else {
57187
+ aiTracker.mergeWasEmpty = true;
56383
57188
  }
56384
57189
  if (testCommand || buildCommand2) {
56385
57190
  throwIfAborted(options.signal, taskId);
@@ -56404,6 +57209,7 @@ async function executeMergeAttempt(params, aiTracker) {
56404
57209
  ).trim() === "0";
56405
57210
  if (squashIsEmpty) {
56406
57211
  mergerLog.log(`${taskId}: squash merge staged nothing \u2014 already merged`);
57212
+ aiTracker.mergeWasEmpty = true;
56407
57213
  if (testCommand || buildCommand2) {
56408
57214
  throwIfAborted(options.signal, taskId);
56409
57215
  await runDeterministicVerification(
@@ -56431,6 +57237,7 @@ async function executeMergeAttempt(params, aiTracker) {
56431
57237
  ).trim() === "0";
56432
57238
  if (squashIsEmpty) {
56433
57239
  mergerLog.log(`${taskId}: squash merge staged nothing \u2014 already merged`);
57240
+ aiTracker.mergeWasEmpty = true;
56434
57241
  if (testCommand || buildCommand2) {
56435
57242
  throwIfAborted(options.signal, taskId);
56436
57243
  await runDeterministicVerification(
@@ -56479,19 +57286,12 @@ async function executeMergeAttempt(params, aiTracker) {
56479
57286
  simplifiedContext: attemptNum === 2,
56480
57287
  options,
56481
57288
  testCommand,
56482
- buildCommand: buildCommand2
57289
+ buildCommand: buildCommand2,
57290
+ sourceIssueRef
56483
57291
  });
56484
57292
  if (!agentResult.success) {
56485
57293
  const errorMessage = agentResult.error || "Build verification failed";
56486
57294
  await store.logEntry(taskId, "Build verification failed during merge", errorMessage);
56487
- try {
56488
- execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
56489
- } catch (resetErr) {
56490
- const msg = resetErr instanceof Error ? resetErr.message : String(resetErr);
56491
- mergerLog.warn(
56492
- `${taskId}: git reset --merge cleanup failed during build-verification rollback (build-verification reset, build-retry): ${msg}`
56493
- );
56494
- }
56495
57295
  throw new Error(`Build verification failed for ${taskId}: ${errorMessage}`);
56496
57296
  }
56497
57297
  if (testCommand || buildCommand2) {
@@ -56507,6 +57307,24 @@ async function executeMergeAttempt(params, aiTracker) {
56507
57307
  options.signal
56508
57308
  );
56509
57309
  }
57310
+ try {
57311
+ const authorArg = getCommitAuthorArg(params.settings);
57312
+ const { subjectArg, bodyArg } = buildDeterministicMergeMessage({
57313
+ taskId,
57314
+ branch,
57315
+ commitLog,
57316
+ includeTaskId
57317
+ });
57318
+ const trailerArg = buildTaskIdTrailerArg(taskId);
57319
+ await execAsync2(
57320
+ `git commit --amend ${subjectArg} ${bodyArg}${trailerArg}${authorArg}`,
57321
+ { cwd: rootDir }
57322
+ );
57323
+ mergerLog.log(`${taskId}: rewrote AI-authored merge commit message with deterministic body`);
57324
+ } catch (err) {
57325
+ const msg = err instanceof Error ? err.message : String(err);
57326
+ mergerLog.warn(`${taskId}: failed to canonicalize merge commit message (${msg}) \u2014 keeping AI-written message`);
57327
+ }
56510
57328
  return true;
56511
57329
  } catch (error) {
56512
57330
  if (error instanceof Error && error.name === "MergeAbortedError") {
@@ -56528,12 +57346,12 @@ async function executeMergeAttempt(params, aiTracker) {
56528
57346
  throw error;
56529
57347
  }
56530
57348
  }
56531
- async function attemptWithTheirsStrategy(params) {
56532
- const { rootDir, branch, commitLog, includeTaskId, taskId, store, settings, testCommand, buildCommand: buildCommand2, testSource, buildSource } = params;
56533
- mergerLog.log(`${taskId}: attempting merge with -X theirs strategy`);
57349
+ async function attemptWithSideStrategy(params, side = "theirs", aiTracker) {
57350
+ const { rootDir, branch, commitLog, includeTaskId, sourceIssueRef, taskId, store, settings, testCommand, buildCommand: buildCommand2, testSource, buildSource } = params;
57351
+ mergerLog.log(`${taskId}: attempting merge with -X ${side} strategy`);
56534
57352
  try {
56535
57353
  throwIfAborted(params.options.signal, taskId);
56536
- await execAsync2(`git merge -X theirs --squash "${branch}"`, {
57354
+ await execAsync2(`git merge -X ${side} --squash "${branch}"`, {
56537
57355
  cwd: rootDir
56538
57356
  });
56539
57357
  const conflictedOutput = execSync("git diff --name-only --diff-filter=U", {
@@ -56541,7 +57359,7 @@ async function attemptWithTheirsStrategy(params) {
56541
57359
  encoding: "utf-8"
56542
57360
  }).trim();
56543
57361
  if (conflictedOutput.length > 0) {
56544
- mergerLog.warn(`${taskId}: -X theirs left unresolved conflicts: ${conflictedOutput}`);
57362
+ mergerLog.warn(`${taskId}: -X ${side} left unresolved conflicts: ${conflictedOutput}`);
56545
57363
  return false;
56546
57364
  }
56547
57365
  const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
@@ -56549,6 +57367,7 @@ async function attemptWithTheirsStrategy(params) {
56549
57367
  encoding: "utf-8"
56550
57368
  }).trim();
56551
57369
  if (staged === "0") {
57370
+ if (aiTracker) aiTracker.mergeWasEmpty = true;
56552
57371
  if (testCommand || buildCommand2) {
56553
57372
  throwIfAborted(params.options.signal, taskId);
56554
57373
  await runDeterministicVerification(
@@ -56568,11 +57387,13 @@ async function attemptWithTheirsStrategy(params) {
56568
57387
  const escapedLog = commitLog.replace(/"/g, '\\"');
56569
57388
  const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
56570
57389
  const authorArg = getCommitAuthorArg(settings);
57390
+ const trailerArg = buildTaskIdTrailerArg(taskId);
57391
+ const issueRefBodyArg = sourceIssueRef ? ` -m "Ref: ${sourceIssueRef}"` : "";
56571
57392
  await execAsync2(
56572
- `git commit -m "${fallbackPrefix}: merge ${branch} (auto-resolved)" -m "${escapedLog}"${authorArg}`,
57393
+ `git commit -m "${fallbackPrefix}: merge ${branch} (auto-resolved)" -m "${escapedLog}"${issueRefBodyArg}${trailerArg}${authorArg}`,
56573
57394
  { cwd: rootDir }
56574
57395
  );
56575
- mergerLog.log(`${taskId}: committed with -X theirs auto-resolution`);
57396
+ mergerLog.log(`${taskId}: committed with -X ${side} auto-resolution`);
56576
57397
  if (testCommand || buildCommand2) {
56577
57398
  throwIfAborted(params.options.signal, taskId);
56578
57399
  await runDeterministicVerification(
@@ -56591,7 +57412,7 @@ async function attemptWithTheirsStrategy(params) {
56591
57412
  if (error instanceof Error && error.name === "MergeAbortedError") {
56592
57413
  throw error;
56593
57414
  }
56594
- mergerLog.error(`${taskId}: -X theirs merge failed: ${error}`);
57415
+ mergerLog.error(`${taskId}: -X ${side} merge failed: ${error}`);
56595
57416
  return false;
56596
57417
  }
56597
57418
  }
@@ -56606,6 +57427,7 @@ async function runAiAgentForCommit(params) {
56606
57427
  includeTaskId,
56607
57428
  hasConflicts,
56608
57429
  simplifiedContext,
57430
+ sourceIssueRef,
56609
57431
  options,
56610
57432
  testCommand,
56611
57433
  buildCommand: buildCommand2
@@ -56704,7 +57526,8 @@ async function runAiAgentForCommit(params) {
56704
57526
  simplifiedContext,
56705
57527
  testCommand,
56706
57528
  buildCommand: buildCommand2,
56707
- authorArg
57529
+ authorArg,
57530
+ sourceIssueRef
56708
57531
  });
56709
57532
  mergerLog.log(`${taskId}: starting fresh merge agent session`);
56710
57533
  try {
@@ -56736,7 +57559,8 @@ async function runAiAgentForCommit(params) {
56736
57559
  // Also skip detailed context
56737
57560
  testCommand,
56738
57561
  buildCommand: buildCommand2,
56739
- authorArg
57562
+ authorArg,
57563
+ sourceIssueRef
56740
57564
  });
56741
57565
  try {
56742
57566
  await withRateLimitRetry(async () => {
@@ -56777,13 +57601,17 @@ async function runAiAgentForCommit(params) {
56777
57601
  const escapedLog = commitLog.replace(/"/g, '\\"');
56778
57602
  const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
56779
57603
  const authorArg2 = getCommitAuthorArg(settings);
57604
+ const trailerArg = buildTaskIdTrailerArg(taskId);
57605
+ const issueRefBodyArg = sourceIssueRef ? ` -m "Ref: ${sourceIssueRef}"` : "";
56780
57606
  await execAsync2(
56781
- `git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"${authorArg2}`,
57607
+ `git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"${issueRefBodyArg}${trailerArg}${authorArg2}`,
56782
57608
  { cwd: rootDir }
56783
57609
  );
56784
57610
  } else {
56785
57611
  throw new Error(`Agent did not commit and did not report build failure for ${taskId}`);
56786
57612
  }
57613
+ } else {
57614
+ await ensureTaskIdTrailerOnHead(rootDir, taskId);
56787
57615
  }
56788
57616
  return { success: true };
56789
57617
  } catch (err) {
@@ -56799,7 +57627,7 @@ async function runAiAgentForCommit(params) {
56799
57627
  }
56800
57628
  }
56801
57629
  function buildMergePrompt(params) {
56802
- const { taskId, branch, commitLog, diffStat, hasConflicts, simplifiedContext, testCommand, buildCommand: buildCommand2, authorArg } = params;
57630
+ const { taskId, branch, commitLog, diffStat, hasConflicts, simplifiedContext, sourceIssueRef, testCommand, buildCommand: buildCommand2, authorArg } = params;
56803
57631
  const truncatedCommitLog = truncateWithEllipsis(commitLog, MERGE_COMMIT_LOG_MAX_CHARS);
56804
57632
  const truncatedDiffStat = truncateWithEllipsis(diffStat, MERGE_DIFF_STAT_MAX_CHARS);
56805
57633
  const parts = [
@@ -56835,6 +57663,13 @@ function buildMergePrompt(params) {
56835
57663
  `Write and run the \`git commit\` command with a good message summarizing the work.${authorArg ? ` Be sure to include \`${authorArg.trim()}\` in the commit command.` : ""}`
56836
57664
  );
56837
57665
  }
57666
+ if (sourceIssueRef) {
57667
+ parts.push(
57668
+ "",
57669
+ "Include this in the commit message body:",
57670
+ `- Ref: ${sourceIssueRef}`
57671
+ );
57672
+ }
56838
57673
  if (testCommand) {
56839
57674
  parts.push(
56840
57675
  "",
@@ -57011,8 +57846,9 @@ If issues are found that need attention, describe them clearly.`;
57011
57846
  agent: "merger"
57012
57847
  });
57013
57848
  try {
57014
- const stepProvider = workflowStep.modelProvider || settings.defaultProvider;
57015
- const stepModelId = workflowStep.modelId || settings.defaultModelId;
57849
+ const defaultModel = resolveProjectDefaultModel(settings);
57850
+ const stepProvider = workflowStep.modelProvider || defaultModel.provider;
57851
+ const stepModelId = workflowStep.modelId || defaultModel.modelId;
57016
57852
  const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
57017
57853
  let postMergeInstructions = "";
57018
57854
  if (mergeOptions.agentStore) {
@@ -57095,12 +57931,11 @@ async function completeTask(store, taskId, result) {
57095
57931
  result.task = task;
57096
57932
  store.emit("task:merged", result);
57097
57933
  }
57098
- var execAsync2, LOCKFILE_PATTERNS, GENERATED_PATTERNS, DEPENDENCY_SYNC_TRIGGER_PATTERNS, VERIFICATION_COMMAND_MAX_BUFFER, VERIFICATION_COMMAND_TIMEOUT_MS, VERIFICATION_LOG_MAX_CHARS, WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS, PULL_REBASE_TIMEOUT_MS, PUSH_TIMEOUT_MS, MERGE_COMMIT_LOG_MAX_CHARS, MERGE_DIFF_STAT_MAX_CHARS, VerificationError, MergeAbortedError;
57934
+ var execAsync2, LOCKFILE_PATTERNS, GENERATED_PATTERNS, DEPENDENCY_SYNC_TRIGGER_PATTERNS, VERIFICATION_COMMAND_MAX_BUFFER, VERIFICATION_COMMAND_TIMEOUT_MS, VERIFICATION_LOG_MAX_CHARS, WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS, PULL_REBASE_TIMEOUT_MS, PUSH_TIMEOUT_MS, MERGE_COMMIT_LOG_MAX_CHARS, MERGE_DIFF_STAT_MAX_CHARS, VerificationError, MergeAbortedError, FUSION_TASK_ID_TRAILER_KEY;
57099
57935
  var init_merger = __esm({
57100
57936
  "../engine/src/merger.ts"() {
57101
57937
  "use strict";
57102
57938
  init_src();
57103
- init_src();
57104
57939
  init_pi();
57105
57940
  init_session_token_usage();
57106
57941
  init_agent_session_helpers();
@@ -57170,6 +58005,7 @@ var init_merger = __esm({
57170
58005
  this.name = "MergeAbortedError";
57171
58006
  }
57172
58007
  };
58008
+ FUSION_TASK_ID_TRAILER_KEY = "Fusion-Task-Id";
57173
58009
  }
57174
58010
  });
57175
58011
 
@@ -58757,6 +59593,7 @@ function buildExecutionPrompt(task, rootDir, settings, worktreePath) {
58757
59593
  const reviewMatch = prompt.match(/##\s*Review Level[:\s]*(\d)/);
58758
59594
  const reviewLevel = reviewMatch ? parseInt(reviewMatch[1], 10) : 0;
58759
59595
  const authorArg = settings?.commitAuthorEnabled !== false ? ` --author="${settings?.commitAuthorName || "Fusion"} <${settings?.commitAuthorEmail || "noreply@runfusion.ai"}>"` : "";
59596
+ const sourceIssueRef = task.sourceIssue?.provider === "github" && task.sourceIssue.repository && task.sourceIssue.issueNumber ? `${task.sourceIssue.repository}#${task.sourceIssue.issueNumber}` : "";
58760
59597
  const hasProgress = task.steps.length > 0 && task.steps.some((s) => s.status !== "pending");
58761
59598
  let progressSection = "";
58762
59599
  if (hasProgress) {
@@ -58859,7 +59696,7 @@ ${hasProgress ? `Resume from Step ${task.currentStep}. Do NOT redo completed ste
58859
59696
  Use \`fn_task_update\` to report progress on every step transition.
58860
59697
  Use \`fn_task_log\` for important actions and decisions.
58861
59698
  Use \`fn_task_create\` for truly separate follow-up work, not for fixes required to get tests, build, or typecheck back to green.
58862
- Commit at step boundaries: \`git commit -m "feat(${task.id}): complete Step N \u2014 description"${authorArg}\`
59699
+ Commit at step boundaries: \`git commit -m "feat(${task.id}): complete Step N \u2014 description"${sourceIssueRef ? ` -m "Ref: ${sourceIssueRef}"` : ""}${authorArg}\`
58863
59700
  When all steps are complete: call \`fn_task_done()\`
58864
59701
 
58865
59702
  If a build command is configured, run that exact command in this worktree before calling \`fn_task_done()\`.
@@ -59045,6 +59882,7 @@ If the task's PROMPT.md includes a "Documentation Requirements" section listing
59045
59882
  ## Git discipline
59046
59883
  - Commit after completing each step (not after every file change)
59047
59884
  - Use conventional commit messages prefixed with the task ID
59885
+ - When the task has a GitHub issue reference, include \`Ref: owner/repo#N\` in the commit body
59048
59886
  - Do NOT commit broken or half-implemented code
59049
59887
 
59050
59888
  ## Worktree Boundaries
@@ -59138,7 +59976,10 @@ Lint, tests, and typecheck are also hard quality gates:
59138
59976
  executorLog.log(`[event:task:moved] ${task.id}: ${from} \u2192 ${to}`);
59139
59977
  if (to === "in-progress") {
59140
59978
  executorLog.log(`[event:task:moved] Initiating execute() for ${task.id}`);
59141
- this.execute(task).catch(
59979
+ void (async () => {
59980
+ const taskForExecution = await this.resetMergeStateIfNeeded(task, from);
59981
+ await this.execute(taskForExecution);
59982
+ })().catch(
59142
59983
  (err) => executorLog.error(`Failed to start ${task.id}:`, err)
59143
59984
  );
59144
59985
  } else if (from === "in-progress") {
@@ -59416,6 +60257,59 @@ Lint, tests, and typecheck are also hard quality gates:
59416
60257
  if (task.steps.length === 0) return false;
59417
60258
  return task.steps.every((s) => s.status === "done" || s.status === "skipped");
59418
60259
  }
60260
+ async resetMergeStateIfNeeded(task, from) {
60261
+ if (from !== "in-review" && from !== "done") {
60262
+ return task;
60263
+ }
60264
+ const hasMergeEvidence = Boolean(task.mergeDetails) || (task.mergeRetries ?? 0) > 0 || (task.verificationFailureCount ?? 0) > 0 || task.status === "merging" || task.status === "merging-pr";
60265
+ if (!hasMergeEvidence) {
60266
+ return task;
60267
+ }
60268
+ return this.cleanupMergeStateForReverification(
60269
+ task,
60270
+ `Task returned to in-progress from ${from} column \u2014 resetting verification steps and merge state for re-verification`
60271
+ );
60272
+ }
60273
+ async cleanupMergeStateForReverification(task, logMessage) {
60274
+ await this.store.updateTask(task.id, {
60275
+ mergeDetails: null,
60276
+ mergeRetries: 0,
60277
+ verificationFailureCount: 0,
60278
+ workflowStepResults: []
60279
+ });
60280
+ const refreshedTask = await this.store.getTask(task.id);
60281
+ const steps = refreshedTask.steps ?? [];
60282
+ if (steps.length > 0) {
60283
+ const allStepsComplete = this.isTaskWorkComplete(refreshedTask);
60284
+ if (allStepsComplete) {
60285
+ await this.reopenLastStepForRevision(task.id, refreshedTask);
60286
+ } else {
60287
+ const resetIndexes = /* @__PURE__ */ new Set();
60288
+ for (let i = 0; i < steps.length; i++) {
60289
+ const name = steps[i].name.toLowerCase();
60290
+ if (/testing|verification/.test(name) || /documentation|delivery/.test(name)) {
60291
+ resetIndexes.add(i);
60292
+ }
60293
+ }
60294
+ if (resetIndexes.size === 0) {
60295
+ const reopened = await this.reopenLastStepForRevision(task.id, refreshedTask);
60296
+ if (reopened) {
60297
+ resetIndexes.add(reopened.index);
60298
+ }
60299
+ } else {
60300
+ for (const index of resetIndexes) {
60301
+ if (steps[index].status !== "pending") {
60302
+ await this.store.updateStep(task.id, index, "pending");
60303
+ }
60304
+ }
60305
+ const earliestIndex = Math.min(...Array.from(resetIndexes));
60306
+ await this.store.updateTask(task.id, { currentStep: earliestIndex });
60307
+ }
60308
+ }
60309
+ }
60310
+ await this.store.logEntry(task.id, logMessage, void 0, this.currentRunContext);
60311
+ return this.store.getTask(task.id);
60312
+ }
59419
60313
  isNoProgressNoTaskDoneFailure(task) {
59420
60314
  return task.status === "failed" && task.error?.includes("without calling fn_task_done") === true && task.steps.every((step) => step.status === "pending");
59421
60315
  }
@@ -59637,7 +60531,7 @@ Lint, tests, and typecheck are also hard quality gates:
59637
60531
  if (inProgress.length === 0) return;
59638
60532
  executorLog.log(`Found ${inProgress.length} orphaned in-progress task(s)`);
59639
60533
  for (const task of inProgress) {
59640
- if (this.isTaskWorkComplete(task)) {
60534
+ if (this.isTaskWorkComplete(task) && !task.mergeDetails) {
59641
60535
  if (this.recoveringCompleted.has(task.id)) {
59642
60536
  executorLog.log(`${task.id} completed-task recovery already running - skipping duplicate startup recovery`);
59643
60537
  continue;
@@ -59772,6 +60666,13 @@ Lint, tests, and typecheck are also hard quality gates:
59772
60666
  return;
59773
60667
  }
59774
60668
  }
60669
+ if (task.column === "in-progress" && task.mergeDetails) {
60670
+ executorLog.warn(`${task.id}: stale mergeDetails found while executing in-progress task \u2014 resetting merge state before continuing`);
60671
+ task = await this.cleanupMergeStateForReverification(
60672
+ task,
60673
+ "Executor detected stale merge state while task was in-progress \u2014 reset verification steps and merge metadata before resuming"
60674
+ );
60675
+ }
59775
60676
  if (task.column === "in-progress" && !task.worktree) {
59776
60677
  executorLog.error(
59777
60678
  `${task.id}: drift detected \u2014 task is in-progress with no worktree. Recovering by creating a fresh worktree. This usually indicates a partial updateTask/moveTask sequence failed somewhere upstream.`
@@ -61776,10 +62677,19 @@ and show an appropriate message to the user.\`
61776
62677
  this.options.onAgentTool?.(taskId, toolName);
61777
62678
  }
61778
62679
  });
61779
- try {
61780
- const stepProvider = workflowStep.modelProvider || settings.defaultProvider;
61781
- const stepModelId = workflowStep.modelId || settings.defaultModelId;
61782
- const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
62680
+ const defaultModel = resolveProjectDefaultModel(settings);
62681
+ const primaryProvider = workflowStep.modelProvider || defaultModel.provider;
62682
+ const primaryModelId = workflowStep.modelId || defaultModel.modelId;
62683
+ const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
62684
+ const fallbackCandidates = [
62685
+ { provider: settings.validatorFallbackProvider, modelId: settings.validatorFallbackModelId, label: "validatorFallback" },
62686
+ { provider: settings.fallbackProvider, modelId: settings.fallbackModelId, label: "globalFallback" }
62687
+ ];
62688
+ const fallback = fallbackCandidates.find(
62689
+ (c) => c.provider && c.modelId && (c.provider !== primaryProvider || c.modelId !== primaryModelId)
62690
+ );
62691
+ const timeoutMs = Math.max(6e4, settings.workflowStepTimeoutMs ?? 36e4);
62692
+ const runOnce = async (provider, modelId, attemptLabel) => {
61783
62693
  const stepInstructions = await this.resolveInstructionsForRole("executor");
61784
62694
  const stepSystemPrompt = buildSystemPromptWithInstructions(systemPrompt, stepInstructions);
61785
62695
  const skillContext = await buildSessionSkillContext({
@@ -61797,16 +62707,19 @@ and show an appropriate message to the user.\`
61797
62707
  cwd: worktreePath,
61798
62708
  systemPrompt: stepSystemPrompt,
61799
62709
  tools: toolMode,
61800
- defaultProvider: stepProvider,
61801
- defaultModelId: stepModelId,
62710
+ defaultProvider: provider,
62711
+ defaultModelId: modelId,
61802
62712
  fallbackProvider: settings.fallbackProvider,
61803
62713
  fallbackModelId: settings.fallbackModelId,
61804
62714
  defaultThinkingLevel: settings.defaultThinkingLevel,
61805
62715
  // Skill selection: use assigned agent skills if available, otherwise role fallback
61806
62716
  ...skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}
61807
62717
  });
61808
- executorLog.log(`${task.id}: workflow step '${workflowStep.name}' using model ${describeModel(session)}${useOverride ? " (workflow step override)" : ""}`);
61809
- await this.store.logEntry(task.id, `Workflow step '${workflowStep.name}' using model: ${describeModel(session)}${useOverride ? " (workflow step override)" : ""}`);
62718
+ executorLog.log(`${task.id}: workflow step '${workflowStep.name}' using model ${describeModel(session)}${useOverride && attemptLabel === "primary" ? " (workflow step override)" : ""}${attemptLabel === "fallback" ? " (fallback after timeout)" : ""}`);
62719
+ await this.store.logEntry(
62720
+ task.id,
62721
+ `Workflow step '${workflowStep.name}' using model: ${describeModel(session)}${useOverride && attemptLabel === "primary" ? " (workflow step override)" : ""}${attemptLabel === "fallback" ? " (fallback after timeout)" : ""}`
62722
+ );
61810
62723
  let output = "";
61811
62724
  session.subscribe((event) => {
61812
62725
  if (event.type === "message_update") {
@@ -61825,33 +62738,75 @@ and show an appropriate message to the user.\`
61825
62738
  agentLogger.onToolEnd(event.toolName, event.isError, event.result);
61826
62739
  }
61827
62740
  });
61828
- await promptWithFallback(
61829
- session,
61830
- `Execute the workflow step "${workflowStep.name}" for task ${task.id}.
62741
+ let timedOut = false;
62742
+ let timeoutHandle;
62743
+ const timeoutPromise = new Promise((resolveTimeout) => {
62744
+ timeoutHandle = setTimeout(() => {
62745
+ timedOut = true;
62746
+ resolveTimeout("timeout");
62747
+ }, timeoutMs);
62748
+ });
62749
+ try {
62750
+ const promptPromise = promptWithFallback(
62751
+ session,
62752
+ `Execute the workflow step "${workflowStep.name}" for task ${task.id}.
61831
62753
 
61832
62754
  Review the work done in this worktree and evaluate it against the criteria in your instructions.`
61833
- );
61834
- checkSessionError(session);
61835
- await accumulateSessionTokenUsage(this.store, task.id, session);
61836
- session.dispose();
61837
- await agentLogger.flush();
61838
- const trimmedOutput = output.trim();
61839
- const revisionMatch = trimmedOutput.match(/^REQUEST REVISION\s*\n*/i);
61840
- if (revisionMatch) {
61841
- const feedbackStart = revisionMatch[0].length;
61842
- const feedback = trimmedOutput.slice(feedbackStart).trim();
61843
- return {
61844
- success: false,
61845
- revisionRequested: true,
61846
- output: feedback
61847
- };
62755
+ );
62756
+ const outcome = await Promise.race([
62757
+ promptPromise.then(() => "completed"),
62758
+ timeoutPromise
62759
+ ]);
62760
+ if (outcome === "timeout") {
62761
+ executorLog.warn(`${task.id}: workflow step '${workflowStep.name}' (${attemptLabel}) timed out after ${timeoutMs}ms \u2014 disposing session`);
62762
+ await this.store.logEntry(
62763
+ task.id,
62764
+ `Workflow step '${workflowStep.name}' ${attemptLabel === "primary" ? "primary" : "fallback"} model timed out after ${Math.round(timeoutMs / 1e3)}s \u2014 aborting session`
62765
+ );
62766
+ try {
62767
+ session.dispose();
62768
+ } catch {
62769
+ }
62770
+ await agentLogger.flush();
62771
+ return { success: false, error: `workflow step timed out after ${timeoutMs}ms`, timedOut: true };
62772
+ }
62773
+ checkSessionError(session);
62774
+ await accumulateSessionTokenUsage(this.store, task.id, session);
62775
+ session.dispose();
62776
+ await agentLogger.flush();
62777
+ const trimmedOutput = output.trim();
62778
+ const revisionMatch = trimmedOutput.match(/^REQUEST REVISION\s*\n*/i);
62779
+ if (revisionMatch) {
62780
+ const feedbackStart = revisionMatch[0].length;
62781
+ const feedback = trimmedOutput.slice(feedbackStart).trim();
62782
+ return { success: false, revisionRequested: true, output: feedback };
62783
+ }
62784
+ return { success: true, output };
62785
+ } catch (err) {
62786
+ await agentLogger.flush();
62787
+ try {
62788
+ session.dispose();
62789
+ } catch {
62790
+ }
62791
+ const errorMessage = err instanceof Error ? err.message : String(err);
62792
+ return { success: false, error: errorMessage };
62793
+ } finally {
62794
+ if (timeoutHandle) clearTimeout(timeoutHandle);
62795
+ void timedOut;
61848
62796
  }
61849
- return { success: true, output };
61850
- } catch (err) {
61851
- const errorMessage = err instanceof Error ? err.message : String(err);
61852
- await agentLogger.flush();
61853
- return { success: false, error: errorMessage };
62797
+ };
62798
+ const primaryOutcome = await runOnce(primaryProvider, primaryModelId, "primary");
62799
+ if (!primaryOutcome.timedOut) return primaryOutcome;
62800
+ if (!fallback) {
62801
+ executorLog.warn(`${task.id}: workflow step '${workflowStep.name}' timed out and no fallback model is configured`);
62802
+ await this.store.logEntry(
62803
+ task.id,
62804
+ `Workflow step '${workflowStep.name}' timed out \u2014 no fallback model configured (set settings.validatorFallbackProvider/Id or fallbackProvider/Id)`
62805
+ );
62806
+ return primaryOutcome;
61854
62807
  }
62808
+ executorLog.log(`${task.id}: retrying workflow step '${workflowStep.name}' with fallback ${fallback.provider}/${fallback.modelId} (label=${fallback.label})`);
62809
+ return runOnce(fallback.provider, fallback.modelId, "fallback");
61855
62810
  }
61856
62811
  MAX_WORKTREE_RETRIES = 3;
61857
62812
  WORKTREE_RETRY_DELAYS = [100, 500, 1e3];
@@ -61879,7 +62834,13 @@ Review the work done in this worktree and evaluate it against the criteria in yo
61879
62834
  }
61880
62835
  for (let attempt = 0; attempt < this.MAX_WORKTREE_RETRIES; attempt++) {
61881
62836
  try {
61882
- return await this.tryCreateWorktree(branch, currentPath, taskId, resolvedStartPoint, attempt);
62837
+ const result = await this.tryCreateWorktree(branch, currentPath, taskId, resolvedStartPoint, attempt);
62838
+ await this.rebaseNewWorktreeOntoRemote(result.path, result.branch, taskId).catch((err) => {
62839
+ executorLog.warn(
62840
+ `Post-create worktree rebase failed for ${taskId} (continuing): ${err instanceof Error ? err.message : String(err)}`
62841
+ );
62842
+ });
62843
+ return result;
61883
62844
  } catch (error) {
61884
62845
  const errorMessage = error instanceof Error ? error.message : String(error);
61885
62846
  const isLastAttempt = attempt === this.MAX_WORKTREE_RETRIES - 1;
@@ -61900,6 +62861,84 @@ Review the work done in this worktree and evaluate it against the criteria in yo
61900
62861
  }
61901
62862
  throw new Error("Unexpected exit from worktree creation retry loop");
61902
62863
  }
62864
+ quoteShellArg(value) {
62865
+ return `'${value.replace(/'/g, "'\\''")}'`;
62866
+ }
62867
+ /**
62868
+ * After creating a fresh task worktree, fetch the configured remote and
62869
+ * rebase the task branch onto `<remote>/<defaultBranch>`. The result is a
62870
+ * branch that contains origin's tip plus any local main commits, so the
62871
+ * eventual merge has fewer surprises and the executor sees the freshest
62872
+ * code its peers/CI may have published.
62873
+ *
62874
+ * No-op when `worktreeRebaseBeforeMerge` is disabled, no remote is
62875
+ * configured/resolvable, or the rebase produces conflicts (we abort and
62876
+ * leave the worktree as-is so the executor can still run).
62877
+ */
62878
+ async rebaseNewWorktreeOntoRemote(worktreePath, branch, taskId) {
62879
+ let settings;
62880
+ try {
62881
+ settings = await this.store.getSettings();
62882
+ } catch {
62883
+ return;
62884
+ }
62885
+ if (settings.worktreeRebaseBeforeMerge === false) return;
62886
+ let remote = settings.worktreeRebaseRemote?.trim() || "";
62887
+ if (!remote) {
62888
+ try {
62889
+ const { stdout } = await execAsync5("git remote", { cwd: this.rootDir });
62890
+ const remotes = stdout.split("\n").map((s) => s.trim()).filter(Boolean);
62891
+ if (remotes.includes("origin")) remote = "origin";
62892
+ else if (remotes.length === 1) remote = remotes[0];
62893
+ } catch {
62894
+ }
62895
+ }
62896
+ if (!remote) return;
62897
+ let defaultBranch = "";
62898
+ try {
62899
+ const { stdout } = await execAsync5(`git rev-parse --abbrev-ref ${remote}/HEAD`, { cwd: this.rootDir });
62900
+ defaultBranch = stdout.trim().replace(new RegExp(`^${remote}/`), "");
62901
+ } catch {
62902
+ }
62903
+ if (!defaultBranch) {
62904
+ try {
62905
+ const { stdout } = await execAsync5("git rev-parse --abbrev-ref HEAD", { cwd: this.rootDir });
62906
+ defaultBranch = stdout.trim();
62907
+ } catch {
62908
+ return;
62909
+ }
62910
+ }
62911
+ if (!defaultBranch || defaultBranch === "HEAD") return;
62912
+ const remoteRef = `${remote}/${defaultBranch}`;
62913
+ try {
62914
+ await execAsync5(`git fetch ${this.quoteShellArg(remote)} ${this.quoteShellArg(defaultBranch)}`, { cwd: this.rootDir });
62915
+ } catch (err) {
62916
+ executorLog.warn(
62917
+ `Worktree rebase: fetch ${remote} ${defaultBranch} failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`
62918
+ );
62919
+ return;
62920
+ }
62921
+ try {
62922
+ await execAsync5(`git rebase ${this.quoteShellArg(remoteRef)}`, { cwd: worktreePath });
62923
+ await this.store.logEntry(
62924
+ taskId,
62925
+ `Rebased new worktree branch ${branch} onto ${remoteRef}`
62926
+ );
62927
+ } catch (rebaseErr) {
62928
+ const msg = rebaseErr instanceof Error ? rebaseErr.message : String(rebaseErr);
62929
+ executorLog.warn(
62930
+ `Worktree rebase: rebase onto ${remoteRef} failed for ${taskId} \u2014 aborting and leaving local base intact: ${msg}`
62931
+ );
62932
+ try {
62933
+ await execAsync5("git rebase --abort", { cwd: worktreePath });
62934
+ } catch {
62935
+ }
62936
+ await this.store.logEntry(
62937
+ taskId,
62938
+ `Could not rebase new worktree onto ${remoteRef} \u2014 kept local base. The merge-time rebase will retry with conflict resolution.`
62939
+ );
62940
+ }
62941
+ }
61903
62942
  /**
61904
62943
  * Resolve a stored baseBranch to a concrete commit SHA.
61905
62944
  *
@@ -62631,6 +63670,7 @@ Child agent: ${agent.id} (${name})`;
62631
63670
  });
62632
63671
  const parentAgent = childTask.assignedAgentId ? await this.options.agentStore.getAgent(childTask.assignedAgentId).catch(() => null) : null;
62633
63672
  const childRuntimeHint = extractRuntimeHint(agent.runtimeConfig) ?? extractRuntimeHint(parentAgent?.runtimeConfig);
63673
+ const { provider: childExecutorProvider, modelId: childExecutorModelId } = resolveExecutorModelPair2(void 0, void 0, settings);
62634
63674
  const { session: childSession } = await createResolvedAgentSession({
62635
63675
  sessionPurpose: "executor",
62636
63676
  runtimeHint: childRuntimeHint,
@@ -62638,8 +63678,8 @@ Child agent: ${agent.id} (${name})`;
62638
63678
  cwd: childWorktreePath,
62639
63679
  systemPrompt: childSystemPrompt,
62640
63680
  tools: "coding",
62641
- defaultProvider: settings.defaultProvider,
62642
- defaultModelId: settings.defaultModelId,
63681
+ defaultProvider: childExecutorProvider,
63682
+ defaultModelId: childExecutorModelId,
62643
63683
  fallbackProvider: settings.fallbackProvider,
62644
63684
  fallbackModelId: settings.fallbackModelId,
62645
63685
  // Skill selection: use assigned agent skills if available, otherwise role fallback
@@ -62735,6 +63775,25 @@ var init_mission_feature_sync = __esm({
62735
63775
  }
62736
63776
  });
62737
63777
 
63778
+ // ../engine/src/effective-node.ts
63779
+ function isSetNodeId(nodeId) {
63780
+ return typeof nodeId === "string" && nodeId.trim().length > 0;
63781
+ }
63782
+ function resolveEffectiveNode(task, settings) {
63783
+ if (isSetNodeId(task.nodeId)) {
63784
+ return { nodeId: task.nodeId, source: "task-override" };
63785
+ }
63786
+ if (isSetNodeId(settings.defaultNodeId)) {
63787
+ return { nodeId: settings.defaultNodeId, source: "project-default" };
63788
+ }
63789
+ return { nodeId: void 0, source: "local" };
63790
+ }
63791
+ var init_effective_node = __esm({
63792
+ "../engine/src/effective-node.ts"() {
63793
+ "use strict";
63794
+ }
63795
+ });
63796
+
62738
63797
  // ../engine/src/scheduler.ts
62739
63798
  import { existsSync as existsSync26 } from "node:fs";
62740
63799
  import { readFile as readFile15 } from "node:fs/promises";
@@ -62793,6 +63852,7 @@ var init_scheduler = __esm({
62793
63852
  init_logger2();
62794
63853
  init_mission_feature_sync();
62795
63854
  init_spec_staleness();
63855
+ init_effective_node();
62796
63856
  Scheduler = class {
62797
63857
  /**
62798
63858
  * Async listener guard convention:
@@ -63216,14 +64276,19 @@ var init_scheduler = __esm({
63216
64276
  schedulerLog.log(`Task ${task.id} is paused \u2014 skipping dispatch`);
63217
64277
  continue;
63218
64278
  }
64279
+ const effectiveNode = resolveEffectiveNode(freshTask, settings);
64280
+ schedulerLog.log(`Task ${task.id} routed to node=${effectiveNode.nodeId ?? "local"} (source=${effectiveNode.source})`);
63219
64281
  schedulerLog.log(`Starting ${task.id}: ${task.title || task.id} (deps satisfied)`);
63220
64282
  await this.store.updateTask(task.id, {
63221
64283
  status: null,
63222
64284
  blockedBy: null,
63223
64285
  baseBranch: baseBranch ?? void 0,
63224
- worktree: plannedWorktree
64286
+ worktree: plannedWorktree,
64287
+ effectiveNodeId: effectiveNode.nodeId ?? null,
64288
+ effectiveNodeSource: effectiveNode.source
63225
64289
  });
63226
64290
  await this.store.moveTask(task.id, "in-progress");
64291
+ await this.store.logEntry(task.id, `Node routing resolved: ${effectiveNode.nodeId ?? "local"} (source: ${effectiveNode.source})`);
63227
64292
  this.options.onSchedule?.(task);
63228
64293
  started++;
63229
64294
  if (settings.groupOverlappingFiles) {
@@ -65679,6 +66744,453 @@ Please review the PR comments and address any remaining issues.`;
65679
66744
  }
65680
66745
  });
65681
66746
 
66747
+ // ../engine/src/notification/ntfy-provider.ts
66748
+ var SUPPORTED_EVENTS, NtfyNotificationProvider;
66749
+ var init_ntfy_provider = __esm({
66750
+ "../engine/src/notification/ntfy-provider.ts"() {
66751
+ "use strict";
66752
+ init_notifier();
66753
+ SUPPORTED_EVENTS = /* @__PURE__ */ new Set([
66754
+ "in-review",
66755
+ "merged",
66756
+ "failed",
66757
+ "awaiting-approval",
66758
+ "awaiting-user-review",
66759
+ "planning-awaiting-input"
66760
+ ]);
66761
+ NtfyNotificationProvider = class {
66762
+ config;
66763
+ abortController = null;
66764
+ getProviderId() {
66765
+ return "ntfy";
66766
+ }
66767
+ async initialize(config) {
66768
+ if (typeof config.topic !== "string" || config.topic.trim() === "") {
66769
+ return;
66770
+ }
66771
+ this.config = config;
66772
+ this.config.events = resolveNtfyEvents(this.config.events);
66773
+ this.abortController = new AbortController();
66774
+ }
66775
+ async shutdown() {
66776
+ this.abortController?.abort();
66777
+ this.abortController = null;
66778
+ }
66779
+ isEventSupported(event) {
66780
+ if (!SUPPORTED_EVENTS.has(event)) {
66781
+ return false;
66782
+ }
66783
+ const enabledEvents = this.config?.events ?? [...DEFAULT_NTFY_EVENTS];
66784
+ return enabledEvents.includes(event);
66785
+ }
66786
+ async sendNotification(event, payload) {
66787
+ if (!this.config?.topic) {
66788
+ return { success: false, providerId: this.getProviderId(), error: "ntfy topic not configured" };
66789
+ }
66790
+ if (!this.isEventSupported(event)) {
66791
+ return {
66792
+ success: false,
66793
+ providerId: this.getProviderId(),
66794
+ error: `unsupported event: ${event}`
66795
+ };
66796
+ }
66797
+ const taskLike = {
66798
+ id: payload.taskId,
66799
+ title: payload.taskTitle,
66800
+ description: payload.taskDescription ?? ""
66801
+ };
66802
+ const identifier = formatTaskIdentifier(taskLike);
66803
+ const clickUrl = buildNtfyClickUrl({
66804
+ dashboardHost: this.config.dashboardHost,
66805
+ projectId: this.config.projectId,
66806
+ taskId: payload.taskId
66807
+ });
66808
+ const contentByEvent = {
66809
+ "in-review": {
66810
+ title: `Task ${payload.taskId} completed`,
66811
+ message: `Task "${identifier}" is ready for review`,
66812
+ priority: "default"
66813
+ },
66814
+ merged: {
66815
+ title: `Task ${payload.taskId} merged`,
66816
+ message: `Task "${identifier}" has been merged to main`,
66817
+ priority: "default"
66818
+ },
66819
+ failed: {
66820
+ title: `Task ${payload.taskId} failed`,
66821
+ message: `Task "${identifier}" has failed and needs attention`,
66822
+ priority: "high"
66823
+ },
66824
+ "awaiting-approval": {
66825
+ title: `Plan needs approval for ${payload.taskId}`,
66826
+ message: `Task "${identifier}" needs your approval before it can proceed`,
66827
+ priority: "high"
66828
+ },
66829
+ "awaiting-user-review": {
66830
+ title: `User review needed for ${payload.taskId}`,
66831
+ message: `Task "${identifier}" needs human review before it can proceed`,
66832
+ priority: "high"
66833
+ },
66834
+ "planning-awaiting-input": {
66835
+ title: `Planning input needed for ${payload.taskId}`,
66836
+ message: `Task "${identifier}" is awaiting your input during planning`,
66837
+ priority: "high"
66838
+ }
66839
+ };
66840
+ const content = contentByEvent[event];
66841
+ await sendNtfyNotification({
66842
+ ntfyBaseUrl: this.config.ntfyBaseUrl,
66843
+ topic: this.config.topic,
66844
+ title: content.title,
66845
+ message: content.message,
66846
+ priority: content.priority,
66847
+ clickUrl,
66848
+ signal: this.abortController?.signal
66849
+ });
66850
+ return { success: true, providerId: this.getProviderId() };
66851
+ }
66852
+ };
66853
+ }
66854
+ });
66855
+
66856
+ // ../engine/src/notification/webhook-provider.ts
66857
+ var WebhookNotificationProvider;
66858
+ var init_webhook_provider = __esm({
66859
+ "../engine/src/notification/webhook-provider.ts"() {
66860
+ "use strict";
66861
+ init_logger2();
66862
+ WebhookNotificationProvider = class {
66863
+ config = null;
66864
+ abortController = null;
66865
+ getProviderId() {
66866
+ return "webhook";
66867
+ }
66868
+ async initialize(config) {
66869
+ const webhookUrl = typeof config.webhookUrl === "string" ? config.webhookUrl.trim() : "";
66870
+ if (!webhookUrl) {
66871
+ throw new Error("webhookUrl is required");
66872
+ }
66873
+ let parsedUrl;
66874
+ try {
66875
+ parsedUrl = new URL(webhookUrl);
66876
+ } catch {
66877
+ throw new Error("webhookUrl must be a valid URL");
66878
+ }
66879
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
66880
+ throw new Error("webhookUrl must use http:// or https://");
66881
+ }
66882
+ const webhookFormat = config.webhookFormat === "slack" || config.webhookFormat === "discord" || config.webhookFormat === "generic" ? config.webhookFormat : "generic";
66883
+ this.config = {
66884
+ webhookUrl,
66885
+ webhookFormat,
66886
+ events: Array.isArray(config.events) ? config.events.filter((event) => typeof event === "string") : []
66887
+ };
66888
+ this.abortController?.abort();
66889
+ this.abortController = new AbortController();
66890
+ }
66891
+ async shutdown() {
66892
+ this.abortController?.abort();
66893
+ this.abortController = null;
66894
+ this.config = null;
66895
+ }
66896
+ isEventSupported(event) {
66897
+ if (!this.config?.events || this.config.events.length === 0) {
66898
+ return true;
66899
+ }
66900
+ return this.config.events.includes(event);
66901
+ }
66902
+ async sendNotification(event, payload) {
66903
+ if (!this.config) {
66904
+ return { success: false, providerId: this.getProviderId(), error: "Not initialized" };
66905
+ }
66906
+ if (!this.isEventSupported(event)) {
66907
+ return {
66908
+ success: false,
66909
+ providerId: this.getProviderId(),
66910
+ error: `unsupported event: ${event}`
66911
+ };
66912
+ }
66913
+ try {
66914
+ const message = this.formatMessage(event, payload);
66915
+ const body = this.formatPayload(payload, message);
66916
+ const response = await fetch(this.config.webhookUrl, {
66917
+ method: "POST",
66918
+ headers: {
66919
+ "Content-Type": "application/json"
66920
+ },
66921
+ body: JSON.stringify(body),
66922
+ signal: this.abortController?.signal
66923
+ });
66924
+ if (!response.ok) {
66925
+ const error = `Webhook notification failed: ${response.status} ${response.statusText}`;
66926
+ schedulerLog.log(error);
66927
+ return {
66928
+ success: false,
66929
+ providerId: this.getProviderId(),
66930
+ error
66931
+ };
66932
+ }
66933
+ return { success: true, providerId: this.getProviderId() };
66934
+ } catch (error) {
66935
+ const message = error instanceof Error ? error.message : String(error);
66936
+ schedulerLog.log(`Failed to send webhook notification: ${message}`);
66937
+ return {
66938
+ success: false,
66939
+ providerId: this.getProviderId(),
66940
+ error: message
66941
+ };
66942
+ }
66943
+ }
66944
+ formatMessage(event, payload) {
66945
+ const identifier = this.formatTaskIdentifier(payload);
66946
+ switch (event) {
66947
+ case "in-review":
66948
+ return `Task "${identifier}" is ready for review`;
66949
+ case "merged":
66950
+ return `Task "${identifier}" has been merged to main`;
66951
+ case "failed":
66952
+ return `Task "${identifier}" has failed and needs attention`;
66953
+ case "awaiting-approval":
66954
+ return `Task "${identifier}" needs your approval before it can proceed`;
66955
+ case "awaiting-user-review":
66956
+ return `Task "${identifier}" needs human review before it can proceed`;
66957
+ case "planning-awaiting-input":
66958
+ return `Task "${identifier}" is awaiting your input during planning`;
66959
+ case "gridlock":
66960
+ return "Pipeline gridlocked";
66961
+ default:
66962
+ return `Event "${event}" for task ${identifier}`;
66963
+ }
66964
+ }
66965
+ formatTaskIdentifier(payload) {
66966
+ if (payload.taskTitle?.trim()) {
66967
+ return payload.taskTitle;
66968
+ }
66969
+ const description = payload.taskDescription ?? "";
66970
+ const snippet = description.length > 200 ? `${description.slice(0, 200)}...` : description;
66971
+ return `${payload.taskId}: ${snippet}`;
66972
+ }
66973
+ formatPayload(payload, message) {
66974
+ if (!this.config) {
66975
+ return {};
66976
+ }
66977
+ if (this.config.webhookFormat === "slack") {
66978
+ return { text: message };
66979
+ }
66980
+ if (this.config.webhookFormat === "discord") {
66981
+ return { content: message };
66982
+ }
66983
+ return {
66984
+ event: payload.event,
66985
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
66986
+ task: {
66987
+ id: payload.taskId,
66988
+ title: payload.taskTitle
66989
+ },
66990
+ metadata: payload.metadata
66991
+ };
66992
+ }
66993
+ };
66994
+ }
66995
+ });
66996
+
66997
+ // ../engine/src/notification/notification-service.ts
66998
+ var NotificationService;
66999
+ var init_notification_service = __esm({
67000
+ "../engine/src/notification/notification-service.ts"() {
67001
+ "use strict";
67002
+ init_src();
67003
+ init_notifier();
67004
+ init_logger2();
67005
+ init_ntfy_provider();
67006
+ init_webhook_provider();
67007
+ NotificationService = class {
67008
+ constructor(store, options = {}) {
67009
+ this.store = store;
67010
+ this.options = options;
67011
+ }
67012
+ dispatcher = new NotificationDispatcher();
67013
+ notifiedEvents = /* @__PURE__ */ new Set();
67014
+ started = false;
67015
+ notificationsEnabled = false;
67016
+ ntfyProvider;
67017
+ webhookProvider;
67018
+ registerProvider(provider) {
67019
+ this.dispatcher.registerProvider(provider);
67020
+ }
67021
+ async start() {
67022
+ if (this.started) {
67023
+ return;
67024
+ }
67025
+ const settings = await this.store.getSettings();
67026
+ this.setNotificationsEnabledFromSettings(settings);
67027
+ await this.syncNtfyProvider(settings);
67028
+ await this.syncWebhookProvider(settings);
67029
+ await this.dispatcher.initializeAll();
67030
+ this.store.on("task:moved", this.handleTaskMoved);
67031
+ this.store.on("task:updated", this.handleTaskUpdated);
67032
+ this.store.on("task:merged", this.handleTaskMerged);
67033
+ this.store.on("settings:updated", this.handleSettingsUpdated);
67034
+ this.started = true;
67035
+ schedulerLog.log("NotificationService started");
67036
+ }
67037
+ async stop() {
67038
+ if (!this.started) {
67039
+ return;
67040
+ }
67041
+ if (typeof this.store.off === "function") {
67042
+ this.store.off("task:moved", this.handleTaskMoved);
67043
+ this.store.off("task:updated", this.handleTaskUpdated);
67044
+ this.store.off("task:merged", this.handleTaskMerged);
67045
+ this.store.off("settings:updated", this.handleSettingsUpdated);
67046
+ }
67047
+ await this.dispatcher.shutdownAll();
67048
+ this.started = false;
67049
+ schedulerLog.log("NotificationService stopped");
67050
+ }
67051
+ handleTaskMoved = (data) => {
67052
+ if (!this.notificationsEnabled || data.to !== "in-review") {
67053
+ return;
67054
+ }
67055
+ const payload = this.createTaskPayload(data.task, "in-review");
67056
+ this.maybeNotify(data.task.id, "in-review", payload);
67057
+ };
67058
+ handleTaskUpdated = (task) => {
67059
+ if (!this.notificationsEnabled) {
67060
+ return;
67061
+ }
67062
+ if (task.status === "failed") {
67063
+ this.maybeNotify(task.id, "failed", this.createTaskPayload(task, "failed"));
67064
+ }
67065
+ if (task.status === "awaiting-approval") {
67066
+ this.maybeNotify(
67067
+ task.id,
67068
+ "awaiting-approval",
67069
+ this.createTaskPayload(task, "awaiting-approval")
67070
+ );
67071
+ }
67072
+ if (task.status === "awaiting-user-review") {
67073
+ this.maybeNotify(
67074
+ task.id,
67075
+ "awaiting-user-review",
67076
+ this.createTaskPayload(task, "awaiting-user-review")
67077
+ );
67078
+ }
67079
+ };
67080
+ handleTaskMerged = (result) => {
67081
+ if (!this.notificationsEnabled || !result.merged) {
67082
+ return;
67083
+ }
67084
+ this.maybeNotify(
67085
+ result.task.id,
67086
+ "merged",
67087
+ this.createTaskPayload(result.task, "merged")
67088
+ );
67089
+ };
67090
+ handleSettingsUpdated = async (data) => {
67091
+ const { settings, previous } = data;
67092
+ this.setNotificationsEnabledFromSettings(settings);
67093
+ if (settings.ntfyEnabled !== previous.ntfyEnabled || settings.ntfyTopic !== previous.ntfyTopic || settings.ntfyBaseUrl !== previous.ntfyBaseUrl || settings.ntfyDashboardHost !== previous.ntfyDashboardHost || JSON.stringify(settings.ntfyEvents) !== JSON.stringify(previous.ntfyEvents)) {
67094
+ const wasEnabled = Boolean(previous.ntfyEnabled && previous.ntfyTopic);
67095
+ const isEnabled = Boolean(settings.ntfyEnabled && settings.ntfyTopic);
67096
+ await this.syncNtfyProvider(settings);
67097
+ if (isEnabled && !wasEnabled) {
67098
+ schedulerLog.log("NotificationService ntfy enabled");
67099
+ } else if (!isEnabled && wasEnabled) {
67100
+ schedulerLog.log("NotificationService ntfy disabled");
67101
+ } else if (settings.ntfyTopic !== previous.ntfyTopic) {
67102
+ schedulerLog.log("NotificationService ntfy topic updated");
67103
+ } else if (settings.ntfyBaseUrl !== previous.ntfyBaseUrl) {
67104
+ schedulerLog.log("NotificationService ntfy base URL updated");
67105
+ } else if (settings.ntfyDashboardHost !== previous.ntfyDashboardHost) {
67106
+ schedulerLog.log("NotificationService ntfy dashboard host updated");
67107
+ } else if (JSON.stringify(settings.ntfyEvents) !== JSON.stringify(previous.ntfyEvents)) {
67108
+ schedulerLog.log("NotificationService ntfy events updated");
67109
+ }
67110
+ }
67111
+ if (settings.webhookEnabled !== previous.webhookEnabled || settings.webhookUrl !== previous.webhookUrl || settings.webhookFormat !== previous.webhookFormat || JSON.stringify(settings.webhookEvents) !== JSON.stringify(previous.webhookEvents)) {
67112
+ await this.syncWebhookProvider(settings);
67113
+ schedulerLog.log("WebhookNotificationProvider config updated");
67114
+ }
67115
+ };
67116
+ async syncNtfyProvider(settings) {
67117
+ const enabled = Boolean(settings.ntfyEnabled && settings.ntfyTopic);
67118
+ if (!enabled) {
67119
+ if (this.ntfyProvider) {
67120
+ await this.ntfyProvider.shutdown?.();
67121
+ this.dispatcher.unregisterProvider(this.ntfyProvider.getProviderId());
67122
+ this.ntfyProvider = void 0;
67123
+ }
67124
+ return;
67125
+ }
67126
+ if (!this.ntfyProvider) {
67127
+ this.ntfyProvider = new NtfyNotificationProvider();
67128
+ this.registerProvider(this.ntfyProvider);
67129
+ }
67130
+ await this.ntfyProvider.initialize?.({
67131
+ topic: settings.ntfyTopic,
67132
+ ntfyBaseUrl: settings.ntfyBaseUrl ?? this.options.ntfyBaseUrl,
67133
+ dashboardHost: settings.ntfyDashboardHost,
67134
+ events: settings.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS],
67135
+ projectId: this.options.projectId
67136
+ });
67137
+ }
67138
+ async syncWebhookProvider(settings) {
67139
+ const enabled = Boolean(settings.webhookEnabled && settings.webhookUrl);
67140
+ if (!enabled) {
67141
+ if (this.webhookProvider) {
67142
+ await this.webhookProvider.shutdown?.();
67143
+ this.dispatcher.unregisterProvider(this.webhookProvider.getProviderId());
67144
+ this.webhookProvider = void 0;
67145
+ }
67146
+ return;
67147
+ }
67148
+ if (!this.webhookProvider) {
67149
+ this.webhookProvider = new WebhookNotificationProvider();
67150
+ this.registerProvider(this.webhookProvider);
67151
+ }
67152
+ await this.webhookProvider.initialize?.({
67153
+ webhookUrl: settings.webhookUrl,
67154
+ webhookFormat: settings.webhookFormat ?? "generic",
67155
+ events: settings.webhookEvents ?? []
67156
+ });
67157
+ }
67158
+ setNotificationsEnabledFromSettings(settings) {
67159
+ this.notificationsEnabled = Boolean(
67160
+ settings.ntfyEnabled && settings.ntfyTopic || settings.webhookEnabled && settings.webhookUrl
67161
+ );
67162
+ }
67163
+ createTaskPayload(task, event) {
67164
+ return {
67165
+ taskId: task.id,
67166
+ taskTitle: task.title,
67167
+ taskDescription: task.description,
67168
+ event
67169
+ };
67170
+ }
67171
+ maybeNotify(taskId, eventType, payload) {
67172
+ const key = `${taskId}:${eventType}`;
67173
+ if (this.notifiedEvents.has(key)) {
67174
+ return;
67175
+ }
67176
+ this.notifiedEvents.add(key);
67177
+ this.dispatcher.dispatch(eventType, payload).catch(() => {
67178
+ });
67179
+ }
67180
+ };
67181
+ }
67182
+ });
67183
+
67184
+ // ../engine/src/notification/index.ts
67185
+ var init_notification = __esm({
67186
+ "../engine/src/notification/index.ts"() {
67187
+ "use strict";
67188
+ init_ntfy_provider();
67189
+ init_webhook_provider();
67190
+ init_notification_service();
67191
+ }
67192
+ });
67193
+
65682
67194
  // ../engine/src/notifier.ts
65683
67195
  function formatTaskIdentifier(task) {
65684
67196
  if (task.title) {
@@ -65757,6 +67269,7 @@ var init_notifier = __esm({
65757
67269
  "../engine/src/notifier.ts"() {
65758
67270
  "use strict";
65759
67271
  init_logger2();
67272
+ init_notification();
65760
67273
  DEFAULT_NTFY_BASE_URL = "https://ntfy.sh";
65761
67274
  DEFAULT_NTFY_EVENTS = [
65762
67275
  "in-review",
@@ -65764,7 +67277,8 @@ var init_notifier = __esm({
65764
67277
  "failed",
65765
67278
  "awaiting-approval",
65766
67279
  "awaiting-user-review",
65767
- "planning-awaiting-input"
67280
+ "planning-awaiting-input",
67281
+ "gridlock"
65768
67282
  ];
65769
67283
  NtfyNotifier = class {
65770
67284
  constructor(store, options = {}) {
@@ -65772,6 +67286,10 @@ var init_notifier = __esm({
65772
67286
  this.defaultNtfyBaseUrl = resolveNtfyBaseUrl(options.ntfyBaseUrl);
65773
67287
  this.ntfyBaseUrl = this.defaultNtfyBaseUrl;
65774
67288
  this.projectId = options.projectId;
67289
+ this.notificationService = new NotificationService(store, {
67290
+ projectId: this.projectId,
67291
+ ntfyBaseUrl: options.ntfyBaseUrl
67292
+ });
65775
67293
  }
65776
67294
  config = {
65777
67295
  enabled: false,
@@ -65779,6 +67297,7 @@ var init_notifier = __esm({
65779
67297
  dashboardHost: void 0,
65780
67298
  events: [...DEFAULT_NTFY_EVENTS]
65781
67299
  };
67300
+ notificationService;
65782
67301
  ntfyBaseUrl;
65783
67302
  defaultNtfyBaseUrl;
65784
67303
  projectId;
@@ -65788,154 +67307,23 @@ var init_notifier = __esm({
65788
67307
  this.abortController = new AbortController();
65789
67308
  const settings = await this.store.getSettings();
65790
67309
  this.loadConfig(settings);
65791
- this.store.on("task:moved", this.handleTaskMoved);
65792
- this.store.on("task:updated", this.handleTaskUpdated);
65793
- this.store.on("task:merged", this.handleTaskMerged);
65794
67310
  this.store.on("settings:updated", this.handleSettingsUpdated);
67311
+ await this.notificationService.start();
65795
67312
  schedulerLog.log("NtfyNotifier started");
65796
67313
  }
65797
67314
  stop() {
65798
67315
  if (typeof this.store.off === "function") {
65799
- this.store.off("task:moved", this.handleTaskMoved);
65800
- this.store.off("task:updated", this.handleTaskUpdated);
65801
- this.store.off("task:merged", this.handleTaskMerged);
65802
67316
  this.store.off("settings:updated", this.handleSettingsUpdated);
65803
67317
  }
65804
67318
  if (this.abortController) {
65805
67319
  this.abortController.abort();
65806
67320
  this.abortController = null;
65807
67321
  }
67322
+ void this.notificationService.stop();
65808
67323
  schedulerLog.log("NtfyNotifier stopped");
65809
67324
  }
65810
- handleTaskMoved = (data) => {
65811
- if (!this.config.enabled || !this.config.topic) return;
65812
- const { task, to } = data;
65813
- if (to === "in-review" && this.isEventEnabled("in-review")) {
65814
- const clickUrl = buildNtfyClickUrl({
65815
- dashboardHost: this.config.dashboardHost,
65816
- projectId: this.projectId,
65817
- taskId: task.id
65818
- });
65819
- this.maybeNotify(
65820
- task.id,
65821
- "in-review",
65822
- () => sendNtfyNotification({
65823
- ntfyBaseUrl: this.ntfyBaseUrl,
65824
- topic: this.config.topic,
65825
- title: `Task ${task.id} completed`,
65826
- message: `Task "${formatTaskIdentifier(task)}" is ready for review`,
65827
- priority: "default",
65828
- clickUrl,
65829
- signal: this.abortController?.signal
65830
- })
65831
- );
65832
- }
65833
- };
65834
- handleTaskUpdated = (task) => {
65835
- if (!this.config.enabled || !this.config.topic) return;
65836
- if (task.status === "failed" && this.isEventEnabled("failed")) {
65837
- const clickUrl = buildNtfyClickUrl({
65838
- dashboardHost: this.config.dashboardHost,
65839
- projectId: this.projectId,
65840
- taskId: task.id
65841
- });
65842
- this.maybeNotify(
65843
- task.id,
65844
- "failed",
65845
- () => sendNtfyNotification({
65846
- ntfyBaseUrl: this.ntfyBaseUrl,
65847
- topic: this.config.topic,
65848
- title: `Task ${task.id} failed`,
65849
- message: `Task "${formatTaskIdentifier(task)}" has failed and needs attention`,
65850
- priority: "high",
65851
- clickUrl,
65852
- signal: this.abortController?.signal
65853
- })
65854
- );
65855
- }
65856
- if (task.status === "awaiting-approval" && this.isEventEnabled("awaiting-approval")) {
65857
- const clickUrl = buildNtfyClickUrl({
65858
- dashboardHost: this.config.dashboardHost,
65859
- projectId: this.projectId,
65860
- taskId: task.id
65861
- });
65862
- this.maybeNotify(
65863
- task.id,
65864
- "awaiting-approval",
65865
- () => sendNtfyNotification({
65866
- ntfyBaseUrl: this.ntfyBaseUrl,
65867
- topic: this.config.topic,
65868
- title: `Plan needs approval for ${task.id}`,
65869
- message: `Task "${formatTaskIdentifier(task)}" needs your approval before it can proceed`,
65870
- priority: "high",
65871
- clickUrl,
65872
- signal: this.abortController?.signal
65873
- })
65874
- );
65875
- }
65876
- if (task.status === "awaiting-user-review" && this.isEventEnabled("awaiting-user-review")) {
65877
- const clickUrl = buildNtfyClickUrl({
65878
- dashboardHost: this.config.dashboardHost,
65879
- projectId: this.projectId,
65880
- taskId: task.id
65881
- });
65882
- this.maybeNotify(
65883
- task.id,
65884
- "awaiting-user-review",
65885
- () => sendNtfyNotification({
65886
- ntfyBaseUrl: this.ntfyBaseUrl,
65887
- topic: this.config.topic,
65888
- title: `User review needed for ${task.id}`,
65889
- message: `Task "${formatTaskIdentifier(task)}" needs human review before it can proceed`,
65890
- priority: "high",
65891
- clickUrl,
65892
- signal: this.abortController?.signal
65893
- })
65894
- );
65895
- }
65896
- };
65897
- handleTaskMerged = (result) => {
65898
- if (!this.config.enabled || !this.config.topic) return;
65899
- if (result.merged && this.isEventEnabled("merged")) {
65900
- const clickUrl = buildNtfyClickUrl({
65901
- dashboardHost: this.config.dashboardHost,
65902
- projectId: this.projectId,
65903
- taskId: result.task.id
65904
- });
65905
- this.maybeNotify(
65906
- result.task.id,
65907
- "merged",
65908
- () => sendNtfyNotification({
65909
- ntfyBaseUrl: this.ntfyBaseUrl,
65910
- topic: this.config.topic,
65911
- title: `Task ${result.task.id} merged`,
65912
- message: `Task "${formatTaskIdentifier(result.task)}" has been merged to main`,
65913
- priority: "default",
65914
- clickUrl,
65915
- signal: this.abortController?.signal
65916
- })
65917
- );
65918
- }
65919
- };
65920
67325
  handleSettingsUpdated = (data) => {
65921
- const { settings, previous } = data;
65922
- if (settings.ntfyEnabled !== previous.ntfyEnabled || settings.ntfyTopic !== previous.ntfyTopic || settings.ntfyBaseUrl !== previous.ntfyBaseUrl || settings.ntfyDashboardHost !== previous.ntfyDashboardHost || JSON.stringify(settings.ntfyEvents) !== JSON.stringify(previous.ntfyEvents)) {
65923
- const wasEnabled = this.config.enabled;
65924
- this.loadConfig(settings);
65925
- if (this.config.enabled && !wasEnabled) {
65926
- schedulerLog.log("NtfyNotifier enabled");
65927
- } else if (!this.config.enabled && wasEnabled) {
65928
- schedulerLog.log("NtfyNotifier disabled");
65929
- } else if (this.config.topic !== previous.ntfyTopic) {
65930
- schedulerLog.log("NtfyNotifier topic updated");
65931
- } else if (this.ntfyBaseUrl !== resolveNtfyBaseUrl(previous.ntfyBaseUrl)) {
65932
- schedulerLog.log("NtfyNotifier base URL updated");
65933
- } else if (this.config.dashboardHost !== previous.ntfyDashboardHost) {
65934
- schedulerLog.log("NtfyNotifier dashboard host updated");
65935
- } else if (JSON.stringify(this.config.events) !== JSON.stringify(previous.ntfyEvents)) {
65936
- schedulerLog.log("NtfyNotifier events updated");
65937
- }
65938
- }
67326
+ this.loadConfig(data.settings);
65939
67327
  };
65940
67328
  loadConfig(settings) {
65941
67329
  this.config = {
@@ -65946,11 +67334,38 @@ var init_notifier = __esm({
65946
67334
  };
65947
67335
  this.ntfyBaseUrl = resolveNtfyBaseUrl(settings.ntfyBaseUrl, this.defaultNtfyBaseUrl);
65948
67336
  }
67337
+ notifyGridlock(event) {
67338
+ if (!this.config.enabled || !this.config.topic || !this.isEventEnabled("gridlock")) return;
67339
+ const blockedTasks = event.blockedTaskIds.sort();
67340
+ const reasonSummary = Object.values(event.reasons).reduce((acc, reason) => {
67341
+ acc[reason] = (acc[reason] ?? 0) + 1;
67342
+ return acc;
67343
+ }, {});
67344
+ const reasons = [];
67345
+ if (reasonSummary.dependency) reasons.push(`${reasonSummary.dependency} dependency`);
67346
+ if (reasonSummary.overlap) reasons.push(`${reasonSummary.overlap} overlap`);
67347
+ const clickUrl = buildNtfyClickUrl({
67348
+ dashboardHost: this.config.dashboardHost,
67349
+ projectId: this.projectId
67350
+ });
67351
+ const dedupKey = `gridlock:${blockedTasks.join(",")}`;
67352
+ this.maybeNotifyByKey(
67353
+ dedupKey,
67354
+ () => sendNtfyNotification({
67355
+ ntfyBaseUrl: this.ntfyBaseUrl,
67356
+ topic: this.config.topic,
67357
+ title: "Pipeline gridlocked",
67358
+ message: `${event.blockedTaskCount} todo tasks are blocked (${reasons.join(", ")}). Blocked: ${blockedTasks.join(", ")}. Blocking: ${event.blockingTaskIds.join(", ") || "none"}.`,
67359
+ priority: "high",
67360
+ clickUrl,
67361
+ signal: this.abortController?.signal
67362
+ })
67363
+ );
67364
+ }
65949
67365
  isEventEnabled(event) {
65950
67366
  return isNtfyEventEnabled(this.config.events, event);
65951
67367
  }
65952
- maybeNotify(taskId, eventType, notifyFn) {
65953
- const key = `${taskId}:${eventType}`;
67368
+ maybeNotifyByKey(key, notifyFn) {
65954
67369
  if (this.notifiedEvents.has(key)) {
65955
67370
  return;
65956
67371
  }
@@ -65978,11 +67393,10 @@ var init_shell_utils = __esm({
65978
67393
  import { exec as exec6 } from "node:child_process";
65979
67394
  import { promisify as promisify7 } from "node:util";
65980
67395
  async function createAiPromptExecutor(cwd) {
65981
- const { createFnAgent: createFnAgent5, promptWithFallback: promptWithFallback2 } = await Promise.resolve().then(() => (init_pi(), pi_exports));
65982
67396
  const disposeLog = createLogger2("cron-runner");
65983
67397
  return async (prompt, modelProvider, modelId) => {
65984
67398
  let responseText = "";
65985
- const { session } = await createFnAgent5({
67399
+ const { session } = await createFnAgent2({
65986
67400
  cwd,
65987
67401
  systemPrompt: AI_AUTOMATION_SYSTEM_PROMPT,
65988
67402
  tools: "readonly",
@@ -65993,7 +67407,7 @@ async function createAiPromptExecutor(cwd) {
65993
67407
  }
65994
67408
  });
65995
67409
  try {
65996
- await promptWithFallback2(session, prompt);
67410
+ await promptWithFallback(session, prompt);
65997
67411
  return responseText;
65998
67412
  } finally {
65999
67413
  try {
@@ -66019,8 +67433,10 @@ var execAsync6, log6, DEFAULT_TIMEOUT_MS, MAX_BUFFER, MAX_OUTPUT_LENGTH, DEFAULT
66019
67433
  var init_cron_runner = __esm({
66020
67434
  "../engine/src/cron-runner.ts"() {
66021
67435
  "use strict";
67436
+ init_src();
66022
67437
  init_logger2();
66023
67438
  init_shell_utils();
67439
+ init_pi();
66024
67440
  execAsync6 = promisify7(exec6);
66025
67441
  log6 = createLogger2("cron-runner");
66026
67442
  DEFAULT_TIMEOUT_MS = 5 * 60 * 1e3;
@@ -66343,8 +67759,9 @@ var init_cron_runner = __esm({
66343
67759
  };
66344
67760
  }
66345
67761
  const settings = await this.store.getSettings();
66346
- const modelProvider = step.modelProvider?.trim() || settings.defaultProvider;
66347
- const modelId = step.modelId?.trim() || settings.defaultModelId;
67762
+ const defaultModel = resolveProjectDefaultModel(settings);
67763
+ const modelProvider = step.modelProvider?.trim() || defaultModel.provider;
67764
+ const modelId = step.modelId?.trim() || defaultModel.modelId;
66348
67765
  const model = modelProvider && modelId ? `${modelProvider}/${modelId}` : "default";
66349
67766
  log6.log(` AI prompt step "${step.name}" using model: ${model}`);
66350
67767
  log6.log(` Prompt: ${step.prompt.slice(0, 100)}${step.prompt.length > 100 ? "\u2026" : ""}`);
@@ -67341,6 +68758,7 @@ var init_agent_heartbeat = __esm({
67341
68758
  init_agent_instructions();
67342
68759
  init_logger2();
67343
68760
  init_run_audit();
68761
+ init_pi();
67344
68762
  HEARTBEAT_SYSTEM_PROMPT = `You are a heartbeat agent running in a short execution window.
67345
68763
 
67346
68764
  Your job:
@@ -68056,7 +69474,6 @@ When sending messages:
68056
69474
  };
68057
69475
  const previousBlockedState = await this.store.getLastBlockedState(agentId);
68058
69476
  if (previousBlockedState && isBlockedStateDuplicate(currentBlockedState, previousBlockedState)) {
68059
- heartbeatLog.log(`Task ${resolvedTaskId2} is still blocked by ${blockedBy} (duplicate state) \u2014 skipping comment`);
68060
69477
  await this.completeRun(agentId, run.id, {
68061
69478
  status: "completed",
68062
69479
  resultJson: { reason: "blocked_duplicate", taskId: resolvedTaskId2, blockedBy }
@@ -68108,7 +69525,6 @@ When sending messages:
68108
69525
  };
68109
69526
  }
68110
69527
  };
68111
- const { promptWithFallback: promptWithFallback2 } = await Promise.resolve().then(() => (init_pi(), pi_exports));
68112
69528
  const { createResolvedAgentSession: createResolvedAgentSession2, extractRuntimeHint: extractRuntimeHint2 } = await Promise.resolve().then(() => (init_agent_session_helpers(), agent_session_helpers_exports));
68113
69529
  const { buildSessionSkillContextSync: buildSessionSkillContextSync2 } = await Promise.resolve().then(() => (init_session_skill_context(), session_skill_context_exports));
68114
69530
  let heartbeatTools;
@@ -68302,7 +69718,7 @@ ${taskDetail.prompt}` : "No PROMPT.md available.",
68302
69718
  "Review the task status and take appropriate action. Call fn_heartbeat_done when finished."
68303
69719
  ].join("\n");
68304
69720
  }
68305
- await promptWithFallback2(session, executionPrompt);
69721
+ await promptWithFallback(session, executionPrompt);
68306
69722
  let usageInput = 0;
68307
69723
  let usageOutput = Math.ceil(outputLength / 4);
68308
69724
  let usageCached = 0;
@@ -69155,13 +70571,40 @@ var init_self_healing = __esm({
69155
70571
  return Date.now() - updatedAt >= timeoutMs;
69156
70572
  }
69157
70573
  async findLandedTaskCommit(task) {
69158
- const readLog = async (range) => {
70574
+ const storedSha = task.mergeDetails?.commitSha;
70575
+ if (storedSha) {
70576
+ try {
70577
+ await execAsync8(
70578
+ `git merge-base --is-ancestor ${shellQuote(storedSha)} HEAD`,
70579
+ { cwd: this.options.rootDir }
70580
+ );
70581
+ const { stdout: stdout2 } = await execAsync8(
70582
+ `git log -1 --format=%H%x1f%s ${shellQuote(storedSha)}`,
70583
+ { cwd: this.options.rootDir, maxBuffer: 1024 * 1024 }
70584
+ );
70585
+ const [sha2, subject2] = stdout2.trim().split("");
70586
+ if (sha2) {
70587
+ const commit2 = { sha: sha2, subject: subject2 };
70588
+ try {
70589
+ const stats = await execAsync8(`git show --shortstat --format= ${shellQuote(sha2)}`, {
70590
+ cwd: this.options.rootDir,
70591
+ maxBuffer: 1024 * 1024
70592
+ });
70593
+ Object.assign(commit2, parseShortstat(stats.stdout));
70594
+ } catch {
70595
+ }
70596
+ return commit2;
70597
+ }
70598
+ } catch {
70599
+ }
70600
+ }
70601
+ const readLog = async (range, grepArg, fixedStrings) => {
69159
70602
  const command = [
69160
70603
  "git log",
69161
70604
  "--format=%H%x1f%s",
69162
70605
  "--max-count=20",
69163
- "--fixed-strings",
69164
- `--grep=${shellQuote(task.id)}`,
70606
+ ...fixedStrings ? ["--fixed-strings"] : ["-E"],
70607
+ `--grep=${grepArg}`,
69165
70608
  shellQuote(range)
69166
70609
  ].join(" ");
69167
70610
  return execAsync8(command, {
@@ -69169,22 +70612,34 @@ var init_self_healing = __esm({
69169
70612
  maxBuffer: 1024 * 1024
69170
70613
  });
69171
70614
  };
69172
- let stdout;
69173
- try {
69174
- const result = await readLog(task.baseCommitSha ? `${task.baseCommitSha}..HEAD` : "HEAD");
69175
- stdout = result.stdout;
69176
- } catch (err) {
69177
- const errorMessage = err instanceof Error ? err.message : String(err);
69178
- log8.warn(
69179
- `Failed to read git log for landed commit lookup (${task.id}): ${errorMessage} \u2014 retrying with HEAD range`
69180
- );
69181
- if (!task.baseCommitSha) return null;
69182
- const result = await readLog("HEAD");
69183
- stdout = result.stdout;
69184
- }
69185
- if (!stdout.trim() && task.baseCommitSha) {
69186
- const result = await readLog("HEAD");
69187
- stdout = result.stdout;
70615
+ const search = async (grepArg, fixedStrings) => {
70616
+ let out;
70617
+ try {
70618
+ const r = await readLog(
70619
+ task.baseCommitSha ? `${task.baseCommitSha}..HEAD` : "HEAD",
70620
+ grepArg,
70621
+ fixedStrings
70622
+ );
70623
+ out = r.stdout;
70624
+ } catch (err) {
70625
+ const errorMessage = err instanceof Error ? err.message : String(err);
70626
+ log8.warn(
70627
+ `Failed to read git log for landed commit lookup (${task.id}): ${errorMessage} \u2014 retrying with HEAD range`
70628
+ );
70629
+ if (!task.baseCommitSha) return "";
70630
+ const r = await readLog("HEAD", grepArg, fixedStrings);
70631
+ out = r.stdout;
70632
+ }
70633
+ if (!out.trim() && task.baseCommitSha) {
70634
+ const r = await readLog("HEAD", grepArg, fixedStrings);
70635
+ out = r.stdout;
70636
+ }
70637
+ return out;
70638
+ };
70639
+ const trailerPattern = `^Fusion-Task-Id: ${task.id}$`;
70640
+ let stdout = await search(shellQuote(trailerPattern), false);
70641
+ if (!stdout.trim()) {
70642
+ stdout = await search(shellQuote(task.id), true);
69188
70643
  }
69189
70644
  const firstLine = stdout.trim().split("\n").find(Boolean);
69190
70645
  if (!firstLine) return null;
@@ -69564,7 +71019,7 @@ var init_self_healing = __esm({
69564
71019
  if (!timeoutMs || timeoutMs <= 0) return 0;
69565
71020
  const tasks = await this.store.listTasks({ column: "in-review", slim: true });
69566
71021
  const candidates = tasks.filter(
69567
- (task) => task.column === "in-review" && Boolean(task.status && ACTIVE_MERGE_STATUSES.has(task.status)) && this.isPastInterruptedMergeGrace(task, timeoutMs)
71022
+ (task) => task.column === "in-review" && !task.paused && Boolean(task.status && ACTIVE_MERGE_STATUSES.has(task.status)) && this.isPastInterruptedMergeGrace(task, timeoutMs)
69568
71023
  );
69569
71024
  if (candidates.length === 0) return 0;
69570
71025
  log8.warn(`Found ${candidates.length} stale merging task(s) in in-review`);
@@ -69605,6 +71060,13 @@ var init_self_healing = __esm({
69605
71060
  "Auto-recovered: stale merge status cleared; merge will be retried"
69606
71061
  );
69607
71062
  log8.log(`Recovered interrupted merge ${task.id}: cleared stale status for retry`);
71063
+ try {
71064
+ this.options.enqueueMerge?.(task.id);
71065
+ } catch (enqueueErr) {
71066
+ log8.warn(
71067
+ `Failed to re-enqueue ${task.id} after stale-merge recovery (will rely on polling sweep): ${enqueueErr instanceof Error ? enqueueErr.message : String(enqueueErr)}`
71068
+ );
71069
+ }
69608
71070
  recovered++;
69609
71071
  } catch (err) {
69610
71072
  const errorMessage = err instanceof Error ? err.message : String(err);
@@ -69635,7 +71097,7 @@ var init_self_healing = __esm({
69635
71097
  try {
69636
71098
  const tasks = await this.store.listTasks({ column: "in-review", slim: true });
69637
71099
  const mergedButNotDone = tasks.filter(
69638
- (t) => t.column === "in-review" && t.mergeDetails?.mergeConfirmed === true
71100
+ (t) => t.column === "in-review" && !t.paused && t.mergeDetails?.mergeConfirmed === true
69639
71101
  );
69640
71102
  if (mergedButNotDone.length === 0) return 0;
69641
71103
  log8.warn(`Found ${mergedButNotDone.length} merged task(s) stuck in in-review`);
@@ -69683,7 +71145,7 @@ var init_self_healing = __esm({
69683
71145
  try {
69684
71146
  const tasks = await this.store.listTasks({ column: "in-review", slim: true });
69685
71147
  const misclassified = tasks.filter(
69686
- (t) => t.column === "in-review" && t.status === "failed" && t.error?.includes("without calling task_done") && t.steps.length > 0 && t.steps.every((s) => s.status === "done" || s.status === "skipped")
71148
+ (t) => t.column === "in-review" && !t.paused && t.status === "failed" && t.error?.includes("without calling task_done") && t.steps.length > 0 && t.steps.every((s) => s.status === "done" || s.status === "skipped")
69687
71149
  );
69688
71150
  if (misclassified.length === 0) return 0;
69689
71151
  log8.warn(`Found ${misclassified.length} misclassified failure(s) with all steps done`);
@@ -70803,6 +72265,12 @@ var init_in_process_runtime = __esm({
70803
72265
  ephemeralCleanupTimers = /* @__PURE__ */ new Map();
70804
72266
  /** Listener for agent:stateChanged events to clean up terminated ephemeral agents */
70805
72267
  ephemeralTerminationListener;
72268
+ /**
72269
+ * Optional callback the runtime forwards to SelfHealingManager so that
72270
+ * stale-merge recovery can re-enqueue tasks immediately. Set by ProjectEngine
72271
+ * before `start()` via `setMergeEnqueuer`.
72272
+ */
72273
+ mergeEnqueuer;
70806
72274
  /**
70807
72275
  * Start the runtime and initialize all subsystems.
70808
72276
  *
@@ -70923,8 +72391,7 @@ var init_in_process_runtime = __esm({
70923
72391
  this.recordActivity();
70924
72392
  runtimeLog.log(`Scheduled task ${task.id}`);
70925
72393
  },
70926
- onBlocked: (task, blockedBy) => {
70927
- runtimeLog.log(`Task ${task.id} blocked by: ${blockedBy.join(", ")}`);
72394
+ onBlocked: () => {
70928
72395
  }
70929
72396
  });
70930
72397
  this.stuckTaskDetector = new StuckTaskDetector(this.taskStore, {
@@ -71244,7 +72711,8 @@ var init_in_process_runtime = __esm({
71244
72711
  getExecutingTaskIds: () => this.executor.getExecutingTaskIds(),
71245
72712
  recoverApprovedTriageTask: (task) => this.triageProcessor?.recoverApprovedTask(task) ?? Promise.resolve(false),
71246
72713
  getPlanningTaskIds: () => this.triageProcessor?.getProcessingTaskIds() ?? /* @__PURE__ */ new Set(),
71247
- evictStaleTriageProcessing: () => this.triageProcessor?.evictStaleProcessing() ?? /* @__PURE__ */ new Set()
72714
+ evictStaleTriageProcessing: () => this.triageProcessor?.evictStaleProcessing() ?? /* @__PURE__ */ new Set(),
72715
+ enqueueMerge: this.mergeEnqueuer ? (taskId) => this.mergeEnqueuer?.(taskId) : void 0
71248
72716
  });
71249
72717
  this.selfHealingManager.start();
71250
72718
  this.stuckTaskDetector.start();
@@ -71421,6 +72889,14 @@ var init_in_process_runtime = __esm({
71421
72889
  getStatus() {
71422
72890
  return this.status;
71423
72891
  }
72892
+ /**
72893
+ * Register a callback used by SelfHealingManager to re-enqueue tasks for
72894
+ * auto-merge after clearing a stale `merging` status. Must be called before
72895
+ * `start()` because SelfHealingManager is constructed during startup.
72896
+ */
72897
+ setMergeEnqueuer(enqueueMerge) {
72898
+ this.mergeEnqueuer = enqueueMerge;
72899
+ }
71424
72900
  /**
71425
72901
  * Get the project's TaskStore instance.
71426
72902
  * @throws Error if runtime has not been started
@@ -73190,6 +74666,132 @@ var init_project_manager = __esm({
73190
74666
  }
73191
74667
  });
73192
74668
 
74669
+ // ../engine/src/gridlock-detector.ts
74670
+ var gridlockLog, GridlockDetector;
74671
+ var init_gridlock_detector = __esm({
74672
+ "../engine/src/gridlock-detector.ts"() {
74673
+ "use strict";
74674
+ init_logger2();
74675
+ init_scheduler();
74676
+ gridlockLog = createLogger2("gridlock-detector");
74677
+ GridlockDetector = class {
74678
+ constructor(store, options = {}) {
74679
+ this.store = store;
74680
+ this.pollIntervalMs = options.pollIntervalMs ?? 3e4;
74681
+ this.missionStore = options.missionStore;
74682
+ this.onGridlock = options.onGridlock;
74683
+ }
74684
+ interval = null;
74685
+ pollIntervalMs;
74686
+ missionStore;
74687
+ onGridlock;
74688
+ lastGridlockKey = null;
74689
+ start() {
74690
+ if (this.interval) return;
74691
+ this.interval = setInterval(() => {
74692
+ this.detectGridlock().catch((error) => {
74693
+ gridlockLog.error("Failed gridlock detection cycle:", error);
74694
+ });
74695
+ }, this.pollIntervalMs);
74696
+ gridlockLog.log(`Started (poll interval: ${this.pollIntervalMs}ms)`);
74697
+ }
74698
+ stop() {
74699
+ if (!this.interval) return;
74700
+ clearInterval(this.interval);
74701
+ this.interval = null;
74702
+ gridlockLog.log("Stopped");
74703
+ }
74704
+ async detectGridlock() {
74705
+ const [tasks, settings] = await Promise.all([
74706
+ this.store.listTasks({ slim: true, includeArchived: false }),
74707
+ this.store.getSettings()
74708
+ ]);
74709
+ const now = Date.now();
74710
+ const schedulable = tasks.filter((task) => {
74711
+ if (task.column !== "todo" || task.paused) return false;
74712
+ if (task.nextRecoveryAt && new Date(task.nextRecoveryAt).getTime() > now) return false;
74713
+ if (this.isMissionBlocked(task)) return false;
74714
+ return true;
74715
+ });
74716
+ if (schedulable.length === 0) {
74717
+ this.lastGridlockKey = null;
74718
+ return null;
74719
+ }
74720
+ const active = tasks.filter((task) => task.column === "in-progress" || task.column === "in-review" && Boolean(task.worktree));
74721
+ if (active.length === 0) {
74722
+ this.lastGridlockKey = null;
74723
+ return null;
74724
+ }
74725
+ const overlapIgnorePaths = settings.overlapIgnorePaths ?? [];
74726
+ const activeScopes = /* @__PURE__ */ new Map();
74727
+ if (settings.groupOverlappingFiles) {
74728
+ for (const task of active) {
74729
+ const scope = filterPathsByIgnoreList(await this.store.parseFileScopeFromPrompt(task.id), overlapIgnorePaths);
74730
+ if (scope.length > 0) {
74731
+ activeScopes.set(task.id, scope);
74732
+ }
74733
+ }
74734
+ }
74735
+ const reasons = {};
74736
+ const blockingTaskIds = /* @__PURE__ */ new Set();
74737
+ for (const task of schedulable) {
74738
+ const unmetDeps = task.dependencies.filter((depId) => {
74739
+ const dep = tasks.find((candidate) => candidate.id === depId);
74740
+ return dep && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived";
74741
+ });
74742
+ if (unmetDeps.length > 0) {
74743
+ reasons[task.id] = "dependency";
74744
+ for (const depId of unmetDeps) blockingTaskIds.add(depId);
74745
+ continue;
74746
+ }
74747
+ if (!settings.groupOverlappingFiles) continue;
74748
+ const taskScope = filterPathsByIgnoreList(await this.store.parseFileScopeFromPrompt(task.id), overlapIgnorePaths);
74749
+ if (taskScope.length === 0) continue;
74750
+ for (const [activeId, activeScope] of activeScopes) {
74751
+ if (pathsOverlap2(taskScope, activeScope)) {
74752
+ reasons[task.id] = "overlap";
74753
+ blockingTaskIds.add(activeId);
74754
+ break;
74755
+ }
74756
+ }
74757
+ }
74758
+ const blockedTaskIds = Object.keys(reasons).sort();
74759
+ if (blockedTaskIds.length !== schedulable.length) {
74760
+ this.lastGridlockKey = null;
74761
+ return null;
74762
+ }
74763
+ const gridlockKey = blockedTaskIds.join(",");
74764
+ const event = {
74765
+ blockedTaskCount: blockedTaskIds.length,
74766
+ reasons,
74767
+ blockedTaskIds,
74768
+ blockingTaskIds: Array.from(blockingTaskIds).sort()
74769
+ };
74770
+ if (this.lastGridlockKey !== gridlockKey) {
74771
+ this.lastGridlockKey = gridlockKey;
74772
+ gridlockLog.warn(`Gridlock detected: blocked=${event.blockedTaskIds.join(",")}; blocking=${event.blockingTaskIds.join(",")}`);
74773
+ this.onGridlock?.(event);
74774
+ }
74775
+ return event;
74776
+ }
74777
+ isMissionBlocked(task) {
74778
+ if (!this.missionStore || !task.sliceId) return false;
74779
+ try {
74780
+ const slice = this.missionStore.getSlice(task.sliceId);
74781
+ if (!slice) return false;
74782
+ const milestone = this.missionStore.getMilestone(slice.milestoneId);
74783
+ if (!milestone) return false;
74784
+ const mission = this.missionStore.getMission(milestone.missionId);
74785
+ return mission?.status === "blocked";
74786
+ } catch (error) {
74787
+ gridlockLog.warn(`Mission lookup failed for ${task.id}:`, error);
74788
+ return false;
74789
+ }
74790
+ }
74791
+ };
74792
+ }
74793
+ });
74794
+
73193
74795
  // ../engine/src/remote-access/provider-adapters.ts
73194
74796
  import { accessSync, constants as fsConstants } from "node:fs";
73195
74797
  function isAbsoluteOrPathLike(input) {
@@ -73772,6 +75374,8 @@ var init_project_engine = __esm({
73772
75374
  init_pr_monitor();
73773
75375
  init_pr_comment_handler();
73774
75376
  init_notifier();
75377
+ init_notification();
75378
+ init_gridlock_detector();
73775
75379
  init_cron_runner();
73776
75380
  init_merger();
73777
75381
  init_concurrency();
@@ -73785,11 +75389,24 @@ var init_project_engine = __esm({
73785
75389
  this.options = options;
73786
75390
  const runtimeConfig = options.externalTaskStore ? { ...config, externalTaskStore: options.externalTaskStore } : config;
73787
75391
  this.runtime = new InProcessRuntime(runtimeConfig, centralCore);
75392
+ this.runtime.setMergeEnqueuer?.((taskId) => {
75393
+ if (this.activeMergeTaskId === taskId) {
75394
+ this.mergeAbortController?.abort();
75395
+ this.mergeAbortController = null;
75396
+ this.activeMergeSession?.dispose();
75397
+ this.activeMergeSession = null;
75398
+ this.activeMergeTaskId = null;
75399
+ }
75400
+ this.mergeActive.delete(taskId);
75401
+ this.internalEnqueueMerge(taskId);
75402
+ });
73788
75403
  }
73789
75404
  runtime;
73790
75405
  prMonitor;
73791
75406
  prCommentHandler;
73792
75407
  notifier;
75408
+ notificationService;
75409
+ gridlockDetector;
73793
75410
  cronRunner;
73794
75411
  automationStore;
73795
75412
  remoteTunnelManager;
@@ -73854,12 +75471,21 @@ var init_project_engine = __esm({
73854
75471
  (taskId, prInfo, comments) => this.prCommentHandler.handleNewComments(taskId, prInfo, comments)
73855
75472
  );
73856
75473
  if (!this.options.skipNotifier) {
75474
+ this.notificationService = new NotificationService(store, {
75475
+ projectId: this.options.projectId,
75476
+ ntfyBaseUrl: this.options.ntfyBaseUrl
75477
+ });
75478
+ await this.notificationService.start();
73857
75479
  this.notifier = new NtfyNotifier(store, {
73858
75480
  projectId: this.options.projectId,
73859
75481
  ntfyBaseUrl: this.options.ntfyBaseUrl
73860
75482
  });
73861
75483
  await this.notifier.start();
73862
75484
  }
75485
+ this.gridlockDetector = new GridlockDetector(store, {
75486
+ onGridlock: (event) => this.notifier?.notifyGridlock(event)
75487
+ });
75488
+ this.gridlockDetector.start();
73863
75489
  this.setAutomationSubsystemHealth(
73864
75490
  "initializing",
73865
75491
  "Initializing AutomationStore and CronRunner"
@@ -73990,7 +75616,9 @@ ${detail}`
73990
75616
  }
73991
75617
  } catch {
73992
75618
  }
75619
+ this.notificationService?.stop();
73993
75620
  this.notifier?.stop();
75621
+ this.gridlockDetector?.stop();
73994
75622
  this.cronRunner?.stop();
73995
75623
  this.setAutomationSubsystemHealth("not-initialized", "Automation subsystem stopped");
73996
75624
  const tunnelManager = this.remoteTunnelManager;
@@ -75809,6 +77437,8 @@ __export(src_exports2, {
75809
77437
  MissionAutopilot: () => MissionAutopilot,
75810
77438
  MissionExecutionLoop: () => MissionExecutionLoop,
75811
77439
  NodeHealthMonitor: () => NodeHealthMonitor,
77440
+ NotificationService: () => NotificationService,
77441
+ NtfyNotificationProvider: () => NtfyNotificationProvider,
75812
77442
  NtfyNotifier: () => NtfyNotifier,
75813
77443
  PRIORITY_EXECUTE: () => PRIORITY_EXECUTE,
75814
77444
  PRIORITY_MERGE: () => PRIORITY_MERGE,
@@ -75833,6 +77463,7 @@ __export(src_exports2, {
75833
77463
  TriageProcessor: () => TriageProcessor,
75834
77464
  TunnelProcessManager: () => TunnelProcessManager,
75835
77465
  UsageLimitPauser: () => UsageLimitPauser,
77466
+ WebhookNotificationProvider: () => WebhookNotificationProvider,
75836
77467
  WorktreePool: () => WorktreePool,
75837
77468
  aiMergeTask: () => aiMergeTask,
75838
77469
  buildAgentChatPrompt: () => buildAgentChatPrompt,
@@ -75851,6 +77482,7 @@ __export(src_exports2, {
75851
77482
  createTaskLogTool: () => createTaskLogTool,
75852
77483
  describeAgentModel: () => describeAgentModel,
75853
77484
  describeModel: () => describeModel,
77485
+ formatTaskIdentifier: () => formatTaskIdentifier,
75854
77486
  getDefaultPiRuntime: () => getDefaultPiRuntime,
75855
77487
  getHostExtensionPaths: () => getHostExtensionPaths,
75856
77488
  getTunnelProviderAdapter: () => getTunnelProviderAdapter,
@@ -75902,6 +77534,7 @@ var init_src2 = __esm({
75902
77534
  init_pr_monitor();
75903
77535
  init_pr_comment_handler();
75904
77536
  init_notifier();
77537
+ init_notification();
75905
77538
  init_cron_runner();
75906
77539
  init_routine_runner();
75907
77540
  init_routine_scheduler();
@@ -76087,6 +77720,7 @@ async function ensureNtfyHelpersReady() {
76087
77720
  }
76088
77721
  try {
76089
77722
  const engine = await Promise.resolve().then(() => (init_src2(), src_exports2));
77723
+ const hasNotificationService = "NotificationService" in engine && typeof engine.NotificationService === "function";
76090
77724
  const hasAllHelpers = "isNtfyEventEnabled" in engine && "buildNtfyClickUrl" in engine && "sendNtfyNotification" in engine && typeof engine.isNtfyEventEnabled === "function" && typeof engine.buildNtfyClickUrl === "function" && typeof engine.sendNtfyNotification === "function";
76091
77725
  if (!hasAllHelpers) {
76092
77726
  return;
@@ -76096,6 +77730,12 @@ async function ensureNtfyHelpersReady() {
76096
77730
  buildNtfyClickUrl: engine.buildNtfyClickUrl,
76097
77731
  sendNtfyNotification: engine.sendNtfyNotification
76098
77732
  };
77733
+ if (hasNotificationService) {
77734
+ diagnostics.info(
77735
+ "NotificationService abstraction detected in engine",
77736
+ { operation: "notification-service-detection" }
77737
+ );
77738
+ }
76099
77739
  } catch {
76100
77740
  }
76101
77741
  }
@@ -76118,6 +77758,11 @@ function cleanupInMemorySession(sessionId) {
76118
77758
  if (!session) {
76119
77759
  return false;
76120
77760
  }
77761
+ const activeGeneration = activeGenerations.get(sessionId);
77762
+ if (activeGeneration) {
77763
+ clearTimeout(activeGeneration.timer);
77764
+ activeGenerations.delete(sessionId);
77765
+ }
76121
77766
  if (session.agent) {
76122
77767
  try {
76123
77768
  session.agent.session.dispose?.();
@@ -76451,109 +78096,157 @@ async function maybeNotifyPlanningAwaitingInput(session, question) {
76451
78096
  });
76452
78097
  }
76453
78098
  }
78099
+ function setSessionError(session, message) {
78100
+ session.error = message;
78101
+ session.updatedAt = /* @__PURE__ */ new Date();
78102
+ persistSession(session, "error", message);
78103
+ planningStreamManager.broadcast(session.id, {
78104
+ type: "error",
78105
+ data: message
78106
+ });
78107
+ }
78108
+ function createAbortError() {
78109
+ const error = new Error("Generation aborted");
78110
+ error.name = "AbortError";
78111
+ return error;
78112
+ }
78113
+ async function runGenerationWithTimeout(session, operation) {
78114
+ const existing = activeGenerations.get(session.id);
78115
+ if (existing) {
78116
+ clearTimeout(existing.timer);
78117
+ existing.abortController.abort();
78118
+ }
78119
+ const abortController = new AbortController();
78120
+ let timeoutTriggered = false;
78121
+ const timer = setTimeout(() => {
78122
+ timeoutTriggered = true;
78123
+ setSessionError(session, "AI generation timed out. You can retry or start a new session.");
78124
+ abortController.abort();
78125
+ }, GENERATION_TIMEOUT_MS);
78126
+ activeGenerations.set(session.id, { abortController, timer });
78127
+ const abortPromise = new Promise((_, reject) => {
78128
+ abortController.signal.addEventListener(
78129
+ "abort",
78130
+ () => reject(createAbortError()),
78131
+ { once: true }
78132
+ );
78133
+ });
78134
+ try {
78135
+ return await Promise.race([operation(), abortPromise]);
78136
+ } catch (error) {
78137
+ if (error instanceof Error && error.name === "AbortError") {
78138
+ if (!timeoutTriggered && !session.error) {
78139
+ setSessionError(session, "Generation stopped by user. You can retry or start a new session.");
78140
+ }
78141
+ }
78142
+ throw error;
78143
+ } finally {
78144
+ clearTimeout(timer);
78145
+ activeGenerations.delete(session.id);
78146
+ }
78147
+ }
76454
78148
  async function continueAgentConversation(session, message) {
76455
78149
  if (!session.agent) {
76456
78150
  throw new InvalidSessionStateError("AI agent not initialized");
76457
78151
  }
76458
78152
  try {
76459
- session.thinkingOutput = "";
76460
- await session.agent.session.prompt(message);
76461
- const lastMessage = session.agent.session.state.messages.filter((m) => m.role === "assistant").pop();
76462
- let responseText = session.thinkingOutput;
76463
- if (lastMessage?.content) {
76464
- if (typeof lastMessage.content === "string") {
76465
- responseText = lastMessage.content;
76466
- } else if (Array.isArray(lastMessage.content)) {
76467
- responseText = lastMessage.content.filter((c) => c.type === "text").map((c) => c.text).join("");
78153
+ await runGenerationWithTimeout(session, async () => {
78154
+ session.thinkingOutput = "";
78155
+ await session.agent.session.prompt(message);
78156
+ const lastMessage = session.agent.session.state.messages.filter((m) => m.role === "assistant").pop();
78157
+ let responseText = session.thinkingOutput;
78158
+ if (lastMessage?.content) {
78159
+ if (typeof lastMessage.content === "string") {
78160
+ responseText = lastMessage.content;
78161
+ } else if (Array.isArray(lastMessage.content)) {
78162
+ responseText = lastMessage.content.filter((c) => c.type === "text").map((c) => c.text).join("");
78163
+ }
76468
78164
  }
76469
- }
76470
- let parsed;
76471
- let lastError;
76472
- for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
76473
- try {
76474
- parsed = parseAgentResponse(responseText);
76475
- break;
76476
- } catch (err) {
76477
- lastError = err instanceof Error ? err : new Error(String(err));
76478
- if (attempt < MAX_PARSE_RETRIES) {
76479
- diagnostics.warn(
76480
- "Parse attempt failed, requesting reformat",
76481
- { sessionId: session.id, attempt: attempt + 1, operation: "parse-retry" }
76482
- );
76483
- try {
76484
- session.thinkingOutput = "";
76485
- await session.agent.session.prompt(
76486
- 'Your previous response could not be parsed as JSON. Please respond with ONLY a valid JSON object: either {"type":"question","data":{...}} or {"type":"complete","data":{...}}. No markdown, no explanation, just the JSON.'
78165
+ let parsed;
78166
+ let lastError;
78167
+ for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
78168
+ try {
78169
+ parsed = parseAgentResponse(responseText);
78170
+ break;
78171
+ } catch (err) {
78172
+ lastError = err instanceof Error ? err : new Error(String(err));
78173
+ if (attempt < MAX_PARSE_RETRIES) {
78174
+ diagnostics.warn(
78175
+ "Parse attempt failed, requesting reformat",
78176
+ { sessionId: session.id, attempt: attempt + 1, operation: "parse-retry" }
76487
78177
  );
76488
- const retryMessage = session.agent.session.state.messages.filter((m) => m.role === "assistant").pop();
76489
- let retryText = session.thinkingOutput;
76490
- if (retryMessage?.content) {
76491
- if (typeof retryMessage.content === "string") {
76492
- retryText = retryMessage.content;
76493
- } else if (Array.isArray(retryMessage.content)) {
76494
- retryText = retryMessage.content.filter((c) => c.type === "text").map((c) => c.text).join("");
78178
+ try {
78179
+ session.thinkingOutput = "";
78180
+ await session.agent.session.prompt(
78181
+ 'Your previous response could not be parsed as JSON. Please respond with ONLY a valid JSON object: either {"type":"question","data":{...}} or {"type":"complete","data":{...}}. No markdown, no explanation, just the JSON.'
78182
+ );
78183
+ const retryMessage = session.agent.session.state.messages.filter((m) => m.role === "assistant").pop();
78184
+ let retryText = session.thinkingOutput;
78185
+ if (retryMessage?.content) {
78186
+ if (typeof retryMessage.content === "string") {
78187
+ retryText = retryMessage.content;
78188
+ } else if (Array.isArray(retryMessage.content)) {
78189
+ retryText = retryMessage.content.filter((c) => c.type === "text").map((c) => c.text).join("");
78190
+ }
76495
78191
  }
78192
+ responseText = retryText;
78193
+ } catch (retryErr) {
78194
+ diagnostics.errorFromException(
78195
+ "Retry prompt failed for session",
78196
+ retryErr,
78197
+ { sessionId: session.id, operation: "retry-prompt" }
78198
+ );
78199
+ break;
76496
78200
  }
76497
- responseText = retryText;
76498
- } catch (retryErr) {
76499
- diagnostics.errorFromException(
76500
- "Retry prompt failed for session",
76501
- retryErr,
76502
- { sessionId: session.id, operation: "retry-prompt" }
76503
- );
76504
- break;
76505
78201
  }
76506
78202
  }
76507
78203
  }
76508
- }
76509
- if (!parsed) {
76510
- const errorMsg = `${lastError?.message || "Failed to parse AI response"} You can try responding again or start a new planning session.`;
76511
- diagnostics.error(
76512
- "All parse attempts exhausted for session",
76513
- { sessionId: session.id, message: errorMsg, operation: "parse-exhausted" }
76514
- );
76515
- session.error = errorMsg;
76516
- session.updatedAt = /* @__PURE__ */ new Date();
76517
- persistSession(session, "error", errorMsg);
76518
- planningStreamManager.broadcast(session.id, {
76519
- type: "error",
76520
- data: errorMsg
76521
- });
78204
+ if (!parsed) {
78205
+ const errorMsg = `${lastError?.message || "Failed to parse AI response"} You can try responding again or start a new planning session.`;
78206
+ diagnostics.error(
78207
+ "All parse attempts exhausted for session",
78208
+ { sessionId: session.id, message: errorMsg, operation: "parse-exhausted" }
78209
+ );
78210
+ session.error = errorMsg;
78211
+ session.updatedAt = /* @__PURE__ */ new Date();
78212
+ persistSession(session, "error", errorMsg);
78213
+ planningStreamManager.broadcast(session.id, {
78214
+ type: "error",
78215
+ data: errorMsg
78216
+ });
78217
+ return;
78218
+ }
78219
+ if (parsed.type === "question") {
78220
+ session.currentQuestion = parsed.data;
78221
+ session.error = void 0;
78222
+ session.lastGeneratedThinking = session.thinkingOutput;
78223
+ session.updatedAt = /* @__PURE__ */ new Date();
78224
+ persistSession(session, "awaiting_input");
78225
+ void maybeNotifyPlanningAwaitingInput(session, parsed.data);
78226
+ planningStreamManager.broadcast(session.id, {
78227
+ type: "question",
78228
+ data: parsed.data
78229
+ });
78230
+ } else if (parsed.type === "complete") {
78231
+ session.summary = parsed.data;
78232
+ session.currentQuestion = void 0;
78233
+ session.error = void 0;
78234
+ session.updatedAt = /* @__PURE__ */ new Date();
78235
+ persistSession(session, "complete");
78236
+ planningStreamManager.broadcast(session.id, {
78237
+ type: "summary",
78238
+ data: parsed.data
78239
+ });
78240
+ planningStreamManager.broadcast(session.id, { type: "complete" });
78241
+ }
78242
+ });
78243
+ } catch (err) {
78244
+ if (err instanceof Error && err.name === "AbortError") {
76522
78245
  return;
76523
78246
  }
76524
- if (parsed.type === "question") {
76525
- session.currentQuestion = parsed.data;
76526
- session.error = void 0;
76527
- session.lastGeneratedThinking = session.thinkingOutput;
76528
- session.updatedAt = /* @__PURE__ */ new Date();
76529
- persistSession(session, "awaiting_input");
76530
- void maybeNotifyPlanningAwaitingInput(session, parsed.data);
76531
- planningStreamManager.broadcast(session.id, {
76532
- type: "question",
76533
- data: parsed.data
76534
- });
76535
- } else if (parsed.type === "complete") {
76536
- session.summary = parsed.data;
76537
- session.currentQuestion = void 0;
76538
- session.error = void 0;
76539
- session.updatedAt = /* @__PURE__ */ new Date();
76540
- persistSession(session, "complete");
76541
- planningStreamManager.broadcast(session.id, {
76542
- type: "summary",
76543
- data: parsed.data
76544
- });
76545
- planningStreamManager.broadcast(session.id, { type: "complete" });
76546
- }
76547
- } catch (err) {
76548
78247
  const errorMessage = err instanceof Error ? err.message : "AI processing failed";
76549
78248
  diagnostics.errorFromException("Agent conversation error for session", err, { sessionId: session.id, operation: "conversation" });
76550
- session.error = errorMessage;
76551
- session.updatedAt = /* @__PURE__ */ new Date();
76552
- persistSession(session, "error", errorMessage);
76553
- planningStreamManager.broadcast(session.id, {
76554
- type: "error",
76555
- data: errorMessage
76556
- });
78249
+ setSessionError(session, errorMessage);
76557
78250
  }
76558
78251
  }
76559
78252
  function extractJsonCandidate(text) {
@@ -76825,7 +78518,7 @@ function getSession(sessionId) {
76825
78518
  return void 0;
76826
78519
  }
76827
78520
  }
76828
- var createFnAgent4, planningNtfyHelpers, diagnostics, PLANNING_SYSTEM_PROMPT, SESSION_TTL_MS, CLEANUP_INTERVAL_MS2, MAX_SESSIONS_PER_IP_PER_HOUR, RATE_LIMIT_WINDOW_MS2, sessions, rateLimits2, _aiSessionStore, cleanupInterval2, PlanningStreamManager, planningStreamManager, MAX_PARSE_RETRIES, RateLimitError2, SessionNotFoundError, InvalidSessionStateError;
78521
+ var createFnAgent4, planningNtfyHelpers, diagnostics, PLANNING_SYSTEM_PROMPT, SESSION_TTL_MS, CLEANUP_INTERVAL_MS2, MAX_SESSIONS_PER_IP_PER_HOUR, RATE_LIMIT_WINDOW_MS2, GENERATION_TIMEOUT_MS, sessions, rateLimits2, activeGenerations, _aiSessionStore, cleanupInterval2, PlanningStreamManager, planningStreamManager, MAX_PARSE_RETRIES, RateLimitError2, SessionNotFoundError, InvalidSessionStateError;
76829
78522
  var init_planning = __esm({
76830
78523
  "../dashboard/src/planning.ts"() {
76831
78524
  "use strict";
@@ -76896,8 +78589,10 @@ For completion:
76896
78589
  CLEANUP_INTERVAL_MS2 = 5 * 60 * 1e3;
76897
78590
  MAX_SESSIONS_PER_IP_PER_HOUR = 5;
76898
78591
  RATE_LIMIT_WINDOW_MS2 = 60 * 60 * 1e3;
78592
+ GENERATION_TIMEOUT_MS = 12e4;
76899
78593
  sessions = /* @__PURE__ */ new Map();
76900
78594
  rateLimits2 = /* @__PURE__ */ new Map();
78595
+ activeGenerations = /* @__PURE__ */ new Map();
76901
78596
  cleanupInterval2 = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS2);
76902
78597
  cleanupInterval2.unref?.();
76903
78598
  process.on("beforeExit", () => {
@@ -77035,6 +78730,108 @@ var init_ai_session_store = __esm({
77035
78730
  }
77036
78731
  });
77037
78732
 
78733
+ // ../dashboard/src/ai-session-timeout.ts
78734
+ function createAbortError2() {
78735
+ const error = new Error("Generation aborted");
78736
+ error.name = "AbortError";
78737
+ return error;
78738
+ }
78739
+ function isAbortError(err) {
78740
+ return err instanceof Error && err.name === "AbortError";
78741
+ }
78742
+ var GenerationGuard;
78743
+ var init_ai_session_timeout = __esm({
78744
+ "../dashboard/src/ai-session-timeout.ts"() {
78745
+ "use strict";
78746
+ GenerationGuard = class {
78747
+ active = /* @__PURE__ */ new Map();
78748
+ /**
78749
+ * Tracks the cause of a pending abort so the original `run()` can
78750
+ * distinguish a user-initiated stop from re-entrant displacement. The flag
78751
+ * is consumed (deleted) by the catch block of the displaced `run()`.
78752
+ */
78753
+ abortCause = /* @__PURE__ */ new Map();
78754
+ /**
78755
+ * Wrap `op` with a timeout + abort. If a previous generation is still
78756
+ * registered for the same id, it is aborted first (the prior `run()`
78757
+ * rejects with `AbortError`, marked as `displaced` so its `onUserStop`
78758
+ * handler does NOT fire).
78759
+ */
78760
+ async run(sessionId, timeoutMs, handlers, op) {
78761
+ this.cancelInternal(sessionId, "displaced");
78762
+ const abort = new AbortController();
78763
+ const timer = setTimeout(() => {
78764
+ this.abortCause.set(abort, "timeout");
78765
+ try {
78766
+ handlers.onTimeout();
78767
+ } catch {
78768
+ }
78769
+ abort.abort();
78770
+ }, timeoutMs);
78771
+ const entry = { abort, timer };
78772
+ this.active.set(sessionId, entry);
78773
+ const abortPromise = new Promise((_, reject) => {
78774
+ abort.signal.addEventListener(
78775
+ "abort",
78776
+ () => reject(createAbortError2()),
78777
+ { once: true }
78778
+ );
78779
+ });
78780
+ try {
78781
+ return await Promise.race([op(), abortPromise]);
78782
+ } catch (err) {
78783
+ if (isAbortError(err)) {
78784
+ const cause = this.abortCause.get(abort) ?? "user-stop";
78785
+ if (cause === "user-stop") {
78786
+ try {
78787
+ handlers.onUserStop?.();
78788
+ } catch {
78789
+ }
78790
+ }
78791
+ }
78792
+ throw err;
78793
+ } finally {
78794
+ clearTimeout(timer);
78795
+ this.abortCause.delete(abort);
78796
+ if (this.active.get(sessionId) === entry) {
78797
+ this.active.delete(sessionId);
78798
+ }
78799
+ }
78800
+ }
78801
+ /** AbortSignal of the in-flight generation, if any — for tools that honor it. */
78802
+ signal(sessionId) {
78803
+ return this.active.get(sessionId)?.abort.signal;
78804
+ }
78805
+ has(sessionId) {
78806
+ return this.active.has(sessionId);
78807
+ }
78808
+ /**
78809
+ * Manually abort the active generation. Returns true if there was one.
78810
+ * The wrapped operation will reject with `AbortError`, and the `onUserStop`
78811
+ * handler from the original `run()` call will fire.
78812
+ */
78813
+ stop(sessionId) {
78814
+ return this.cancelInternal(sessionId, "user-stop");
78815
+ }
78816
+ /** Reset all in-flight generations (test/shutdown only). */
78817
+ reset() {
78818
+ for (const sessionId of [...this.active.keys()]) {
78819
+ this.cancelInternal(sessionId, "user-stop");
78820
+ }
78821
+ }
78822
+ cancelInternal(sessionId, cause) {
78823
+ const entry = this.active.get(sessionId);
78824
+ if (!entry) return false;
78825
+ clearTimeout(entry.timer);
78826
+ this.abortCause.set(entry.abort, cause);
78827
+ entry.abort.abort();
78828
+ this.active.delete(sessionId);
78829
+ return true;
78830
+ }
78831
+ };
78832
+ }
78833
+ });
78834
+
77038
78835
  // ../dashboard/src/subtask-breakdown.ts
77039
78836
  import { EventEmitter as EventEmitter24 } from "node:events";
77040
78837
  function cleanupInMemorySubtaskSession(sessionId) {
@@ -77042,6 +78839,7 @@ function cleanupInMemorySubtaskSession(sessionId) {
77042
78839
  if (!session) {
77043
78840
  return false;
77044
78841
  }
78842
+ generationGuard.stop(sessionId);
77045
78843
  try {
77046
78844
  session.agent?.session?.dispose?.();
77047
78845
  } catch {
@@ -77058,17 +78856,19 @@ function cleanupExpiredSessions2() {
77058
78856
  }
77059
78857
  }
77060
78858
  }
77061
- var diagnostics3, SESSION_TTL_MS2, CLEANUP_INTERVAL_MS3, sessions2, cleanupInterval3, SubtaskStreamManager, subtaskStreamManager;
78859
+ var diagnostics3, SESSION_TTL_MS2, CLEANUP_INTERVAL_MS3, generationGuard, sessions2, cleanupInterval3, SubtaskStreamManager, subtaskStreamManager;
77062
78860
  var init_subtask_breakdown = __esm({
77063
78861
  "../dashboard/src/subtask-breakdown.ts"() {
77064
78862
  "use strict";
77065
78863
  init_src();
77066
78864
  init_sse_buffer();
77067
78865
  init_ai_session_diagnostics();
78866
+ init_ai_session_timeout();
77068
78867
  init_src2();
77069
78868
  diagnostics3 = createSessionDiagnostics("subtask-breakdown");
77070
78869
  SESSION_TTL_MS2 = 7 * 24 * 60 * 60 * 1e3;
77071
78870
  CLEANUP_INTERVAL_MS3 = 5 * 60 * 1e3;
78871
+ generationGuard = new GenerationGuard();
77072
78872
  sessions2 = /* @__PURE__ */ new Map();
77073
78873
  cleanupInterval3 = setInterval(cleanupExpiredSessions2, CLEANUP_INTERVAL_MS3);
77074
78874
  cleanupInterval3.unref?.();
@@ -77143,6 +78943,7 @@ function cleanupInMemoryMissionSession(sessionId) {
77143
78943
  if (!session) {
77144
78944
  return false;
77145
78945
  }
78946
+ generationGuard2.stop(sessionId);
77146
78947
  if (session.agent) {
77147
78948
  try {
77148
78949
  session.agent.session.dispose?.();
@@ -77167,18 +78968,20 @@ function cleanupExpiredSessions3() {
77167
78968
  }
77168
78969
  }
77169
78970
  }
77170
- var diagnostics4, SESSION_TTL_MS3, CLEANUP_INTERVAL_MS4, RATE_LIMIT_WINDOW_MS3, sessions3, rateLimits3, cleanupInterval4, MissionInterviewStreamManager, missionInterviewStreamManager;
78971
+ var diagnostics4, SESSION_TTL_MS3, CLEANUP_INTERVAL_MS4, RATE_LIMIT_WINDOW_MS3, generationGuard2, sessions3, rateLimits3, cleanupInterval4, MissionInterviewStreamManager, missionInterviewStreamManager;
77171
78972
  var init_mission_interview = __esm({
77172
78973
  "../dashboard/src/mission-interview.ts"() {
77173
78974
  "use strict";
77174
78975
  init_src();
77175
78976
  init_sse_buffer();
77176
78977
  init_ai_session_diagnostics();
78978
+ init_ai_session_timeout();
77177
78979
  init_src2();
77178
78980
  diagnostics4 = createSessionDiagnostics("mission-interview");
77179
78981
  SESSION_TTL_MS3 = 7 * 24 * 60 * 60 * 1e3;
77180
78982
  CLEANUP_INTERVAL_MS4 = 5 * 60 * 1e3;
77181
78983
  RATE_LIMIT_WINDOW_MS3 = 60 * 60 * 1e3;
78984
+ generationGuard2 = new GenerationGuard();
77182
78985
  sessions3 = /* @__PURE__ */ new Map();
77183
78986
  rateLimits3 = /* @__PURE__ */ new Map();
77184
78987
  cleanupInterval4 = setInterval(cleanupExpiredSessions3, CLEANUP_INTERVAL_MS4);
@@ -77258,6 +79061,7 @@ function cleanupInMemorySession2(sessionId) {
77258
79061
  if (!session) {
77259
79062
  return false;
77260
79063
  }
79064
+ generationGuard3.stop(sessionId);
77261
79065
  if (session.agent) {
77262
79066
  try {
77263
79067
  session.agent.session.dispose?.();
@@ -77282,19 +79086,21 @@ function cleanupExpiredSessions4() {
77282
79086
  }
77283
79087
  }
77284
79088
  }
77285
- var diagnostics5, SESSION_TTL_MS4, CLEANUP_INTERVAL_MS5, RATE_LIMIT_WINDOW_MS4, sessions4, rateLimits4, cleanupInterval5, MilestoneSliceInterviewStreamManager, milestoneSliceInterviewStreamManager;
79089
+ var diagnostics5, SESSION_TTL_MS4, CLEANUP_INTERVAL_MS5, RATE_LIMIT_WINDOW_MS4, generationGuard3, sessions4, rateLimits4, cleanupInterval5, MilestoneSliceInterviewStreamManager, milestoneSliceInterviewStreamManager;
77286
79090
  var init_milestone_slice_interview = __esm({
77287
79091
  "../dashboard/src/milestone-slice-interview.ts"() {
77288
79092
  "use strict";
77289
79093
  init_sse_buffer();
77290
79094
  init_mission_interview();
77291
79095
  init_ai_session_diagnostics();
79096
+ init_ai_session_timeout();
77292
79097
  init_mission_interview();
77293
79098
  init_src2();
77294
79099
  diagnostics5 = createSessionDiagnostics("milestone-slice-interview");
77295
79100
  SESSION_TTL_MS4 = 7 * 24 * 60 * 60 * 1e3;
77296
79101
  CLEANUP_INTERVAL_MS5 = 5 * 60 * 1e3;
77297
79102
  RATE_LIMIT_WINDOW_MS4 = 60 * 60 * 1e3;
79103
+ generationGuard3 = new GenerationGuard();
77298
79104
  sessions4 = /* @__PURE__ */ new Map();
77299
79105
  rateLimits4 = /* @__PURE__ */ new Map();
77300
79106
  cleanupInterval5 = setInterval(cleanupExpiredSessions4, CLEANUP_INTERVAL_MS5);
@@ -78139,6 +79945,7 @@ var init_register_task_workflow_routes = __esm({
78139
79945
  var init_register_planning_subtask_routes = __esm({
78140
79946
  "../dashboard/src/routes/register-planning-subtask-routes.ts"() {
78141
79947
  "use strict";
79948
+ init_src();
78142
79949
  init_api_error();
78143
79950
  init_sse_buffer();
78144
79951
  }
@@ -78153,12 +79960,14 @@ var init_rate_limit = __esm({
78153
79960
  });
78154
79961
 
78155
79962
  // ../dashboard/src/routes/register-chat-routes.ts
79963
+ var CHAT_MAX_ATTACHMENT_SIZE;
78156
79964
  var init_register_chat_routes = __esm({
78157
79965
  "../dashboard/src/routes/register-chat-routes.ts"() {
78158
79966
  "use strict";
78159
79967
  init_api_error();
78160
79968
  init_rate_limit();
78161
79969
  init_sse_buffer();
79970
+ CHAT_MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024;
78162
79971
  }
78163
79972
  });
78164
79973
 
@@ -80381,16 +82190,26 @@ var init_register_agents_projects_nodes = __esm({
80381
82190
  }
80382
82191
  });
80383
82192
 
80384
- // ../dashboard/src/routes/register-project-routes.ts
82193
+ // ../dashboard/src/exec-file.ts
80385
82194
  import { execFile as execFile5 } from "node:child_process";
80386
- import * as fsPromises from "node:fs/promises";
80387
82195
  import { promisify as promisify12 } from "node:util";
80388
- var access3, stat6, mkdir12, readdir8, rm2, execFileAsync3;
82196
+ var execFileAsync3;
82197
+ var init_exec_file = __esm({
82198
+ "../dashboard/src/exec-file.ts"() {
82199
+ "use strict";
82200
+ execFileAsync3 = promisify12(execFile5);
82201
+ }
82202
+ });
82203
+
82204
+ // ../dashboard/src/routes/register-project-routes.ts
82205
+ import * as fsPromises from "node:fs/promises";
82206
+ var access3, stat6, mkdir12, readdir8, rm2;
80389
82207
  var init_register_project_routes = __esm({
80390
82208
  "../dashboard/src/routes/register-project-routes.ts"() {
80391
82209
  "use strict";
80392
82210
  init_src();
80393
82211
  init_api_error();
82212
+ init_exec_file();
80394
82213
  init_project_store_resolver();
80395
82214
  ({
80396
82215
  access: access3,
@@ -80399,7 +82218,6 @@ var init_register_project_routes = __esm({
80399
82218
  readdir: readdir8,
80400
82219
  rm: rm2
80401
82220
  } = fsPromises);
80402
- execFileAsync3 = promisify12(execFile5);
80403
82221
  }
80404
82222
  });
80405
82223
 
@@ -85738,12 +87556,29 @@ var init_project_context = __esm({
85738
87556
  }
85739
87557
  });
85740
87558
 
87559
+ // src/commands/node.ts
87560
+ import { createInterface } from "node:readline/promises";
87561
+ async function findNodeByNameOrId(central, nameOrId) {
87562
+ const byId = await central.getNode(nameOrId);
87563
+ if (byId) {
87564
+ return byId;
87565
+ }
87566
+ return central.getNodeByName(nameOrId);
87567
+ }
87568
+ var init_node = __esm({
87569
+ "src/commands/node.ts"() {
87570
+ "use strict";
87571
+ init_src();
87572
+ }
87573
+ });
87574
+
85741
87575
  // src/commands/task.ts
85742
87576
  var task_exports = {};
85743
87577
  __export(task_exports, {
85744
87578
  fetchGitHubIssues: () => fetchGitHubIssues,
85745
87579
  runTaskArchive: () => runTaskArchive,
85746
87580
  runTaskAttach: () => runTaskAttach,
87581
+ runTaskClearNode: () => runTaskClearNode,
85747
87582
  runTaskComment: () => runTaskComment,
85748
87583
  runTaskComments: () => runTaskComments,
85749
87584
  runTaskCreate: () => runTaskCreate,
@@ -85761,13 +87596,14 @@ __export(task_exports, {
85761
87596
  runTaskPrCreate: () => runTaskPrCreate,
85762
87597
  runTaskRefine: () => runTaskRefine,
85763
87598
  runTaskRetry: () => runTaskRetry,
87599
+ runTaskSetNode: () => runTaskSetNode,
85764
87600
  runTaskShow: () => runTaskShow,
85765
87601
  runTaskSteer: () => runTaskSteer,
85766
87602
  runTaskUnarchive: () => runTaskUnarchive,
85767
87603
  runTaskUnpause: () => runTaskUnpause,
85768
87604
  runTaskUpdate: () => runTaskUpdate
85769
87605
  });
85770
- import { createInterface } from "node:readline/promises";
87606
+ import { createInterface as createInterface2 } from "node:readline/promises";
85771
87607
  import { watchFile, unwatchFile, statSync as statSync6, existsSync as existsSync30, readFileSync as readFileSync9 } from "node:fs";
85772
87608
  import { join as join37 } from "node:path";
85773
87609
  function asLocalProjectContext(store) {
@@ -85832,11 +87668,28 @@ async function getProjectContext(projectName) {
85832
87668
  async function getProjectPath(projectName) {
85833
87669
  return (await getCommandContext(projectName)).projectPath;
85834
87670
  }
85835
- async function runTaskCreate(descriptionArg, attachFiles, depends, projectName) {
87671
+ async function resolveNodeByNameOrId(nodeNameOrId) {
87672
+ const central = new CentralCore();
87673
+ await central.init();
87674
+ try {
87675
+ const looksLikeNodeId = nodeNameOrId.includes("-") && nodeNameOrId.length > 20;
87676
+ let node = looksLikeNodeId ? await central.getNode(nodeNameOrId) : await central.getNodeByName(nodeNameOrId);
87677
+ if (!node) {
87678
+ node = await findNodeByNameOrId(central, nodeNameOrId);
87679
+ }
87680
+ if (!node) {
87681
+ throw new Error(`Node not found: ${nodeNameOrId}`);
87682
+ }
87683
+ return { id: node.id, name: node.name };
87684
+ } finally {
87685
+ await central.close();
87686
+ }
87687
+ }
87688
+ async function runTaskCreate(descriptionArg, attachFiles, depends, projectName, nodeName) {
85836
87689
  let description = descriptionArg;
85837
87690
  const projectContext = await getProjectContext(projectName);
85838
87691
  if (!description) {
85839
- const rl = createInterface({ input: process.stdin, output: process.stdout });
87692
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
85840
87693
  description = await rl.question("Task description: ");
85841
87694
  rl.close();
85842
87695
  }
@@ -85846,6 +87699,16 @@ async function runTaskCreate(descriptionArg, attachFiles, depends, projectName)
85846
87699
  }
85847
87700
  const store = projectContext?.store ?? await getStore(projectName);
85848
87701
  const task = await store.createTask({ description: description.trim(), dependencies: depends });
87702
+ let resolvedNode;
87703
+ if (nodeName) {
87704
+ try {
87705
+ resolvedNode = await resolveNodeByNameOrId(nodeName);
87706
+ await store.updateTask(task.id, { nodeId: resolvedNode.id });
87707
+ } catch (error) {
87708
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
87709
+ process.exit(1);
87710
+ }
87711
+ }
85849
87712
  const label = task.description.length > 60 ? task.description.slice(0, 60) + "\u2026" : task.description;
85850
87713
  console.log();
85851
87714
  if (projectContext) {
@@ -85856,6 +87719,9 @@ async function runTaskCreate(descriptionArg, attachFiles, depends, projectName)
85856
87719
  if (task.dependencies.length > 0) {
85857
87720
  console.log(` Dependencies: ${task.dependencies.join(", ")}`);
85858
87721
  }
87722
+ if (resolvedNode) {
87723
+ console.log(` Node: ${resolvedNode.name || resolvedNode.id}`);
87724
+ }
85859
87725
  console.log(` Path: .fusion/tasks/${task.id}/`);
85860
87726
  if (attachFiles && attachFiles.length > 0) {
85861
87727
  const { readFile: readFile19 } = await import("node:fs/promises");
@@ -86047,15 +87913,62 @@ async function runTaskLogs(id, options = {}, projectName) {
86047
87913
  });
86048
87914
  }
86049
87915
  }
87916
+ async function runTaskSetNode(id, nodeNameOrId, projectName) {
87917
+ const store = await getStore(projectName);
87918
+ const task = await store.getTask(id);
87919
+ if (task.column === "in-progress") {
87920
+ console.error(`Cannot change node override: task ${id} is in progress`);
87921
+ process.exit(1);
87922
+ }
87923
+ let resolvedNode;
87924
+ try {
87925
+ resolvedNode = await resolveNodeByNameOrId(nodeNameOrId);
87926
+ } catch (error) {
87927
+ console.error(error instanceof Error ? error.message : String(error));
87928
+ process.exit(1);
87929
+ return;
87930
+ }
87931
+ await store.updateTask(id, { nodeId: resolvedNode.id });
87932
+ console.log(`\u2713 Set node override for ${id}: ${resolvedNode.name || resolvedNode.id}`);
87933
+ }
87934
+ async function runTaskClearNode(id, projectName) {
87935
+ const store = await getStore(projectName);
87936
+ const task = await store.getTask(id);
87937
+ if (task.column === "in-progress") {
87938
+ console.error(`Cannot change node override: task ${id} is in progress`);
87939
+ process.exit(1);
87940
+ }
87941
+ await store.updateTask(id, { nodeId: null });
87942
+ console.log(`\u2713 Cleared node override for ${id}`);
87943
+ }
86050
87944
  async function runTaskShow(id, projectName) {
86051
87945
  const store = await getStore(projectName);
86052
87946
  const task = await store.getTask(id);
87947
+ const settings = "getSettings" in store ? await store.getSettings() : {};
87948
+ let nodeSummary = "(default local)";
87949
+ if (task.nodeId) {
87950
+ let nodeName;
87951
+ const central = new CentralCore();
87952
+ await central.init();
87953
+ try {
87954
+ nodeName = (await central.getNode(task.nodeId))?.name;
87955
+ } finally {
87956
+ await central.close();
87957
+ }
87958
+ nodeSummary = nodeName ? `${nodeName} (${task.nodeId})` : task.nodeId;
87959
+ } else if (settings.defaultNodeId) {
87960
+ nodeSummary = `project default: ${settings.defaultNodeId}`;
87961
+ }
86053
87962
  console.log();
86054
87963
  console.log(` ${task.id}: ${task.title || task.description}`);
86055
87964
  console.log(` Column: ${COLUMN_LABELS[task.column]}${task.size ? ` \xB7 Size: ${task.size}` : ""}${task.reviewLevel !== void 0 ? ` \xB7 Review: ${task.reviewLevel}` : ""}`);
86056
87965
  if (task.dependencies.length) {
86057
87966
  console.log(` Dependencies: ${task.dependencies.join(", ")}`);
86058
87967
  }
87968
+ console.log(` Node: ${nodeSummary}`);
87969
+ if (settings.unavailableNodePolicy) {
87970
+ console.log(` Unavailable Node Policy: ${settings.unavailableNodePolicy}`);
87971
+ }
86059
87972
  console.log();
86060
87973
  if (task.steps.length > 0) {
86061
87974
  console.log(` Steps (${task.steps.filter((s) => s.status === "done").length}/${task.steps.length}):`);
@@ -86172,7 +88085,7 @@ async function runTaskRefine(id, feedbackArg, projectName) {
86172
88085
  const store = await getStore(projectName);
86173
88086
  let feedback = feedbackArg;
86174
88087
  if (feedback === void 0) {
86175
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88088
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86176
88089
  feedback = await rl.question("What needs to be refined? ");
86177
88090
  rl.close();
86178
88091
  }
@@ -86243,7 +88156,7 @@ async function runTaskDelete(id, force, projectName) {
86243
88156
  return;
86244
88157
  }
86245
88158
  if (!force) {
86246
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88159
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86247
88160
  const answer = await rl.question(`Are you sure you want to delete ${id}? [y/N] `);
86248
88161
  rl.close();
86249
88162
  const trimmed = answer.trim().toLowerCase();
@@ -86307,7 +88220,7 @@ async function runTaskImportGitHubInteractive(ownerRepo, options = {}, projectNa
86307
88220
  console.log(` ${i + 1}. #${issue.number} ${issue.title.slice(0, 80)}${issue.title.length > 80 ? "\u2026" : ""}${status}`);
86308
88221
  }
86309
88222
  console.log();
86310
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88223
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86311
88224
  let selectedIndices = [];
86312
88225
  let validInput = false;
86313
88226
  while (!validInput) {
@@ -86464,7 +88377,7 @@ async function runTaskComment(id, message, author = "user", projectName) {
86464
88377
  const store = await getStore(projectName);
86465
88378
  let text = message;
86466
88379
  if (text === void 0) {
86467
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88380
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86468
88381
  text = await rl.question("Comment: ");
86469
88382
  rl.close();
86470
88383
  }
@@ -86507,7 +88420,7 @@ async function runTaskSteer(id, message, projectName) {
86507
88420
  const store = await getStore(projectName);
86508
88421
  let text = message;
86509
88422
  if (text === void 0) {
86510
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88423
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86511
88424
  text = await rl.question("Message: ");
86512
88425
  rl.close();
86513
88426
  }
@@ -86638,7 +88551,7 @@ async function promptText(question) {
86638
88551
  console.log(` ${question.description}`);
86639
88552
  }
86640
88553
  console.log(" (Enter your response. Type DONE on its own line when finished):\n");
86641
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88554
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86642
88555
  const lines = [];
86643
88556
  return new Promise((resolve17) => {
86644
88557
  const askLine = () => {
@@ -86672,7 +88585,7 @@ async function promptSingleSelect(question) {
86672
88585
  console.log(` ${opt.description}`);
86673
88586
  }
86674
88587
  }
86675
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88588
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86676
88589
  while (true) {
86677
88590
  const answer = await rl.question("\n Select (1-" + question.options.length + "): ");
86678
88591
  const num = parseInt(answer.trim(), 10);
@@ -86700,7 +88613,7 @@ async function promptMultiSelect(question) {
86700
88613
  console.log(` ${opt.description}`);
86701
88614
  }
86702
88615
  }
86703
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88616
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86704
88617
  while (true) {
86705
88618
  const answer = await rl.question("\n Select (comma-separated): ");
86706
88619
  const nums = answer.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
@@ -86723,7 +88636,7 @@ async function promptConfirm(question) {
86723
88636
  if (question.description) {
86724
88637
  console.log(` ${question.description}`);
86725
88638
  }
86726
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88639
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86727
88640
  const answer = await rl.question("\n [Y/n]: ");
86728
88641
  rl.close();
86729
88642
  const trimmed = answer.trim().toLowerCase();
@@ -86788,7 +88701,7 @@ function wrapText(text, width) {
86788
88701
  async function runTaskPlan(initialPlanArg, yesFlag = false, projectName) {
86789
88702
  let initialPlan = initialPlanArg;
86790
88703
  if (!initialPlan) {
86791
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88704
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86792
88705
  console.log("\n Let's plan your task. What would you like to accomplish?\n");
86793
88706
  initialPlan = await rl.question(" Describe your idea: ");
86794
88707
  rl.close();
@@ -86889,7 +88802,7 @@ async function runTaskPlan(initialPlanArg, yesFlag = false, projectName) {
86889
88802
  displaySummary(result.data);
86890
88803
  let confirmed = yesFlag;
86891
88804
  if (!yesFlag) {
86892
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88805
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86893
88806
  const answer = await rl.question(" Create this task? [Y/n]: ");
86894
88807
  rl.close();
86895
88808
  const trimmed = answer.trim().toLowerCase();
@@ -86931,6 +88844,7 @@ var init_task = __esm({
86931
88844
  init_src4();
86932
88845
  init_gh_cli();
86933
88846
  init_project_context();
88847
+ init_node();
86934
88848
  STEP_STATUSES2 = ["pending", "in-progress", "done", "skipped"];
86935
88849
  ANSI = {
86936
88850
  reset: "\x1B[0m",
@@ -87143,17 +89057,17 @@ async function fetchGitHubIssuesViaGh(owner, repo, options = {}) {
87143
89057
  }
87144
89058
  const path2 = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${queryParams.toString()}`;
87145
89059
  try {
87146
- const issues = await runGhJsonAsync(["api", path2]);
89060
+ const issues = await runGhJsonAsync(["api", path2], { signal: options.signal });
87147
89061
  return issues.filter((issue) => !issue.pull_request);
87148
89062
  } catch (error) {
87149
89063
  throw new Error(getGhErrorMessage(error));
87150
89064
  }
87151
89065
  }
87152
- async function fetchGitHubIssueViaGh(owner, repo, issueNumber) {
89066
+ async function fetchGitHubIssueViaGh(owner, repo, issueNumber, options = {}) {
87153
89067
  ensureGhCliAuth();
87154
89068
  const path2 = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`;
87155
89069
  try {
87156
- return await runGhJsonAsync(["api", path2]);
89070
+ return await runGhJsonAsync(["api", path2], { signal: options.signal });
87157
89071
  } catch (error) {
87158
89072
  throw new Error(getGhErrorMessage(error));
87159
89073
  }
@@ -87234,12 +89148,18 @@ Column: triage
87234
89148
  ], {
87235
89149
  description: "Agent ID to assign this task to, or null to clear (e.g. 'agent-abc123')"
87236
89150
  })
89151
+ ),
89152
+ nodeId: Type7.Optional(
89153
+ Type7.Union([Type7.String(), Type7.Null()], {
89154
+ description: "Node ID override for this task, or null to clear"
89155
+ })
87237
89156
  )
87238
89157
  }),
87239
89158
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
87240
89159
  const store = await getStore2(ctx.cwd);
89160
+ let task;
87241
89161
  try {
87242
- await store.getTask(params.id);
89162
+ task = await store.getTask(params.id);
87243
89163
  } catch {
87244
89164
  return {
87245
89165
  content: [{ type: "text", text: `Task ${params.id} not found` }],
@@ -87265,9 +89185,21 @@ Column: triage
87265
89185
  updates.assignedAgentId = params.agentId;
87266
89186
  updatedFields.push("agentId");
87267
89187
  }
89188
+ if (params.nodeId !== void 0) {
89189
+ const validation = validateNodeOverrideChange(task, params.nodeId ?? null);
89190
+ if (!validation.allowed) {
89191
+ return {
89192
+ content: [{ type: "text", text: validation.message ?? "Node override change blocked" }],
89193
+ isError: true,
89194
+ details: { error: validation.reason }
89195
+ };
89196
+ }
89197
+ updates.nodeId = params.nodeId;
89198
+ updatedFields.push("nodeId");
89199
+ }
87268
89200
  if (updatedFields.length === 0) {
87269
89201
  return {
87270
- content: [{ type: "text", text: "No fields to update. Provide at least one of: title, description, depends, agentId." }],
89202
+ content: [{ type: "text", text: "No fields to update. Provide at least one of: title, description, depends, agentId, nodeId." }],
87271
89203
  isError: true,
87272
89204
  details: { error: "No fields provided" }
87273
89205
  };
@@ -87648,11 +89580,11 @@ Path: .fusion/tasks/${params.id}/attachments/${attachment.filename}`
87648
89580
  })
87649
89581
  )
87650
89582
  }),
87651
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
89583
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
87652
89584
  const [owner, repo] = params.ownerRepo.split("/");
87653
89585
  const limit = params.limit ?? 30;
87654
89586
  const labels = params.labels;
87655
- const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels });
89587
+ const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels, signal });
87656
89588
  if (issues.length === 0) {
87657
89589
  return {
87658
89590
  content: [{ type: "text", text: `No open issues found in ${owner}/${repo}.` }],
@@ -87727,9 +89659,9 @@ ${createdTasks.map((task) => ` ${task.id}: ${task.title}`).join("\n") || " Non
87727
89659
  minimum: 1
87728
89660
  })
87729
89661
  }),
87730
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
89662
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
87731
89663
  const { owner, repo, issueNumber } = params;
87732
- const issue = await fetchGitHubIssueViaGh(owner, repo, issueNumber);
89664
+ const issue = await fetchGitHubIssueViaGh(owner, repo, issueNumber, { signal });
87733
89665
  if (issue.pull_request) {
87734
89666
  throw new Error(`#${issueNumber} is a pull request, not an issue`);
87735
89667
  }
@@ -87813,9 +89745,9 @@ ${sourceUrl}`
87813
89745
  })
87814
89746
  )
87815
89747
  }),
87816
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
89748
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
87817
89749
  const { owner, repo, limit = 30, labels } = params;
87818
- const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels });
89750
+ const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels, signal });
87819
89751
  if (issues.length === 0) {
87820
89752
  return {
87821
89753
  content: [{ type: "text", text: `No open issues found in ${owner}/${repo}.` }],