@runfusion/fusion 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/bin.js +2932 -844
  2. package/dist/client/assets/{AgentDetailView-DyzuiJas.js → AgentDetailView-C3Xcrxnp.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-EjE4y4rM.js} +3 -3
  5. package/dist/client/assets/{ChatView-DrY8FMIt.js → ChatView-DQLvKCYj.js} +1 -1
  6. package/dist/client/assets/DevServerView-CX7paFRQ.js +1 -0
  7. package/dist/client/assets/{DirectoryPicker-D5KQ-im_.js → DirectoryPicker-_cBPx6Nx.js} +1 -1
  8. package/dist/client/assets/{DocumentsView-D2wK7FYJ.js → DocumentsView-Wz33aYqp.js} +1 -1
  9. package/dist/client/assets/{InsightsView-DfY3sa1j.js → InsightsView-C7YPnS92.js} +1 -1
  10. package/dist/client/assets/MemoryView-DKQtFzFQ.js +2 -0
  11. package/dist/client/assets/{NodesView-g26-j7rg.js → NodesView-CI4rUQC4.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-BFmdKgHZ.js} +3 -3
  14. package/dist/client/assets/{PluginManager-DiMOD-Kj.js → PluginManager-BGQU1IIw.js} +1 -1
  15. package/dist/client/assets/{RoadmapsView-DJC4F4CD.js → RoadmapsView-Cts3hoIS.js} +1 -1
  16. package/dist/client/assets/SettingsModal-D5hLoLXp.css +1 -0
  17. package/dist/client/assets/{SettingsModal-Cx3iMWDs.js → SettingsModal-DXvBGZHf.js} +1 -1
  18. package/dist/client/assets/SettingsModal-DvRd0ZOE.js +31 -0
  19. package/dist/client/assets/SetupWizardModal-DRF5fOoR.css +1 -0
  20. package/dist/client/assets/{SetupWizardModal-Cow6woq6.js → SetupWizardModal-Y2ewEE8Y.js} +1 -1
  21. package/dist/client/assets/{SkillsView-DTB2cmXQ.js → SkillsView-BXvrHzEZ.js} +1 -1
  22. package/dist/client/assets/{TodoView-CyxdHUdz.js → TodoView-NZHkv9YQ.js} +2 -2
  23. package/dist/client/assets/{folder-open-C3zB1vmh.js → folder-open-Kh0ScTc5.js} +1 -1
  24. package/dist/client/assets/index-CWz44REw.css +1 -0
  25. package/dist/client/assets/index-D1gavMG-.js +656 -0
  26. package/dist/client/assets/{list-checks-CK3_6p5e.js → list-checks-CvoT0bwU.js} +1 -1
  27. package/dist/client/assets/{star-BQhDgM9V.js → star-BdfwSLBU.js} +1 -1
  28. package/dist/client/assets/{upload-DDdZveEJ.js → upload-Bx8Yk_7Q.js} +1 -1
  29. package/dist/client/assets/{users-DWWgd19M.js → users-DgVaFEsz.js} +1 -1
  30. package/dist/client/index.html +2 -2
  31. package/dist/client/version.json +1 -1
  32. package/dist/extension.js +2219 -433
  33. package/dist/pi-claude-cli/index.ts +27 -0
  34. package/dist/pi-claude-cli/package.json +1 -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/index-Belw0PQt.css +0 -1
  48. 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 = 50;
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,12 @@ 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
+ }
3751
3968
  }
3752
3969
  /**
3753
3970
  * Run a single migration step inside a transaction and bump the version.
@@ -3763,6 +3980,14 @@ CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder
3763
3980
  const row = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
3764
3981
  return Boolean(row);
3765
3982
  }
3983
+ /**
3984
+ * Check whether an error appears to be an FTS5 corruption/integrity failure.
3985
+ */
3986
+ isFts5CorruptionError(error) {
3987
+ const message = error instanceof Error ? error.message : String(error ?? "");
3988
+ const lower = message.toLowerCase();
3989
+ return lower.includes("corruption found reading blob") || lower.includes("database disk image is malformed") || lower.includes("fts5") && lower.includes("corrupt");
3990
+ }
3766
3991
  /**
3767
3992
  * Check whether a table has a given column.
3768
3993
  */
@@ -18238,10 +18463,31 @@ var init_task_merge = __esm({
18238
18463
  "use strict";
18239
18464
  BLOCKING_TASK_STATUSES = /* @__PURE__ */ new Set([
18240
18465
  "failed",
18466
+ // ── User-attention / awaiting-handoff states ─────────────────────────
18241
18467
  "awaiting-inspection",
18242
18468
  "awaiting-user-review",
18469
+ "awaiting-approval",
18470
+ // triage spec awaiting user approval
18471
+ // ── Active merge in-flight ───────────────────────────────────────────
18243
18472
  "merging",
18244
- "merging-pr"
18473
+ "merging-pr",
18474
+ // ── Re-planning / triage states (scope not finalized) ────────────────
18475
+ // A task in planning/triage hasn't finalized its scope yet — letting it
18476
+ // merge skips the work the user moved it back to plan. Same for the legacy
18477
+ // "specifying" alias migrated to "planning" in db.ts.
18478
+ "planning",
18479
+ "specifying",
18480
+ "needs-replan",
18481
+ // scheduler/executor/triage signaled re-plan
18482
+ // ── Mission-level validation in flight ───────────────────────────────
18483
+ "mission-validation",
18484
+ // ── Scheduler-side transient state ───────────────────────────────────
18485
+ "queued",
18486
+ // scheduler placed the task in line; not finalized
18487
+ // ── Abnormal termination — defensive guard ───────────────────────────
18488
+ // Task was killed by the stuck detector. If it surfaces in in-review,
18489
+ // it needs investigation, not auto-merge.
18490
+ "stuck-killed"
18245
18491
  ]);
18246
18492
  NON_TERMINAL_STEP_STATUSES = /* @__PURE__ */ new Set([
18247
18493
  "pending",
@@ -29462,6 +29708,26 @@ var init_logger = __esm({
29462
29708
  }
29463
29709
  });
29464
29710
 
29711
+ // ../core/src/node-override-guard.ts
29712
+ function validateNodeOverrideChange(task, newNodeId) {
29713
+ if (newNodeId === void 0) {
29714
+ return { allowed: true };
29715
+ }
29716
+ if (task.column === "in-progress") {
29717
+ return {
29718
+ allowed: false,
29719
+ reason: "task-in-progress",
29720
+ 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.`
29721
+ };
29722
+ }
29723
+ return { allowed: true };
29724
+ }
29725
+ var init_node_override_guard = __esm({
29726
+ "../core/src/node-override-guard.ts"() {
29727
+ "use strict";
29728
+ }
29729
+ });
29730
+
29465
29731
  // ../core/src/store.ts
29466
29732
  import { EventEmitter as EventEmitter12 } from "node:events";
29467
29733
  import { randomUUID as randomUUID6 } from "node:crypto";
@@ -29549,6 +29815,7 @@ var init_store = __esm({
29549
29815
  init_project_memory();
29550
29816
  init_run_command();
29551
29817
  init_logger();
29818
+ init_node_override_guard();
29552
29819
  TASK_ACTIVITY_LOG_ENTRY_LIMIT = 1e3;
29553
29820
  TASK_ACTIVITY_LOG_OUTCOME_LIMIT = 4e3;
29554
29821
  ARCHIVE_AGENT_LOG_SNAPSHOT_LIMIT = 25;
@@ -29835,6 +30102,8 @@ var init_store = __esm({
29835
30102
  assignedAgentId: row.assignedAgentId || void 0,
29836
30103
  assigneeUserId: row.assigneeUserId || void 0,
29837
30104
  nodeId: row.nodeId || void 0,
30105
+ effectiveNodeId: row.effectiveNodeId || void 0,
30106
+ effectiveNodeSource: row.effectiveNodeSource || void 0,
29838
30107
  checkedOutBy: row.checkedOutBy || void 0,
29839
30108
  checkedOutAt: row.checkedOutAt || void 0
29840
30109
  };
@@ -30083,6 +30352,8 @@ ${recentText}` : void 0
30083
30352
  "assignedAgentId",
30084
30353
  "assigneeUserId",
30085
30354
  "nodeId",
30355
+ "effectiveNodeId",
30356
+ "effectiveNodeSource",
30086
30357
  "checkedOutBy",
30087
30358
  "checkedOutAt",
30088
30359
  // `log` is fetched in slim mode so the server can aggregate
@@ -30183,6 +30454,8 @@ ${outcome}`;
30183
30454
  "assignedAgentId",
30184
30455
  "assigneeUserId",
30185
30456
  "nodeId",
30457
+ "effectiveNodeId",
30458
+ "effectiveNodeSource",
30186
30459
  "checkedOutBy",
30187
30460
  "checkedOutAt"
30188
30461
  ];
@@ -30221,9 +30494,9 @@ ${outcome}`;
30221
30494
  dependencies, steps, log, attachments, steeringComments,
30222
30495
  comments, workflowStepResults, prInfo, issueInfo,
30223
30496
  sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
30224
- mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, nodeId, checkedOutBy, checkedOutAt
30497
+ mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, checkedOutBy, checkedOutAt
30225
30498
  ) VALUES (
30226
- ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
30499
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
30227
30500
  )
30228
30501
  ON CONFLICT(id) DO UPDATE SET
30229
30502
  title = excluded.title,
@@ -30291,6 +30564,8 @@ ${outcome}`;
30291
30564
  assignedAgentId = excluded.assignedAgentId,
30292
30565
  assigneeUserId = excluded.assigneeUserId,
30293
30566
  nodeId = excluded.nodeId,
30567
+ effectiveNodeId = excluded.effectiveNodeId,
30568
+ effectiveNodeSource = excluded.effectiveNodeSource,
30294
30569
  checkedOutBy = excluded.checkedOutBy,
30295
30570
  checkedOutAt = excluded.checkedOutAt
30296
30571
  `).run(
@@ -30360,11 +30635,36 @@ ${outcome}`;
30360
30635
  task.assignedAgentId ?? null,
30361
30636
  task.assigneeUserId ?? null,
30362
30637
  task.nodeId ?? null,
30638
+ task.effectiveNodeId ?? null,
30639
+ task.effectiveNodeSource ?? null,
30363
30640
  task.checkedOutBy ?? null,
30364
30641
  task.checkedOutAt ?? null
30365
30642
  );
30366
30643
  this.db.bumpLastModified();
30367
30644
  }
30645
+ upsertTaskWithFtsRecovery(task) {
30646
+ try {
30647
+ this.upsertTask(task);
30648
+ return;
30649
+ } catch (error) {
30650
+ if (!this.db.isFts5CorruptionError(error)) {
30651
+ throw error;
30652
+ }
30653
+ console.warn(`[fusion:store] FTS5 corruption detected during upsert for task ${task.id}; rebuilding index and retrying once`);
30654
+ try {
30655
+ this.db.rebuildFts5Index();
30656
+ } catch (rebuildError) {
30657
+ console.warn("[fusion:store] FTS5 rebuild failed; propagating original upsert error", rebuildError);
30658
+ throw error;
30659
+ }
30660
+ try {
30661
+ this.upsertTask(task);
30662
+ } catch (retryError) {
30663
+ console.warn("[fusion:store] Upsert retry after FTS5 rebuild failed; propagating original upsert error", retryError);
30664
+ throw error;
30665
+ }
30666
+ }
30667
+ }
30368
30668
  /**
30369
30669
  * Read a task from SQLite by ID.
30370
30670
  */
@@ -30583,7 +30883,7 @@ ${outcome}`;
30583
30883
  * for backward compatibility and debugging.
30584
30884
  */
30585
30885
  async atomicWriteTaskJson(dir, task) {
30586
- this.upsertTask(task);
30886
+ this.upsertTaskWithFtsRecovery(task);
30587
30887
  await this.writeTaskJsonFile(dir, task);
30588
30888
  }
30589
30889
  /**
@@ -30596,7 +30896,7 @@ ${outcome}`;
30596
30896
  */
30597
30897
  async atomicWriteTaskJsonWithAudit(dir, task, auditInput) {
30598
30898
  this.db.transaction(() => {
30599
- this.upsertTask(task);
30899
+ this.upsertTaskWithFtsRecovery(task);
30600
30900
  if (auditInput) {
30601
30901
  const eventId = randomUUID6();
30602
30902
  const timestamp = auditInput.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
@@ -31544,6 +31844,14 @@ ${newTask.description}
31544
31844
  if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress") || fromColumn === "done" && (toColumn === "todo" || toColumn === "triage")) {
31545
31845
  task.workflowStepResults = void 0;
31546
31846
  }
31847
+ if (fromColumn === "in-review" && toColumn === "todo") {
31848
+ task.branch = void 0;
31849
+ task.baseBranch = void 0;
31850
+ task.baseCommitSha = void 0;
31851
+ task.summary = void 0;
31852
+ task.recoveryRetryCount = void 0;
31853
+ task.nextRecoveryAt = void 0;
31854
+ }
31547
31855
  await this.atomicWriteTaskJson(dir, task);
31548
31856
  if (this.isWatching) this.taskCache.set(id, { ...task });
31549
31857
  this.emit("task:moved", { task, from: fromColumn, to: toColumn });
@@ -31557,6 +31865,12 @@ ${newTask.description}
31557
31865
  }
31558
31866
  const dir = this.taskDir(id);
31559
31867
  const task = await this.readTaskJson(dir);
31868
+ if (updates.nodeId !== void 0) {
31869
+ const validation = validateNodeOverrideChange(task, updates.nodeId ?? null);
31870
+ if (!validation.allowed) {
31871
+ throw new Error(validation.message);
31872
+ }
31873
+ }
31560
31874
  if (!task.log) {
31561
31875
  task.log = [];
31562
31876
  }
@@ -31619,6 +31933,16 @@ ${newTask.description}
31619
31933
  } else if (updates.nodeId !== void 0) {
31620
31934
  task.nodeId = updates.nodeId;
31621
31935
  }
31936
+ if (updates.effectiveNodeId === null) {
31937
+ task.effectiveNodeId = void 0;
31938
+ } else if (updates.effectiveNodeId !== void 0) {
31939
+ task.effectiveNodeId = updates.effectiveNodeId;
31940
+ }
31941
+ if (updates.effectiveNodeSource === null) {
31942
+ task.effectiveNodeSource = void 0;
31943
+ } else if (updates.effectiveNodeSource !== void 0) {
31944
+ task.effectiveNodeSource = updates.effectiveNodeSource;
31945
+ }
31622
31946
  if (updates.checkedOutBy === null) {
31623
31947
  task.checkedOutBy = void 0;
31624
31948
  task.checkedOutAt = void 0;
@@ -33852,7 +34176,7 @@ ${stepsSection}`;
33852
34176
  healthCheck() {
33853
34177
  try {
33854
34178
  this.db.prepare("SELECT 1").get();
33855
- return true;
34179
+ return this.db.checkFts5Integrity();
33856
34180
  } catch {
33857
34181
  return false;
33858
34182
  }
@@ -34474,6 +34798,10 @@ var init_board = __esm({
34474
34798
 
34475
34799
  // ../core/src/gh-cli.ts
34476
34800
  import { execFileSync, execFile as execFile2 } from "node:child_process";
34801
+ function normalizeRunGhOptions(opts) {
34802
+ if (typeof opts === "string") return { cwd: opts };
34803
+ return opts ?? {};
34804
+ }
34477
34805
  function isGhAvailable() {
34478
34806
  try {
34479
34807
  execFileSync("gh", ["--version"], {
@@ -34513,19 +34841,55 @@ function runGh(args, cwd) {
34513
34841
  throw error;
34514
34842
  }
34515
34843
  }
34516
- function runGhAsync(args, cwd) {
34844
+ function runGhAsync(args, cwdOrOptions) {
34845
+ const { cwd, signal: externalSignal, timeoutMs = DEFAULT_GH_TIMEOUT_MS } = normalizeRunGhOptions(cwdOrOptions);
34517
34846
  return new Promise((resolve17, reject) => {
34847
+ if (externalSignal?.aborted) {
34848
+ reject(makeGhError(`gh command aborted: ${describeAbortReason(externalSignal.reason)}`, "ABORT_ERR"));
34849
+ return;
34850
+ }
34851
+ const controller = new AbortController();
34852
+ let timedOut = false;
34853
+ let externalAborted = false;
34854
+ const onExternalAbort = () => {
34855
+ externalAborted = true;
34856
+ controller.abort();
34857
+ };
34858
+ if (externalSignal) {
34859
+ externalSignal.addEventListener("abort", onExternalAbort, { once: true });
34860
+ }
34861
+ const timer = timeoutMs > 0 ? setTimeout(() => {
34862
+ timedOut = true;
34863
+ controller.abort();
34864
+ }, timeoutMs) : void 0;
34865
+ const cleanup = () => {
34866
+ if (timer) clearTimeout(timer);
34867
+ if (externalSignal) externalSignal.removeEventListener("abort", onExternalAbort);
34868
+ };
34518
34869
  execFile2(
34519
34870
  "gh",
34520
34871
  args,
34521
34872
  {
34522
34873
  encoding: "utf-8",
34523
- cwd
34874
+ cwd,
34875
+ signal: controller.signal
34524
34876
  },
34525
34877
  (error, stdout, stderr) => {
34878
+ cleanup();
34526
34879
  if (error) {
34527
- const ghError = new Error(`gh command failed: ${error.message}`);
34528
- ghError.code = error.code ?? null;
34880
+ const isAbort = error.code === "ABORT_ERR" || error.name === "AbortError";
34881
+ let message;
34882
+ if (timedOut) {
34883
+ message = `gh command timed out after ${timeoutMs}ms`;
34884
+ } else if (isAbort && externalAborted) {
34885
+ message = `gh command aborted: ${describeAbortReason(externalSignal?.reason)}`;
34886
+ } else if (isAbort) {
34887
+ message = "gh command aborted";
34888
+ } else {
34889
+ message = `gh command failed: ${error.message}`;
34890
+ }
34891
+ const ghError = new Error(message);
34892
+ ghError.code = error.code ?? (isAbort ? "ABORT_ERR" : null);
34529
34893
  ghError.stdout = stdout ?? "";
34530
34894
  ghError.stderr = stderr ?? "";
34531
34895
  reject(ghError);
@@ -34536,6 +34900,18 @@ function runGhAsync(args, cwd) {
34536
34900
  );
34537
34901
  });
34538
34902
  }
34903
+ function makeGhError(message, code) {
34904
+ const err = new Error(message);
34905
+ err.code = code;
34906
+ err.stdout = "";
34907
+ err.stderr = "";
34908
+ return err;
34909
+ }
34910
+ function describeAbortReason(reason) {
34911
+ if (reason instanceof Error) return reason.message;
34912
+ if (typeof reason === "string") return reason;
34913
+ return "aborted";
34914
+ }
34539
34915
  function runGhJson(args, cwd) {
34540
34916
  const jsonArgs = args.includes("--json") ? args : [...args, "--json"];
34541
34917
  const output = runGh(jsonArgs, cwd);
@@ -34545,9 +34921,9 @@ function runGhJson(args, cwd) {
34545
34921
  throw new Error(`Failed to parse gh JSON output: ${err instanceof Error ? err.message : String(err)}`);
34546
34922
  }
34547
34923
  }
34548
- async function runGhJsonAsync(args, cwd) {
34924
+ async function runGhJsonAsync(args, cwdOrOptions) {
34549
34925
  const jsonArgs = args.includes("--json") ? args : [...args, "--json"];
34550
- const output = await runGhAsync(jsonArgs, cwd);
34926
+ const output = await runGhAsync(jsonArgs, cwdOrOptions);
34551
34927
  try {
34552
34928
  return JSON.parse(output);
34553
34929
  } catch (err) {
@@ -34605,9 +34981,11 @@ function getCurrentRepo(cwd) {
34605
34981
  return null;
34606
34982
  }
34607
34983
  }
34984
+ var DEFAULT_GH_TIMEOUT_MS;
34608
34985
  var init_gh_cli = __esm({
34609
34986
  "../core/src/gh-cli.ts"() {
34610
34987
  "use strict";
34988
+ DEFAULT_GH_TIMEOUT_MS = 3e4;
34611
34989
  }
34612
34990
  });
34613
34991
 
@@ -35071,6 +35449,83 @@ var init_routine_store = __esm({
35071
35449
  }
35072
35450
  });
35073
35451
 
35452
+ // ../core/src/notification/dispatcher.ts
35453
+ var NotificationDispatcher;
35454
+ var init_dispatcher = __esm({
35455
+ "../core/src/notification/dispatcher.ts"() {
35456
+ "use strict";
35457
+ NotificationDispatcher = class {
35458
+ constructor(config = {}) {
35459
+ this.config = config;
35460
+ }
35461
+ providers = /* @__PURE__ */ new Map();
35462
+ registerProvider(provider) {
35463
+ this.providers.set(provider.getProviderId(), provider);
35464
+ }
35465
+ unregisterProvider(providerId) {
35466
+ this.providers.delete(providerId);
35467
+ }
35468
+ getProviders() {
35469
+ return [...this.providers.values()];
35470
+ }
35471
+ async dispatch(event, payload) {
35472
+ const providers = this.getProviders().filter(
35473
+ (provider) => provider.isEventSupported(event)
35474
+ );
35475
+ const results = await Promise.all(
35476
+ providers.map(async (provider) => {
35477
+ const providerId = provider.getProviderId();
35478
+ try {
35479
+ return await provider.sendNotification(event, payload);
35480
+ } catch (error) {
35481
+ const message = error instanceof Error ? error.message : String(error);
35482
+ console.warn(
35483
+ `[notification-dispatcher] Provider ${providerId} failed for event ${event}: ${message}`
35484
+ );
35485
+ return { success: false, providerId, error: message };
35486
+ }
35487
+ })
35488
+ );
35489
+ return results;
35490
+ }
35491
+ async initializeAll() {
35492
+ await Promise.all(
35493
+ this.getProviders().map(async (provider) => {
35494
+ if (!provider.initialize) {
35495
+ return;
35496
+ }
35497
+ try {
35498
+ await provider.initialize(this.config);
35499
+ } catch (error) {
35500
+ const message = error instanceof Error ? error.message : String(error);
35501
+ console.warn(
35502
+ `[notification-dispatcher] Provider ${provider.getProviderId()} initialization failed: ${message}`
35503
+ );
35504
+ }
35505
+ })
35506
+ );
35507
+ }
35508
+ async shutdownAll() {
35509
+ await Promise.all(
35510
+ this.getProviders().map(async (provider) => {
35511
+ if (!provider.shutdown) {
35512
+ return;
35513
+ }
35514
+ try {
35515
+ await provider.shutdown();
35516
+ } catch (error) {
35517
+ const message = error instanceof Error ? error.message : String(error);
35518
+ console.warn(
35519
+ `[notification-dispatcher] Provider ${provider.getProviderId()} shutdown failed: ${message}`
35520
+ );
35521
+ }
35522
+ })
35523
+ );
35524
+ }
35525
+ };
35526
+ }
35527
+ });
35528
+
35074
35529
  // ../core/src/plugin-loader.ts
35075
35530
  import { basename as basename6, dirname as dirname5, extname, isAbsolute as isAbsolute5, resolve as resolve7 } from "node:path";
35076
35531
  import { copyFile, rm } from "node:fs/promises";
@@ -48447,8 +48902,10 @@ __export(src_exports, {
48447
48902
  MessageStore: () => MessageStore,
48448
48903
  MigrationCoordinator: () => MigrationCoordinator,
48449
48904
  MissionStore: () => MissionStore,
48905
+ NOTIFICATION_EVENTS: () => NOTIFICATION_EVENTS,
48450
48906
  NodeConnection: () => NodeConnection,
48451
48907
  NodeDiscovery: () => NodeDiscovery,
48908
+ NotificationDispatcher: () => NotificationDispatcher,
48452
48909
  PROJECT_SETTINGS_KEYS: () => PROJECT_SETTINGS_KEYS,
48453
48910
  PROMPT_KEY_CATALOG: () => PROMPT_KEY_CATALOG,
48454
48911
  PluginLoader: () => PluginLoader,
@@ -48596,6 +49053,7 @@ __export(src_exports, {
48596
49053
  mergeInsights: () => mergeInsights,
48597
49054
  migrateFromLegacy: () => migrateFromLegacy,
48598
49055
  moveRoadmapFeature: () => moveRoadmapFeature,
49056
+ normalizeMergeConflictStrategy: () => normalizeMergeConflictStrategy,
48599
49057
  normalizePermissions: () => normalizePermissions,
48600
49058
  normalizeRoadmapFeatureOrder: () => normalizeRoadmapFeatureOrder,
48601
49059
  normalizeRoadmapMilestoneOrder: () => normalizeRoadmapMilestoneOrder,
@@ -48635,12 +49093,20 @@ __export(src_exports, {
48635
49093
  renderMemoryAuditMarkdown: () => renderMemoryAuditMarkdown,
48636
49094
  resolveAgentPrompt: () => resolveAgentPrompt,
48637
49095
  resolveDependencyOrder: () => resolveDependencyOrder,
49096
+ resolveExecutionSettingsModel: () => resolveExecutionSettingsModel,
48638
49097
  resolveGlobalDir: () => resolveGlobalDir,
48639
49098
  resolveMemoryBackend: () => resolveMemoryBackend,
48640
49099
  resolveMemoryInstructionContext: () => resolveMemoryInstructionContext,
48641
49100
  resolvePiExtensionProjectRoot: () => resolvePiExtensionProjectRoot,
49101
+ resolvePlanningSettingsModel: () => resolvePlanningSettingsModel,
49102
+ resolveProjectDefaultModel: () => resolveProjectDefaultModel,
48642
49103
  resolvePrompt: () => resolvePrompt,
48643
49104
  resolveRolePrompts: () => resolveRolePrompts,
49105
+ resolveTaskExecutionModel: () => resolveTaskExecutionModel,
49106
+ resolveTaskPlanningModel: () => resolveTaskPlanningModel,
49107
+ resolveTaskValidatorModel: () => resolveTaskValidatorModel,
49108
+ resolveTitleSummarizerSettingsModel: () => resolveTitleSummarizerSettingsModel,
49109
+ resolveValidatorSettingsModel: () => resolveValidatorSettingsModel,
48644
49110
  runBackupCommand: () => runBackupCommand,
48645
49111
  runCommandAsync: () => runCommandAsync,
48646
49112
  runGh: () => runGh,
@@ -48670,6 +49136,7 @@ __export(src_exports, {
48670
49136
  validateDescription: () => validateDescription,
48671
49137
  validateImportData: () => validateImportData,
48672
49138
  validateMessageMetadata: () => validateMessageMetadata,
49139
+ validateNodeOverrideChange: () => validateNodeOverrideChange,
48673
49140
  validatePluginManifest: () => validatePluginManifest,
48674
49141
  validateUnavailableNodePolicy: () => validateUnavailableNodePolicy,
48675
49142
  writeAgentMemoryFile: () => writeAgentMemoryFile,
@@ -48704,15 +49171,19 @@ var init_src = __esm({
48704
49171
  init_automation();
48705
49172
  init_automation_store();
48706
49173
  init_run_command();
49174
+ init_node_override_guard();
48707
49175
  init_settings_validation();
48708
49176
  init_routine();
48709
49177
  init_routine_store();
49178
+ init_dispatcher();
49179
+ init_types();
48710
49180
  init_plugin_types();
48711
49181
  init_plugin_store();
48712
49182
  init_plugin_loader();
48713
49183
  init_backup();
48714
49184
  init_settings_export();
48715
49185
  init_ai_summarize();
49186
+ init_model_resolution();
48716
49187
  init_memory_compaction();
48717
49188
  init_roadmap_ordering();
48718
49189
  init_task_priority();
@@ -50291,17 +50762,6 @@ var init_auth_storage = __esm({
50291
50762
  });
50292
50763
 
50293
50764
  // ../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
50765
  import { existsSync as existsSync20, readFileSync as readFileSync6 } from "node:fs";
50306
50766
  import { exec } from "node:child_process";
50307
50767
  import { promisify as promisify2 } from "node:util";
@@ -51225,16 +51685,14 @@ function wrapPluginRuntime(instance, runtimeId, runtimeName) {
51225
51685
  if (typeof adapter.promptWithFallback === "function") {
51226
51686
  return adapter.promptWithFallback(session, prompt, options);
51227
51687
  }
51228
- const { promptWithFallback: pwf } = await Promise.resolve().then(() => (init_pi(), pi_exports));
51229
- return pwf(session, prompt, options);
51688
+ return promptWithFallback(session, prompt, options);
51230
51689
  },
51231
51690
  describeModel: (session) => {
51232
51691
  const adapter = instance;
51233
51692
  if (typeof adapter.describeModel === "function") {
51234
51693
  return adapter.describeModel(session);
51235
51694
  }
51236
- const { describeModel: dm } = (init_pi(), __toCommonJS(pi_exports));
51237
- return dm(session);
51695
+ return describeModel(session);
51238
51696
  }
51239
51697
  };
51240
51698
  }
@@ -51305,36 +51763,19 @@ var init_runtime_resolution = __esm({
51305
51763
  "../engine/src/runtime-resolution.ts"() {
51306
51764
  "use strict";
51307
51765
  init_logger2();
51766
+ init_pi();
51308
51767
  runtimeLog2 = createLogger2("runtime-resolver");
51309
- DefaultPiRuntime = class _DefaultPiRuntime {
51768
+ DefaultPiRuntime = class {
51310
51769
  id = "pi";
51311
51770
  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
51771
  async createSession(options) {
51318
- const { createFnAgent: createFnAgent5 } = await Promise.resolve().then(() => (init_pi(), pi_exports));
51319
- return createFnAgent5(options);
51772
+ return createFnAgent2(options);
51320
51773
  }
51321
- /**
51322
- * Prompt with automatic retry and compaction.
51323
- * Delegates to the existing promptWithFallback implementation.
51324
- */
51325
51774
  async promptWithFallback(session, prompt, options) {
51326
- const { promptWithFallback: pwf } = await Promise.resolve().then(() => (init_pi(), pi_exports));
51327
- return pwf(session, prompt, options);
51775
+ return promptWithFallback(session, prompt, options);
51328
51776
  }
51329
- /**
51330
- * Get model description from session.
51331
- */
51332
51777
  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);
51778
+ return describeModel(session);
51338
51779
  }
51339
51780
  };
51340
51781
  defaultPiRuntimeInstance = null;
@@ -51378,12 +51819,10 @@ async function createResolvedAgentSession(options) {
51378
51819
  };
51379
51820
  }
51380
51821
  async function promptWithAutoRetry(session, prompt, options) {
51381
- const { promptWithFallback: pwf } = await Promise.resolve().then(() => (init_pi(), pi_exports));
51382
- return pwf(session, prompt, options);
51822
+ return promptWithFallback(session, prompt, options);
51383
51823
  }
51384
51824
  async function describeAgentModel(session) {
51385
- const { describeModel: dm } = await Promise.resolve().then(() => (init_pi(), pi_exports));
51386
- return dm(session);
51825
+ return describeModel(session);
51387
51826
  }
51388
51827
  var sessionLog;
51389
51828
  var init_agent_session_helpers = __esm({
@@ -51391,6 +51830,7 @@ var init_agent_session_helpers = __esm({
51391
51830
  "use strict";
51392
51831
  init_runtime_resolution();
51393
51832
  init_logger2();
51833
+ init_pi();
51394
51834
  sessionLog = createLogger2("agent-session");
51395
51835
  }
51396
51836
  });
@@ -55114,24 +55554,26 @@ async function resolveTaskDiffBaseRef({
55114
55554
  baseBranch,
55115
55555
  baseCommitSha
55116
55556
  }) {
55117
- const resolvedBaseBranch = baseBranch?.trim() || "main";
55557
+ const resolvedBaseBranch = baseBranch?.trim() || (baseCommitSha ? void 0 : "main");
55118
55558
  const quotedHeadRef = quoteArg(headRef);
55119
55559
  let mergeBase;
55120
- try {
55560
+ if (resolvedBaseBranch) {
55121
55561
  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;
55562
+ try {
55563
+ const { stdout } = await execAsync2(`git merge-base ${quotedHeadRef} ${quoteArg(resolvedBaseBranch)}`, {
55564
+ cwd,
55565
+ encoding: "utf-8"
55566
+ });
55567
+ mergeBase = stdout.trim() || void 0;
55568
+ } catch {
55569
+ const { stdout } = await execAsync2(`git merge-base ${quotedHeadRef} ${quoteArg(`origin/${resolvedBaseBranch}`)}`, {
55570
+ cwd,
55571
+ encoding: "utf-8"
55572
+ });
55573
+ mergeBase = stdout.trim() || void 0;
55574
+ }
55127
55575
  } 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
55576
  }
55134
- } catch {
55135
55577
  }
55136
55578
  if (mergeBase) {
55137
55579
  try {
@@ -55238,6 +55680,26 @@ async function resolveTrivialWhitespace(filePath, cwd) {
55238
55680
  throw new Error(`Failed to auto-resolve ${filePath} trivial conflict: ${error}`);
55239
55681
  }
55240
55682
  }
55683
+ function buildTaskIdTrailerArg(taskId) {
55684
+ return ` -m "${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}"`;
55685
+ }
55686
+ async function ensureTaskIdTrailerOnHead(rootDir, taskId) {
55687
+ try {
55688
+ const { stdout: existingMessage } = await execAsync2("git log -1 --pretty=%B", {
55689
+ cwd: rootDir,
55690
+ encoding: "utf-8"
55691
+ });
55692
+ const trailerLine = `${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`;
55693
+ if (existingMessage.includes(trailerLine)) return;
55694
+ await execAsync2(
55695
+ `git -c trailer.ifExists=addIfDifferent commit --amend --no-edit --trailer "${trailerLine}"`,
55696
+ { cwd: rootDir }
55697
+ );
55698
+ } catch (err) {
55699
+ const msg = err instanceof Error ? err.message : String(err);
55700
+ mergerLog.warn(`${taskId}: failed to add ${FUSION_TASK_ID_TRAILER_KEY} trailer to HEAD (${msg}) \u2014 relying on subject grep for recovery`);
55701
+ }
55702
+ }
55241
55703
  function getCommitAuthorArg(settings) {
55242
55704
  if (settings.commitAuthorEnabled === false) return "";
55243
55705
  const name = settings.commitAuthorName || "Fusion";
@@ -55676,6 +56138,13 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55676
56138
  const settings = await store.getSettings();
55677
56139
  const includeTaskId = settings.includeTaskIdInCommit !== false;
55678
56140
  const smartConflictResolution = (settings.smartConflictResolution ?? settings.autoResolveConflicts) !== false;
56141
+ const mergeConflictStrategy = normalizeMergeConflictStrategy(
56142
+ settings.mergeConflictStrategy
56143
+ );
56144
+ if (mergeConflictStrategy === "smart-prefer-main" || mergeConflictStrategy === "smart-prefer-branch") {
56145
+ await tryFastForwardFromOrigin(rootDir, taskId);
56146
+ }
56147
+ let mergeWasEmpty = false;
55679
56148
  try {
55680
56149
  execSync(`git rev-parse --verify "${branch}"`, {
55681
56150
  cwd: rootDir,
@@ -55735,6 +56204,57 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55735
56204
  mergerLog.warn(`${taskId}: unable to verify/checkout main branch \u2014 proceeding on current HEAD`);
55736
56205
  }
55737
56206
  }
56207
+ let rebaseHappened = false;
56208
+ let preferMainRebaseFailureMessage;
56209
+ if (settings.worktreeRebaseBeforeMerge === false && settings.worktreeRebaseLocalBase === false && mergeConflictStrategy === "smart-prefer-main") {
56210
+ throw new Error(
56211
+ `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".`
56212
+ );
56213
+ }
56214
+ async function runLocalBaseRebase(label) {
56215
+ if (!worktreePath) return;
56216
+ try {
56217
+ const { stdout: localHeadOut } = await execAsync2("git rev-parse HEAD", {
56218
+ cwd: rootDir,
56219
+ encoding: "utf-8"
56220
+ });
56221
+ const localHead = localHeadOut.trim();
56222
+ if (!localHead) return;
56223
+ let alreadyContains = false;
56224
+ try {
56225
+ await execAsync2(`git merge-base --is-ancestor "${localHead}" HEAD`, { cwd: worktreePath });
56226
+ alreadyContains = true;
56227
+ } catch {
56228
+ }
56229
+ if (alreadyContains) {
56230
+ rebaseHappened = true;
56231
+ return;
56232
+ }
56233
+ throwIfAborted(options.signal, taskId);
56234
+ await execAsync2(`git rebase "${localHead}"`, { cwd: worktreePath });
56235
+ rebaseHappened = true;
56236
+ mergerLog.log(`${taskId}: rebased ${branch} onto local HEAD ${localHead.slice(0, 8)}${label ? ` (${label})` : ""}`);
56237
+ await store.appendAgentLog(
56238
+ taskId,
56239
+ `Pre-merge rebase: ${branch} \u2192 local HEAD ${localHead.slice(0, 8)}${label ? ` (${label})` : ""}`,
56240
+ "text",
56241
+ void 0,
56242
+ "merger"
56243
+ );
56244
+ } catch (localRebaseErr) {
56245
+ rethrowIfMergeAborted(localRebaseErr);
56246
+ const lmsg = localRebaseErr instanceof Error ? localRebaseErr.message : String(localRebaseErr);
56247
+ mergerLog.warn(`${taskId}: pre-merge rebase onto local HEAD failed (${lmsg}) \u2014 aborting and falling through`);
56248
+ try {
56249
+ await execAsync2("git rebase --abort", { cwd: worktreePath });
56250
+ } catch (abortError) {
56251
+ mergerLog.warn(`${taskId}: failed to abort local-HEAD rebase: ${getCommandErrorMessage(abortError)}`);
56252
+ }
56253
+ if (mergeConflictStrategy === "smart-prefer-main" && !preferMainRebaseFailureMessage) {
56254
+ preferMainRebaseFailureMessage = `Pre-merge rebase onto local HEAD aborted (${lmsg})`;
56255
+ }
56256
+ }
56257
+ }
55738
56258
  if (settings.worktreeRebaseBeforeMerge !== false) {
55739
56259
  try {
55740
56260
  let remote = settings.worktreeRebaseRemote?.trim();
@@ -55769,7 +56289,9 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55769
56289
  }
55770
56290
  }
55771
56291
  if (!remote) {
55772
- mergerLog.log(`${taskId}: no remote resolvable \u2014 skipping pre-merge rebase`);
56292
+ mergerLog.log(`${taskId}: no remote resolvable \u2014 skipping remote rebase stage (local-base stage may still run)`);
56293
+ } else if (!worktreePath) {
56294
+ mergerLog.warn(`${taskId}: no worktreePath \u2014 skipping remote rebase stage`);
55773
56295
  } else {
55774
56296
  throwIfAborted(options.signal, taskId);
55775
56297
  mergerLog.log(`${taskId}: fetching ${remote} before merge`);
@@ -55781,17 +56303,21 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55781
56303
  );
55782
56304
  const mainBranch = mainBranchOut.trim();
55783
56305
  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
- }
56306
+ throwIfAborted(options.signal, taskId);
56307
+ await execAsync2(`git rebase "${remoteRef}"`, { cwd: worktreePath });
56308
+ rebaseHappened = true;
56309
+ mergerLog.log(`${taskId}: rebased ${branch} onto ${remoteRef}`);
56310
+ await store.appendAgentLog(
56311
+ taskId,
56312
+ `Pre-merge rebase: ${branch} \u2192 ${remoteRef}`,
56313
+ "text",
56314
+ void 0,
56315
+ "merger"
56316
+ );
55791
56317
  } catch (rebaseErr) {
55792
56318
  rethrowIfMergeAborted(rebaseErr);
55793
56319
  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`);
56320
+ mergerLog.warn(`${taskId}: pre-merge rebase failed (${msg}) \u2014 aborting rebase and falling through`);
55795
56321
  if (worktreePath) {
55796
56322
  try {
55797
56323
  await execAsync2("git rebase --abort", { cwd: worktreePath });
@@ -55799,14 +56325,32 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55799
56325
  mergerLog.warn(`${taskId}: failed to abort pre-merge rebase: ${getCommandErrorMessage(abortError)}`);
55800
56326
  }
55801
56327
  }
56328
+ if (mergeConflictStrategy === "smart-prefer-main") {
56329
+ preferMainRebaseFailureMessage = `Pre-merge rebase onto remote main aborted (${msg})`;
56330
+ }
55802
56331
  }
55803
56332
  }
55804
56333
  } catch (err) {
55805
56334
  rethrowIfMergeAborted(err);
55806
56335
  const msg = err instanceof Error ? err.message : String(err);
55807
- mergerLog.warn(`${taskId}: pre-merge rebase pipeline failed (${msg}) \u2014 proceeding without rebase`);
56336
+ mergerLog.warn(`${taskId}: pre-merge remote rebase pipeline failed (${msg}) \u2014 proceeding without remote rebase`);
55808
56337
  }
55809
56338
  }
56339
+ if (settings.worktreeRebaseLocalBase !== false && !preferMainRebaseFailureMessage) {
56340
+ await runLocalBaseRebase(
56341
+ settings.worktreeRebaseBeforeMerge === false ? "remote rebase disabled" : ""
56342
+ );
56343
+ }
56344
+ if (preferMainRebaseFailureMessage) {
56345
+ throw new Error(
56346
+ `${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".`
56347
+ );
56348
+ }
56349
+ if (mergeConflictStrategy === "smart-prefer-main" && !rebaseHappened) {
56350
+ mergerLog.warn(
56351
+ `${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.`
56352
+ );
56353
+ }
55810
56354
  const diffBaseRef = await resolveTaskDiffBaseRef({
55811
56355
  cwd: rootDir,
55812
56356
  headRef: branch,
@@ -55867,6 +56411,14 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55867
56411
  }
55868
56412
  const mergeAttempt = async (attemptNum) => {
55869
56413
  mergerLog.log(`${taskId}: merge attempt ${attemptNum}/3...`);
56414
+ 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`;
56415
+ await store.appendAgentLog(
56416
+ taskId,
56417
+ `Starting merge ${attemptLabel}`,
56418
+ "text",
56419
+ void 0,
56420
+ "merger"
56421
+ );
55870
56422
  try {
55871
56423
  const success = await executeMergeAttempt({
55872
56424
  store,
@@ -55877,6 +56429,7 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55877
56429
  diffStat,
55878
56430
  includeTaskId,
55879
56431
  smartConflictResolution,
56432
+ mergeConflictStrategy,
55880
56433
  attemptNum,
55881
56434
  options,
55882
56435
  result,
@@ -55888,7 +56441,7 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
55888
56441
  }, aiTracker);
55889
56442
  if (success) {
55890
56443
  result.attemptsMade = attemptNum;
55891
- result.resolutionStrategy = getResolutionStrategy(attemptNum, smartConflictResolution);
56444
+ result.resolutionStrategy = getResolutionStrategy(attemptNum, smartConflictResolution, mergeConflictStrategy);
55892
56445
  result.resolutionMethod = getResolutionMethod(result.resolutionStrategy, result.autoResolvedCount, aiTracker.aiWasInvoked);
55893
56446
  result.merged = true;
55894
56447
  return true;
@@ -56079,6 +56632,13 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
56079
56632
  }
56080
56633
  throw error;
56081
56634
  }
56635
+ if (error.name === "MergeNonConflictError") {
56636
+ try {
56637
+ execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
56638
+ } catch {
56639
+ }
56640
+ throw error;
56641
+ }
56082
56642
  if (attemptNum < 3 && smartConflictResolution) {
56083
56643
  mergerLog.log(`${taskId}: attempt ${attemptNum} error, cleaning up for retry...`);
56084
56644
  try {
@@ -56096,12 +56656,15 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
56096
56656
  const aiTracker = { aiWasInvoked: false };
56097
56657
  let merged = false;
56098
56658
  merged = await mergeAttempt(1);
56099
- if (!merged && smartConflictResolution) {
56659
+ if (!merged && smartConflictResolution && mergeConflictStrategy !== "abort") {
56100
56660
  merged = await mergeAttempt(2);
56101
56661
  }
56102
- if (!merged && smartConflictResolution) {
56662
+ if (!merged && smartConflictResolution && mergeConflictStrategy !== "ai-only" && mergeConflictStrategy !== "abort") {
56103
56663
  merged = await mergeAttempt(3);
56104
56664
  }
56665
+ if (aiTracker.mergeWasEmpty) {
56666
+ mergeWasEmpty = true;
56667
+ }
56105
56668
  if (!merged) {
56106
56669
  try {
56107
56670
  execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
@@ -56109,6 +56672,10 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
56109
56672
  const errorMessage = err instanceof Error ? err.message : String(err);
56110
56673
  mergerLog.warn(`${taskId}: git reset --merge cleanup failed: ${errorMessage}`);
56111
56674
  }
56675
+ if (mergeConflictStrategy === "abort") {
56676
+ result.resolutionStrategy = "abort";
56677
+ throw new Error(`Merge conflict for ${taskId}: aborted per mergeConflictStrategy="abort" \u2014 manual resolution required`);
56678
+ }
56112
56679
  throw new Error(`AI merge failed for ${taskId}: all 3 attempts exhausted`);
56113
56680
  }
56114
56681
  try {
@@ -56135,17 +56702,24 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
56135
56702
  } catch {
56136
56703
  }
56137
56704
  const isEmptyCommit = filesChanged === 0;
56138
- const recordedSha = isEmptyCommit ? void 0 : commitSha;
56705
+ const recordedSha = isEmptyCommit || mergeWasEmpty ? void 0 : commitSha;
56139
56706
  if (isEmptyCommit) {
56140
56707
  mergerLog.warn(
56141
56708
  `${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
56709
  );
56710
+ } else if (mergeWasEmpty) {
56711
+ mergerLog.warn(
56712
+ `${taskId}: merge succeeded without committing (branch already on main). Skipping commitSha; nothing new landed locally.`
56713
+ );
56143
56714
  }
56715
+ const recordedFilesChanged = mergeWasEmpty ? 0 : filesChanged;
56716
+ const recordedInsertions = mergeWasEmpty ? 0 : insertions;
56717
+ const recordedDeletions = mergeWasEmpty ? 0 : deletions;
56144
56718
  const mergeDetails = {
56145
56719
  commitSha: recordedSha,
56146
- filesChanged,
56147
- insertions,
56148
- deletions,
56720
+ filesChanged: recordedFilesChanged,
56721
+ insertions: recordedInsertions,
56722
+ deletions: recordedDeletions,
56149
56723
  mergeCommitMessage: commitLog,
56150
56724
  mergedAt: (/* @__PURE__ */ new Date()).toISOString(),
56151
56725
  mergeConfirmed: true,
@@ -56156,6 +56730,26 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
56156
56730
  };
56157
56731
  await store.updateTask(taskId, { mergeDetails });
56158
56732
  mergerLog.log(`${taskId}: merge details stored (commitSha: ${recordedSha?.slice(0, 8) ?? "<deferred>"})`);
56733
+ const summaryParts = [
56734
+ `Merge completed via ${result.resolutionStrategy ?? "unknown"} (attempt ${result.attemptsMade ?? "?"}/3)`
56735
+ ];
56736
+ if (recordedSha) {
56737
+ summaryParts.push(`commit ${recordedSha.slice(0, 8)}`);
56738
+ } else if (mergeWasEmpty) {
56739
+ summaryParts.push("no commit landed (branch already on main)");
56740
+ } else if (isEmptyCommit) {
56741
+ summaryParts.push("squash collapsed to empty (sha deferred)");
56742
+ }
56743
+ if (!mergeWasEmpty && filesChanged !== void 0) {
56744
+ summaryParts.push(`${filesChanged} file${filesChanged === 1 ? "" : "s"} changed (+${insertions ?? 0}/-${deletions ?? 0})`);
56745
+ }
56746
+ await store.appendAgentLog(
56747
+ taskId,
56748
+ summaryParts.join(" \xB7 "),
56749
+ "text",
56750
+ void 0,
56751
+ "merger"
56752
+ );
56159
56753
  } catch (err) {
56160
56754
  mergerLog.warn(`${taskId}: failed to collect/store merge details: ${err.message}`);
56161
56755
  }
@@ -56250,6 +56844,27 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
56250
56844
  });
56251
56845
  if (pushResult.pushed) {
56252
56846
  mergerLog.log(`${taskId}: pushed merged result to remote`);
56847
+ try {
56848
+ const postPushSha = execSync("git rev-parse HEAD", {
56849
+ cwd: rootDir,
56850
+ stdio: "pipe",
56851
+ encoding: "utf-8"
56852
+ }).trim() || void 0;
56853
+ if (postPushSha) {
56854
+ const existingTask = await store.getTask(taskId).catch(() => null);
56855
+ const existingDetails = existingTask?.mergeDetails;
56856
+ if (existingDetails?.commitSha && existingDetails.commitSha !== postPushSha) {
56857
+ await store.updateTask(taskId, {
56858
+ mergeDetails: { ...existingDetails, commitSha: postPushSha }
56859
+ });
56860
+ mergerLog.log(
56861
+ `${taskId}: post-push HEAD changed from ${existingDetails.commitSha.slice(0, 8)} to ${postPushSha.slice(0, 8)} \u2014 refreshed mergeDetails.commitSha`
56862
+ );
56863
+ }
56864
+ }
56865
+ } catch (refreshErr) {
56866
+ mergerLog.warn(`${taskId}: failed to refresh mergeDetails after push: ${refreshErr.message}`);
56867
+ }
56253
56868
  } else {
56254
56869
  mergerLog.warn(`${taskId}: push to remote failed: ${pushResult.error}`);
56255
56870
  }
@@ -56277,18 +56892,74 @@ async function aiMergeTask(store, rootDir, taskId, options = {}) {
56277
56892
  await completeTask(store, taskId, result);
56278
56893
  return result;
56279
56894
  }
56280
- function getResolutionStrategy(attemptNum, smartConflictResolution) {
56895
+ async function tryFastForwardFromOrigin(rootDir, taskId) {
56896
+ let currentBranch;
56897
+ try {
56898
+ currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
56899
+ cwd: rootDir,
56900
+ encoding: "utf-8",
56901
+ stdio: "pipe"
56902
+ }).trim();
56903
+ } catch {
56904
+ return;
56905
+ }
56906
+ if (!currentBranch || currentBranch === "HEAD") return;
56907
+ try {
56908
+ await execAsync2(`git fetch origin "${currentBranch}"`, { cwd: rootDir });
56909
+ } catch (err) {
56910
+ mergerLog.log(`${taskId}: pre-merge fetch failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
56911
+ return;
56912
+ }
56913
+ let behind = 0;
56914
+ let ahead = 0;
56915
+ try {
56916
+ const counts = execSync(`git rev-list --left-right --count "origin/${currentBranch}...HEAD"`, {
56917
+ cwd: rootDir,
56918
+ encoding: "utf-8",
56919
+ stdio: "pipe"
56920
+ }).trim();
56921
+ const [b, a] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
56922
+ behind = b;
56923
+ ahead = a;
56924
+ } catch {
56925
+ return;
56926
+ }
56927
+ if (behind === 0) return;
56928
+ if (ahead > 0) {
56929
+ mergerLog.log(`${taskId}: local ${currentBranch} has ${ahead} unpushed commit(s); skipping fast-forward`);
56930
+ return;
56931
+ }
56932
+ try {
56933
+ await execAsync2(`git merge --ff-only "origin/${currentBranch}"`, { cwd: rootDir });
56934
+ mergerLog.log(`${taskId}: fast-forwarded ${currentBranch} by ${behind} commit(s) from origin`);
56935
+ } catch (err) {
56936
+ mergerLog.log(`${taskId}: fast-forward failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
56937
+ }
56938
+ }
56939
+ function getResolutionStrategy(attemptNum, smartConflictResolution, mergeConflictStrategy = "smart-prefer-main") {
56281
56940
  if (!smartConflictResolution || attemptNum === 1) {
56282
56941
  return "ai";
56283
56942
  }
56284
56943
  if (attemptNum === 2) {
56285
56944
  return "auto-resolve";
56286
56945
  }
56287
- return "theirs";
56946
+ switch (mergeConflictStrategy) {
56947
+ case "ai-only":
56948
+ return "ai";
56949
+ case "smart-prefer-main":
56950
+ return "ours";
56951
+ case "abort":
56952
+ return "abort";
56953
+ case "smart-prefer-branch":
56954
+ default:
56955
+ return "theirs";
56956
+ }
56288
56957
  }
56289
56958
  function getResolutionMethod(strategy, autoResolvedCount, aiWasUsed) {
56290
56959
  if (strategy === "ai") return "ai";
56291
56960
  if (strategy === "theirs") return "theirs";
56961
+ if (strategy === "ours") return "ours";
56962
+ if (strategy === "abort") return "abort";
56292
56963
  if (strategy === "auto-resolve") {
56293
56964
  if (autoResolvedCount && autoResolvedCount > 0) {
56294
56965
  return aiWasUsed ? "mixed" : "auto";
@@ -56317,12 +56988,15 @@ async function executeMergeAttempt(params, aiTracker) {
56317
56988
  buildSource
56318
56989
  } = params;
56319
56990
  if (attemptNum === 3) {
56320
- return attemptWithTheirsStrategy(params);
56991
+ if (params.mergeConflictStrategy === "smart-prefer-main") {
56992
+ return attemptWithSideStrategy(params, "ours", aiTracker);
56993
+ }
56994
+ return attemptWithSideStrategy(params, "theirs", aiTracker);
56321
56995
  }
56322
56996
  let hasConflicts = false;
56323
56997
  try {
56324
56998
  if (attemptNum === 2 && smartConflictResolution) {
56325
- let mergeExitedWithConflicts = false;
56999
+ let mergeError;
56326
57000
  try {
56327
57001
  await execAsync2(`git merge --squash "${branch}"`, {
56328
57002
  cwd: rootDir
@@ -56330,9 +57004,18 @@ async function executeMergeAttempt(params, aiTracker) {
56330
57004
  throwIfAborted(options.signal, taskId);
56331
57005
  } catch (error) {
56332
57006
  rethrowIfMergeAborted(error);
56333
- mergeExitedWithConflicts = true;
57007
+ mergeError = error;
56334
57008
  }
56335
57009
  const conflictedFiles = await getConflictedFiles(rootDir);
57010
+ if (mergeError && conflictedFiles.length === 0) {
57011
+ const cause = mergeError instanceof Error ? mergeError.message : String(mergeError);
57012
+ const fatal = new Error(
57013
+ `${taskId}: git merge --squash failed without producing conflicts (${cause}) \u2014 refusing to treat as a no-op merge.`
57014
+ );
57015
+ fatal.name = "MergeNonConflictError";
57016
+ throw fatal;
57017
+ }
57018
+ const mergeExitedWithConflicts = mergeError !== void 0;
56336
57019
  if (conflictedFiles.length > 0 || mergeExitedWithConflicts) {
56337
57020
  const classified = [];
56338
57021
  for (const file of conflictedFiles) {
@@ -56375,11 +57058,14 @@ async function executeMergeAttempt(params, aiTracker) {
56375
57058
  const escapedLog = commitLog.replace(/"/g, '\\"');
56376
57059
  const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
56377
57060
  const authorArg = getCommitAuthorArg(settings);
57061
+ const trailerArg = buildTaskIdTrailerArg(taskId);
56378
57062
  await execAsync2(
56379
- `git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"${authorArg}`,
57063
+ `git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"${trailerArg}${authorArg}`,
56380
57064
  { cwd: rootDir }
56381
57065
  );
56382
57066
  mergerLog.log(`${taskId}: committed after auto-resolving all conflicts`);
57067
+ } else {
57068
+ aiTracker.mergeWasEmpty = true;
56383
57069
  }
56384
57070
  if (testCommand || buildCommand2) {
56385
57071
  throwIfAborted(options.signal, taskId);
@@ -56404,6 +57090,7 @@ async function executeMergeAttempt(params, aiTracker) {
56404
57090
  ).trim() === "0";
56405
57091
  if (squashIsEmpty) {
56406
57092
  mergerLog.log(`${taskId}: squash merge staged nothing \u2014 already merged`);
57093
+ aiTracker.mergeWasEmpty = true;
56407
57094
  if (testCommand || buildCommand2) {
56408
57095
  throwIfAborted(options.signal, taskId);
56409
57096
  await runDeterministicVerification(
@@ -56431,6 +57118,7 @@ async function executeMergeAttempt(params, aiTracker) {
56431
57118
  ).trim() === "0";
56432
57119
  if (squashIsEmpty) {
56433
57120
  mergerLog.log(`${taskId}: squash merge staged nothing \u2014 already merged`);
57121
+ aiTracker.mergeWasEmpty = true;
56434
57122
  if (testCommand || buildCommand2) {
56435
57123
  throwIfAborted(options.signal, taskId);
56436
57124
  await runDeterministicVerification(
@@ -56528,12 +57216,12 @@ async function executeMergeAttempt(params, aiTracker) {
56528
57216
  throw error;
56529
57217
  }
56530
57218
  }
56531
- async function attemptWithTheirsStrategy(params) {
57219
+ async function attemptWithSideStrategy(params, side = "theirs", aiTracker) {
56532
57220
  const { rootDir, branch, commitLog, includeTaskId, taskId, store, settings, testCommand, buildCommand: buildCommand2, testSource, buildSource } = params;
56533
- mergerLog.log(`${taskId}: attempting merge with -X theirs strategy`);
57221
+ mergerLog.log(`${taskId}: attempting merge with -X ${side} strategy`);
56534
57222
  try {
56535
57223
  throwIfAborted(params.options.signal, taskId);
56536
- await execAsync2(`git merge -X theirs --squash "${branch}"`, {
57224
+ await execAsync2(`git merge -X ${side} --squash "${branch}"`, {
56537
57225
  cwd: rootDir
56538
57226
  });
56539
57227
  const conflictedOutput = execSync("git diff --name-only --diff-filter=U", {
@@ -56541,7 +57229,7 @@ async function attemptWithTheirsStrategy(params) {
56541
57229
  encoding: "utf-8"
56542
57230
  }).trim();
56543
57231
  if (conflictedOutput.length > 0) {
56544
- mergerLog.warn(`${taskId}: -X theirs left unresolved conflicts: ${conflictedOutput}`);
57232
+ mergerLog.warn(`${taskId}: -X ${side} left unresolved conflicts: ${conflictedOutput}`);
56545
57233
  return false;
56546
57234
  }
56547
57235
  const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
@@ -56549,6 +57237,7 @@ async function attemptWithTheirsStrategy(params) {
56549
57237
  encoding: "utf-8"
56550
57238
  }).trim();
56551
57239
  if (staged === "0") {
57240
+ if (aiTracker) aiTracker.mergeWasEmpty = true;
56552
57241
  if (testCommand || buildCommand2) {
56553
57242
  throwIfAborted(params.options.signal, taskId);
56554
57243
  await runDeterministicVerification(
@@ -56568,11 +57257,12 @@ async function attemptWithTheirsStrategy(params) {
56568
57257
  const escapedLog = commitLog.replace(/"/g, '\\"');
56569
57258
  const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
56570
57259
  const authorArg = getCommitAuthorArg(settings);
57260
+ const trailerArg = buildTaskIdTrailerArg(taskId);
56571
57261
  await execAsync2(
56572
- `git commit -m "${fallbackPrefix}: merge ${branch} (auto-resolved)" -m "${escapedLog}"${authorArg}`,
57262
+ `git commit -m "${fallbackPrefix}: merge ${branch} (auto-resolved)" -m "${escapedLog}"${trailerArg}${authorArg}`,
56573
57263
  { cwd: rootDir }
56574
57264
  );
56575
- mergerLog.log(`${taskId}: committed with -X theirs auto-resolution`);
57265
+ mergerLog.log(`${taskId}: committed with -X ${side} auto-resolution`);
56576
57266
  if (testCommand || buildCommand2) {
56577
57267
  throwIfAborted(params.options.signal, taskId);
56578
57268
  await runDeterministicVerification(
@@ -56591,7 +57281,7 @@ async function attemptWithTheirsStrategy(params) {
56591
57281
  if (error instanceof Error && error.name === "MergeAbortedError") {
56592
57282
  throw error;
56593
57283
  }
56594
- mergerLog.error(`${taskId}: -X theirs merge failed: ${error}`);
57284
+ mergerLog.error(`${taskId}: -X ${side} merge failed: ${error}`);
56595
57285
  return false;
56596
57286
  }
56597
57287
  }
@@ -56777,13 +57467,16 @@ async function runAiAgentForCommit(params) {
56777
57467
  const escapedLog = commitLog.replace(/"/g, '\\"');
56778
57468
  const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
56779
57469
  const authorArg2 = getCommitAuthorArg(settings);
57470
+ const trailerArg = buildTaskIdTrailerArg(taskId);
56780
57471
  await execAsync2(
56781
- `git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"${authorArg2}`,
57472
+ `git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"${trailerArg}${authorArg2}`,
56782
57473
  { cwd: rootDir }
56783
57474
  );
56784
57475
  } else {
56785
57476
  throw new Error(`Agent did not commit and did not report build failure for ${taskId}`);
56786
57477
  }
57478
+ } else {
57479
+ await ensureTaskIdTrailerOnHead(rootDir, taskId);
56787
57480
  }
56788
57481
  return { success: true };
56789
57482
  } catch (err) {
@@ -57011,8 +57704,9 @@ If issues are found that need attention, describe them clearly.`;
57011
57704
  agent: "merger"
57012
57705
  });
57013
57706
  try {
57014
- const stepProvider = workflowStep.modelProvider || settings.defaultProvider;
57015
- const stepModelId = workflowStep.modelId || settings.defaultModelId;
57707
+ const defaultModel = resolveProjectDefaultModel(settings);
57708
+ const stepProvider = workflowStep.modelProvider || defaultModel.provider;
57709
+ const stepModelId = workflowStep.modelId || defaultModel.modelId;
57016
57710
  const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
57017
57711
  let postMergeInstructions = "";
57018
57712
  if (mergeOptions.agentStore) {
@@ -57095,12 +57789,11 @@ async function completeTask(store, taskId, result) {
57095
57789
  result.task = task;
57096
57790
  store.emit("task:merged", result);
57097
57791
  }
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;
57792
+ 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
57793
  var init_merger = __esm({
57100
57794
  "../engine/src/merger.ts"() {
57101
57795
  "use strict";
57102
57796
  init_src();
57103
- init_src();
57104
57797
  init_pi();
57105
57798
  init_session_token_usage();
57106
57799
  init_agent_session_helpers();
@@ -57170,6 +57863,7 @@ var init_merger = __esm({
57170
57863
  this.name = "MergeAbortedError";
57171
57864
  }
57172
57865
  };
57866
+ FUSION_TASK_ID_TRAILER_KEY = "Fusion-Task-Id";
57173
57867
  }
57174
57868
  });
57175
57869
 
@@ -59138,7 +59832,10 @@ Lint, tests, and typecheck are also hard quality gates:
59138
59832
  executorLog.log(`[event:task:moved] ${task.id}: ${from} \u2192 ${to}`);
59139
59833
  if (to === "in-progress") {
59140
59834
  executorLog.log(`[event:task:moved] Initiating execute() for ${task.id}`);
59141
- this.execute(task).catch(
59835
+ void (async () => {
59836
+ const taskForExecution = await this.resetMergeStateIfNeeded(task, from);
59837
+ await this.execute(taskForExecution);
59838
+ })().catch(
59142
59839
  (err) => executorLog.error(`Failed to start ${task.id}:`, err)
59143
59840
  );
59144
59841
  } else if (from === "in-progress") {
@@ -59416,6 +60113,59 @@ Lint, tests, and typecheck are also hard quality gates:
59416
60113
  if (task.steps.length === 0) return false;
59417
60114
  return task.steps.every((s) => s.status === "done" || s.status === "skipped");
59418
60115
  }
60116
+ async resetMergeStateIfNeeded(task, from) {
60117
+ if (from !== "in-review" && from !== "done") {
60118
+ return task;
60119
+ }
60120
+ const hasMergeEvidence = Boolean(task.mergeDetails) || (task.mergeRetries ?? 0) > 0 || (task.verificationFailureCount ?? 0) > 0 || task.status === "merging" || task.status === "merging-pr";
60121
+ if (!hasMergeEvidence) {
60122
+ return task;
60123
+ }
60124
+ return this.cleanupMergeStateForReverification(
60125
+ task,
60126
+ `Task returned to in-progress from ${from} column \u2014 resetting verification steps and merge state for re-verification`
60127
+ );
60128
+ }
60129
+ async cleanupMergeStateForReverification(task, logMessage) {
60130
+ await this.store.updateTask(task.id, {
60131
+ mergeDetails: null,
60132
+ mergeRetries: 0,
60133
+ verificationFailureCount: 0,
60134
+ workflowStepResults: []
60135
+ });
60136
+ const refreshedTask = await this.store.getTask(task.id);
60137
+ const steps = refreshedTask.steps ?? [];
60138
+ if (steps.length > 0) {
60139
+ const allStepsComplete = this.isTaskWorkComplete(refreshedTask);
60140
+ if (allStepsComplete) {
60141
+ await this.reopenLastStepForRevision(task.id, refreshedTask);
60142
+ } else {
60143
+ const resetIndexes = /* @__PURE__ */ new Set();
60144
+ for (let i = 0; i < steps.length; i++) {
60145
+ const name = steps[i].name.toLowerCase();
60146
+ if (/testing|verification/.test(name) || /documentation|delivery/.test(name)) {
60147
+ resetIndexes.add(i);
60148
+ }
60149
+ }
60150
+ if (resetIndexes.size === 0) {
60151
+ const reopened = await this.reopenLastStepForRevision(task.id, refreshedTask);
60152
+ if (reopened) {
60153
+ resetIndexes.add(reopened.index);
60154
+ }
60155
+ } else {
60156
+ for (const index of resetIndexes) {
60157
+ if (steps[index].status !== "pending") {
60158
+ await this.store.updateStep(task.id, index, "pending");
60159
+ }
60160
+ }
60161
+ const earliestIndex = Math.min(...Array.from(resetIndexes));
60162
+ await this.store.updateTask(task.id, { currentStep: earliestIndex });
60163
+ }
60164
+ }
60165
+ }
60166
+ await this.store.logEntry(task.id, logMessage, void 0, this.currentRunContext);
60167
+ return this.store.getTask(task.id);
60168
+ }
59419
60169
  isNoProgressNoTaskDoneFailure(task) {
59420
60170
  return task.status === "failed" && task.error?.includes("without calling fn_task_done") === true && task.steps.every((step) => step.status === "pending");
59421
60171
  }
@@ -59637,7 +60387,7 @@ Lint, tests, and typecheck are also hard quality gates:
59637
60387
  if (inProgress.length === 0) return;
59638
60388
  executorLog.log(`Found ${inProgress.length} orphaned in-progress task(s)`);
59639
60389
  for (const task of inProgress) {
59640
- if (this.isTaskWorkComplete(task)) {
60390
+ if (this.isTaskWorkComplete(task) && !task.mergeDetails) {
59641
60391
  if (this.recoveringCompleted.has(task.id)) {
59642
60392
  executorLog.log(`${task.id} completed-task recovery already running - skipping duplicate startup recovery`);
59643
60393
  continue;
@@ -59772,6 +60522,13 @@ Lint, tests, and typecheck are also hard quality gates:
59772
60522
  return;
59773
60523
  }
59774
60524
  }
60525
+ if (task.column === "in-progress" && task.mergeDetails) {
60526
+ executorLog.warn(`${task.id}: stale mergeDetails found while executing in-progress task \u2014 resetting merge state before continuing`);
60527
+ task = await this.cleanupMergeStateForReverification(
60528
+ task,
60529
+ "Executor detected stale merge state while task was in-progress \u2014 reset verification steps and merge metadata before resuming"
60530
+ );
60531
+ }
59775
60532
  if (task.column === "in-progress" && !task.worktree) {
59776
60533
  executorLog.error(
59777
60534
  `${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 +62533,19 @@ and show an appropriate message to the user.\`
61776
62533
  this.options.onAgentTool?.(taskId, toolName);
61777
62534
  }
61778
62535
  });
61779
- try {
61780
- const stepProvider = workflowStep.modelProvider || settings.defaultProvider;
61781
- const stepModelId = workflowStep.modelId || settings.defaultModelId;
61782
- const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
62536
+ const defaultModel = resolveProjectDefaultModel(settings);
62537
+ const primaryProvider = workflowStep.modelProvider || defaultModel.provider;
62538
+ const primaryModelId = workflowStep.modelId || defaultModel.modelId;
62539
+ const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
62540
+ const fallbackCandidates = [
62541
+ { provider: settings.validatorFallbackProvider, modelId: settings.validatorFallbackModelId, label: "validatorFallback" },
62542
+ { provider: settings.fallbackProvider, modelId: settings.fallbackModelId, label: "globalFallback" }
62543
+ ];
62544
+ const fallback = fallbackCandidates.find(
62545
+ (c) => c.provider && c.modelId && (c.provider !== primaryProvider || c.modelId !== primaryModelId)
62546
+ );
62547
+ const timeoutMs = Math.max(6e4, settings.workflowStepTimeoutMs ?? 36e4);
62548
+ const runOnce = async (provider, modelId, attemptLabel) => {
61783
62549
  const stepInstructions = await this.resolveInstructionsForRole("executor");
61784
62550
  const stepSystemPrompt = buildSystemPromptWithInstructions(systemPrompt, stepInstructions);
61785
62551
  const skillContext = await buildSessionSkillContext({
@@ -61797,16 +62563,19 @@ and show an appropriate message to the user.\`
61797
62563
  cwd: worktreePath,
61798
62564
  systemPrompt: stepSystemPrompt,
61799
62565
  tools: toolMode,
61800
- defaultProvider: stepProvider,
61801
- defaultModelId: stepModelId,
62566
+ defaultProvider: provider,
62567
+ defaultModelId: modelId,
61802
62568
  fallbackProvider: settings.fallbackProvider,
61803
62569
  fallbackModelId: settings.fallbackModelId,
61804
62570
  defaultThinkingLevel: settings.defaultThinkingLevel,
61805
62571
  // Skill selection: use assigned agent skills if available, otherwise role fallback
61806
62572
  ...skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}
61807
62573
  });
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)" : ""}`);
62574
+ executorLog.log(`${task.id}: workflow step '${workflowStep.name}' using model ${describeModel(session)}${useOverride && attemptLabel === "primary" ? " (workflow step override)" : ""}${attemptLabel === "fallback" ? " (fallback after timeout)" : ""}`);
62575
+ await this.store.logEntry(
62576
+ task.id,
62577
+ `Workflow step '${workflowStep.name}' using model: ${describeModel(session)}${useOverride && attemptLabel === "primary" ? " (workflow step override)" : ""}${attemptLabel === "fallback" ? " (fallback after timeout)" : ""}`
62578
+ );
61810
62579
  let output = "";
61811
62580
  session.subscribe((event) => {
61812
62581
  if (event.type === "message_update") {
@@ -61825,33 +62594,75 @@ and show an appropriate message to the user.\`
61825
62594
  agentLogger.onToolEnd(event.toolName, event.isError, event.result);
61826
62595
  }
61827
62596
  });
61828
- await promptWithFallback(
61829
- session,
61830
- `Execute the workflow step "${workflowStep.name}" for task ${task.id}.
62597
+ let timedOut = false;
62598
+ let timeoutHandle;
62599
+ const timeoutPromise = new Promise((resolveTimeout) => {
62600
+ timeoutHandle = setTimeout(() => {
62601
+ timedOut = true;
62602
+ resolveTimeout("timeout");
62603
+ }, timeoutMs);
62604
+ });
62605
+ try {
62606
+ const promptPromise = promptWithFallback(
62607
+ session,
62608
+ `Execute the workflow step "${workflowStep.name}" for task ${task.id}.
61831
62609
 
61832
62610
  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
- };
62611
+ );
62612
+ const outcome = await Promise.race([
62613
+ promptPromise.then(() => "completed"),
62614
+ timeoutPromise
62615
+ ]);
62616
+ if (outcome === "timeout") {
62617
+ executorLog.warn(`${task.id}: workflow step '${workflowStep.name}' (${attemptLabel}) timed out after ${timeoutMs}ms \u2014 disposing session`);
62618
+ await this.store.logEntry(
62619
+ task.id,
62620
+ `Workflow step '${workflowStep.name}' ${attemptLabel === "primary" ? "primary" : "fallback"} model timed out after ${Math.round(timeoutMs / 1e3)}s \u2014 aborting session`
62621
+ );
62622
+ try {
62623
+ session.dispose();
62624
+ } catch {
62625
+ }
62626
+ await agentLogger.flush();
62627
+ return { success: false, error: `workflow step timed out after ${timeoutMs}ms`, timedOut: true };
62628
+ }
62629
+ checkSessionError(session);
62630
+ await accumulateSessionTokenUsage(this.store, task.id, session);
62631
+ session.dispose();
62632
+ await agentLogger.flush();
62633
+ const trimmedOutput = output.trim();
62634
+ const revisionMatch = trimmedOutput.match(/^REQUEST REVISION\s*\n*/i);
62635
+ if (revisionMatch) {
62636
+ const feedbackStart = revisionMatch[0].length;
62637
+ const feedback = trimmedOutput.slice(feedbackStart).trim();
62638
+ return { success: false, revisionRequested: true, output: feedback };
62639
+ }
62640
+ return { success: true, output };
62641
+ } catch (err) {
62642
+ await agentLogger.flush();
62643
+ try {
62644
+ session.dispose();
62645
+ } catch {
62646
+ }
62647
+ const errorMessage = err instanceof Error ? err.message : String(err);
62648
+ return { success: false, error: errorMessage };
62649
+ } finally {
62650
+ if (timeoutHandle) clearTimeout(timeoutHandle);
62651
+ void timedOut;
61848
62652
  }
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 };
62653
+ };
62654
+ const primaryOutcome = await runOnce(primaryProvider, primaryModelId, "primary");
62655
+ if (!primaryOutcome.timedOut) return primaryOutcome;
62656
+ if (!fallback) {
62657
+ executorLog.warn(`${task.id}: workflow step '${workflowStep.name}' timed out and no fallback model is configured`);
62658
+ await this.store.logEntry(
62659
+ task.id,
62660
+ `Workflow step '${workflowStep.name}' timed out \u2014 no fallback model configured (set settings.validatorFallbackProvider/Id or fallbackProvider/Id)`
62661
+ );
62662
+ return primaryOutcome;
61854
62663
  }
62664
+ executorLog.log(`${task.id}: retrying workflow step '${workflowStep.name}' with fallback ${fallback.provider}/${fallback.modelId} (label=${fallback.label})`);
62665
+ return runOnce(fallback.provider, fallback.modelId, "fallback");
61855
62666
  }
61856
62667
  MAX_WORKTREE_RETRIES = 3;
61857
62668
  WORKTREE_RETRY_DELAYS = [100, 500, 1e3];
@@ -61879,7 +62690,13 @@ Review the work done in this worktree and evaluate it against the criteria in yo
61879
62690
  }
61880
62691
  for (let attempt = 0; attempt < this.MAX_WORKTREE_RETRIES; attempt++) {
61881
62692
  try {
61882
- return await this.tryCreateWorktree(branch, currentPath, taskId, resolvedStartPoint, attempt);
62693
+ const result = await this.tryCreateWorktree(branch, currentPath, taskId, resolvedStartPoint, attempt);
62694
+ await this.rebaseNewWorktreeOntoRemote(result.path, result.branch, taskId).catch((err) => {
62695
+ executorLog.warn(
62696
+ `Post-create worktree rebase failed for ${taskId} (continuing): ${err instanceof Error ? err.message : String(err)}`
62697
+ );
62698
+ });
62699
+ return result;
61883
62700
  } catch (error) {
61884
62701
  const errorMessage = error instanceof Error ? error.message : String(error);
61885
62702
  const isLastAttempt = attempt === this.MAX_WORKTREE_RETRIES - 1;
@@ -61900,6 +62717,84 @@ Review the work done in this worktree and evaluate it against the criteria in yo
61900
62717
  }
61901
62718
  throw new Error("Unexpected exit from worktree creation retry loop");
61902
62719
  }
62720
+ quoteShellArg(value) {
62721
+ return `'${value.replace(/'/g, "'\\''")}'`;
62722
+ }
62723
+ /**
62724
+ * After creating a fresh task worktree, fetch the configured remote and
62725
+ * rebase the task branch onto `<remote>/<defaultBranch>`. The result is a
62726
+ * branch that contains origin's tip plus any local main commits, so the
62727
+ * eventual merge has fewer surprises and the executor sees the freshest
62728
+ * code its peers/CI may have published.
62729
+ *
62730
+ * No-op when `worktreeRebaseBeforeMerge` is disabled, no remote is
62731
+ * configured/resolvable, or the rebase produces conflicts (we abort and
62732
+ * leave the worktree as-is so the executor can still run).
62733
+ */
62734
+ async rebaseNewWorktreeOntoRemote(worktreePath, branch, taskId) {
62735
+ let settings;
62736
+ try {
62737
+ settings = await this.store.getSettings();
62738
+ } catch {
62739
+ return;
62740
+ }
62741
+ if (settings.worktreeRebaseBeforeMerge === false) return;
62742
+ let remote = settings.worktreeRebaseRemote?.trim() || "";
62743
+ if (!remote) {
62744
+ try {
62745
+ const { stdout } = await execAsync5("git remote", { cwd: this.rootDir });
62746
+ const remotes = stdout.split("\n").map((s) => s.trim()).filter(Boolean);
62747
+ if (remotes.includes("origin")) remote = "origin";
62748
+ else if (remotes.length === 1) remote = remotes[0];
62749
+ } catch {
62750
+ }
62751
+ }
62752
+ if (!remote) return;
62753
+ let defaultBranch = "";
62754
+ try {
62755
+ const { stdout } = await execAsync5(`git rev-parse --abbrev-ref ${remote}/HEAD`, { cwd: this.rootDir });
62756
+ defaultBranch = stdout.trim().replace(new RegExp(`^${remote}/`), "");
62757
+ } catch {
62758
+ }
62759
+ if (!defaultBranch) {
62760
+ try {
62761
+ const { stdout } = await execAsync5("git rev-parse --abbrev-ref HEAD", { cwd: this.rootDir });
62762
+ defaultBranch = stdout.trim();
62763
+ } catch {
62764
+ return;
62765
+ }
62766
+ }
62767
+ if (!defaultBranch || defaultBranch === "HEAD") return;
62768
+ const remoteRef = `${remote}/${defaultBranch}`;
62769
+ try {
62770
+ await execAsync5(`git fetch ${this.quoteShellArg(remote)} ${this.quoteShellArg(defaultBranch)}`, { cwd: this.rootDir });
62771
+ } catch (err) {
62772
+ executorLog.warn(
62773
+ `Worktree rebase: fetch ${remote} ${defaultBranch} failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`
62774
+ );
62775
+ return;
62776
+ }
62777
+ try {
62778
+ await execAsync5(`git rebase ${this.quoteShellArg(remoteRef)}`, { cwd: worktreePath });
62779
+ await this.store.logEntry(
62780
+ taskId,
62781
+ `Rebased new worktree branch ${branch} onto ${remoteRef}`
62782
+ );
62783
+ } catch (rebaseErr) {
62784
+ const msg = rebaseErr instanceof Error ? rebaseErr.message : String(rebaseErr);
62785
+ executorLog.warn(
62786
+ `Worktree rebase: rebase onto ${remoteRef} failed for ${taskId} \u2014 aborting and leaving local base intact: ${msg}`
62787
+ );
62788
+ try {
62789
+ await execAsync5("git rebase --abort", { cwd: worktreePath });
62790
+ } catch {
62791
+ }
62792
+ await this.store.logEntry(
62793
+ taskId,
62794
+ `Could not rebase new worktree onto ${remoteRef} \u2014 kept local base. The merge-time rebase will retry with conflict resolution.`
62795
+ );
62796
+ }
62797
+ }
61903
62798
  /**
61904
62799
  * Resolve a stored baseBranch to a concrete commit SHA.
61905
62800
  *
@@ -62631,6 +63526,7 @@ Child agent: ${agent.id} (${name})`;
62631
63526
  });
62632
63527
  const parentAgent = childTask.assignedAgentId ? await this.options.agentStore.getAgent(childTask.assignedAgentId).catch(() => null) : null;
62633
63528
  const childRuntimeHint = extractRuntimeHint(agent.runtimeConfig) ?? extractRuntimeHint(parentAgent?.runtimeConfig);
63529
+ const { provider: childExecutorProvider, modelId: childExecutorModelId } = resolveExecutorModelPair2(void 0, void 0, settings);
62634
63530
  const { session: childSession } = await createResolvedAgentSession({
62635
63531
  sessionPurpose: "executor",
62636
63532
  runtimeHint: childRuntimeHint,
@@ -62638,8 +63534,8 @@ Child agent: ${agent.id} (${name})`;
62638
63534
  cwd: childWorktreePath,
62639
63535
  systemPrompt: childSystemPrompt,
62640
63536
  tools: "coding",
62641
- defaultProvider: settings.defaultProvider,
62642
- defaultModelId: settings.defaultModelId,
63537
+ defaultProvider: childExecutorProvider,
63538
+ defaultModelId: childExecutorModelId,
62643
63539
  fallbackProvider: settings.fallbackProvider,
62644
63540
  fallbackModelId: settings.fallbackModelId,
62645
63541
  // Skill selection: use assigned agent skills if available, otherwise role fallback
@@ -62735,6 +63631,25 @@ var init_mission_feature_sync = __esm({
62735
63631
  }
62736
63632
  });
62737
63633
 
63634
+ // ../engine/src/effective-node.ts
63635
+ function isSetNodeId(nodeId) {
63636
+ return typeof nodeId === "string" && nodeId.trim().length > 0;
63637
+ }
63638
+ function resolveEffectiveNode(task, settings) {
63639
+ if (isSetNodeId(task.nodeId)) {
63640
+ return { nodeId: task.nodeId, source: "task-override" };
63641
+ }
63642
+ if (isSetNodeId(settings.defaultNodeId)) {
63643
+ return { nodeId: settings.defaultNodeId, source: "project-default" };
63644
+ }
63645
+ return { nodeId: void 0, source: "local" };
63646
+ }
63647
+ var init_effective_node = __esm({
63648
+ "../engine/src/effective-node.ts"() {
63649
+ "use strict";
63650
+ }
63651
+ });
63652
+
62738
63653
  // ../engine/src/scheduler.ts
62739
63654
  import { existsSync as existsSync26 } from "node:fs";
62740
63655
  import { readFile as readFile15 } from "node:fs/promises";
@@ -62793,6 +63708,7 @@ var init_scheduler = __esm({
62793
63708
  init_logger2();
62794
63709
  init_mission_feature_sync();
62795
63710
  init_spec_staleness();
63711
+ init_effective_node();
62796
63712
  Scheduler = class {
62797
63713
  /**
62798
63714
  * Async listener guard convention:
@@ -63216,14 +64132,19 @@ var init_scheduler = __esm({
63216
64132
  schedulerLog.log(`Task ${task.id} is paused \u2014 skipping dispatch`);
63217
64133
  continue;
63218
64134
  }
64135
+ const effectiveNode = resolveEffectiveNode(freshTask, settings);
64136
+ schedulerLog.log(`Task ${task.id} routed to node=${effectiveNode.nodeId ?? "local"} (source=${effectiveNode.source})`);
63219
64137
  schedulerLog.log(`Starting ${task.id}: ${task.title || task.id} (deps satisfied)`);
63220
64138
  await this.store.updateTask(task.id, {
63221
64139
  status: null,
63222
64140
  blockedBy: null,
63223
64141
  baseBranch: baseBranch ?? void 0,
63224
- worktree: plannedWorktree
64142
+ worktree: plannedWorktree,
64143
+ effectiveNodeId: effectiveNode.nodeId ?? null,
64144
+ effectiveNodeSource: effectiveNode.source
63225
64145
  });
63226
64146
  await this.store.moveTask(task.id, "in-progress");
64147
+ await this.store.logEntry(task.id, `Node routing resolved: ${effectiveNode.nodeId ?? "local"} (source: ${effectiveNode.source})`);
63227
64148
  this.options.onSchedule?.(task);
63228
64149
  started++;
63229
64150
  if (settings.groupOverlappingFiles) {
@@ -65679,6 +66600,453 @@ Please review the PR comments and address any remaining issues.`;
65679
66600
  }
65680
66601
  });
65681
66602
 
66603
+ // ../engine/src/notification/ntfy-provider.ts
66604
+ var SUPPORTED_EVENTS, NtfyNotificationProvider;
66605
+ var init_ntfy_provider = __esm({
66606
+ "../engine/src/notification/ntfy-provider.ts"() {
66607
+ "use strict";
66608
+ init_notifier();
66609
+ SUPPORTED_EVENTS = /* @__PURE__ */ new Set([
66610
+ "in-review",
66611
+ "merged",
66612
+ "failed",
66613
+ "awaiting-approval",
66614
+ "awaiting-user-review",
66615
+ "planning-awaiting-input"
66616
+ ]);
66617
+ NtfyNotificationProvider = class {
66618
+ config;
66619
+ abortController = null;
66620
+ getProviderId() {
66621
+ return "ntfy";
66622
+ }
66623
+ async initialize(config) {
66624
+ if (typeof config.topic !== "string" || config.topic.trim() === "") {
66625
+ return;
66626
+ }
66627
+ this.config = config;
66628
+ this.config.events = resolveNtfyEvents(this.config.events);
66629
+ this.abortController = new AbortController();
66630
+ }
66631
+ async shutdown() {
66632
+ this.abortController?.abort();
66633
+ this.abortController = null;
66634
+ }
66635
+ isEventSupported(event) {
66636
+ if (!SUPPORTED_EVENTS.has(event)) {
66637
+ return false;
66638
+ }
66639
+ const enabledEvents = this.config?.events ?? [...DEFAULT_NTFY_EVENTS];
66640
+ return enabledEvents.includes(event);
66641
+ }
66642
+ async sendNotification(event, payload) {
66643
+ if (!this.config?.topic) {
66644
+ return { success: false, providerId: this.getProviderId(), error: "ntfy topic not configured" };
66645
+ }
66646
+ if (!this.isEventSupported(event)) {
66647
+ return {
66648
+ success: false,
66649
+ providerId: this.getProviderId(),
66650
+ error: `unsupported event: ${event}`
66651
+ };
66652
+ }
66653
+ const taskLike = {
66654
+ id: payload.taskId,
66655
+ title: payload.taskTitle,
66656
+ description: payload.taskDescription ?? ""
66657
+ };
66658
+ const identifier = formatTaskIdentifier(taskLike);
66659
+ const clickUrl = buildNtfyClickUrl({
66660
+ dashboardHost: this.config.dashboardHost,
66661
+ projectId: this.config.projectId,
66662
+ taskId: payload.taskId
66663
+ });
66664
+ const contentByEvent = {
66665
+ "in-review": {
66666
+ title: `Task ${payload.taskId} completed`,
66667
+ message: `Task "${identifier}" is ready for review`,
66668
+ priority: "default"
66669
+ },
66670
+ merged: {
66671
+ title: `Task ${payload.taskId} merged`,
66672
+ message: `Task "${identifier}" has been merged to main`,
66673
+ priority: "default"
66674
+ },
66675
+ failed: {
66676
+ title: `Task ${payload.taskId} failed`,
66677
+ message: `Task "${identifier}" has failed and needs attention`,
66678
+ priority: "high"
66679
+ },
66680
+ "awaiting-approval": {
66681
+ title: `Plan needs approval for ${payload.taskId}`,
66682
+ message: `Task "${identifier}" needs your approval before it can proceed`,
66683
+ priority: "high"
66684
+ },
66685
+ "awaiting-user-review": {
66686
+ title: `User review needed for ${payload.taskId}`,
66687
+ message: `Task "${identifier}" needs human review before it can proceed`,
66688
+ priority: "high"
66689
+ },
66690
+ "planning-awaiting-input": {
66691
+ title: `Planning input needed for ${payload.taskId}`,
66692
+ message: `Task "${identifier}" is awaiting your input during planning`,
66693
+ priority: "high"
66694
+ }
66695
+ };
66696
+ const content = contentByEvent[event];
66697
+ await sendNtfyNotification({
66698
+ ntfyBaseUrl: this.config.ntfyBaseUrl,
66699
+ topic: this.config.topic,
66700
+ title: content.title,
66701
+ message: content.message,
66702
+ priority: content.priority,
66703
+ clickUrl,
66704
+ signal: this.abortController?.signal
66705
+ });
66706
+ return { success: true, providerId: this.getProviderId() };
66707
+ }
66708
+ };
66709
+ }
66710
+ });
66711
+
66712
+ // ../engine/src/notification/webhook-provider.ts
66713
+ var WebhookNotificationProvider;
66714
+ var init_webhook_provider = __esm({
66715
+ "../engine/src/notification/webhook-provider.ts"() {
66716
+ "use strict";
66717
+ init_logger2();
66718
+ WebhookNotificationProvider = class {
66719
+ config = null;
66720
+ abortController = null;
66721
+ getProviderId() {
66722
+ return "webhook";
66723
+ }
66724
+ async initialize(config) {
66725
+ const webhookUrl = typeof config.webhookUrl === "string" ? config.webhookUrl.trim() : "";
66726
+ if (!webhookUrl) {
66727
+ throw new Error("webhookUrl is required");
66728
+ }
66729
+ let parsedUrl;
66730
+ try {
66731
+ parsedUrl = new URL(webhookUrl);
66732
+ } catch {
66733
+ throw new Error("webhookUrl must be a valid URL");
66734
+ }
66735
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
66736
+ throw new Error("webhookUrl must use http:// or https://");
66737
+ }
66738
+ const webhookFormat = config.webhookFormat === "slack" || config.webhookFormat === "discord" || config.webhookFormat === "generic" ? config.webhookFormat : "generic";
66739
+ this.config = {
66740
+ webhookUrl,
66741
+ webhookFormat,
66742
+ events: Array.isArray(config.events) ? config.events.filter((event) => typeof event === "string") : []
66743
+ };
66744
+ this.abortController?.abort();
66745
+ this.abortController = new AbortController();
66746
+ }
66747
+ async shutdown() {
66748
+ this.abortController?.abort();
66749
+ this.abortController = null;
66750
+ this.config = null;
66751
+ }
66752
+ isEventSupported(event) {
66753
+ if (!this.config?.events || this.config.events.length === 0) {
66754
+ return true;
66755
+ }
66756
+ return this.config.events.includes(event);
66757
+ }
66758
+ async sendNotification(event, payload) {
66759
+ if (!this.config) {
66760
+ return { success: false, providerId: this.getProviderId(), error: "Not initialized" };
66761
+ }
66762
+ if (!this.isEventSupported(event)) {
66763
+ return {
66764
+ success: false,
66765
+ providerId: this.getProviderId(),
66766
+ error: `unsupported event: ${event}`
66767
+ };
66768
+ }
66769
+ try {
66770
+ const message = this.formatMessage(event, payload);
66771
+ const body = this.formatPayload(payload, message);
66772
+ const response = await fetch(this.config.webhookUrl, {
66773
+ method: "POST",
66774
+ headers: {
66775
+ "Content-Type": "application/json"
66776
+ },
66777
+ body: JSON.stringify(body),
66778
+ signal: this.abortController?.signal
66779
+ });
66780
+ if (!response.ok) {
66781
+ const error = `Webhook notification failed: ${response.status} ${response.statusText}`;
66782
+ schedulerLog.log(error);
66783
+ return {
66784
+ success: false,
66785
+ providerId: this.getProviderId(),
66786
+ error
66787
+ };
66788
+ }
66789
+ return { success: true, providerId: this.getProviderId() };
66790
+ } catch (error) {
66791
+ const message = error instanceof Error ? error.message : String(error);
66792
+ schedulerLog.log(`Failed to send webhook notification: ${message}`);
66793
+ return {
66794
+ success: false,
66795
+ providerId: this.getProviderId(),
66796
+ error: message
66797
+ };
66798
+ }
66799
+ }
66800
+ formatMessage(event, payload) {
66801
+ const identifier = this.formatTaskIdentifier(payload);
66802
+ switch (event) {
66803
+ case "in-review":
66804
+ return `Task "${identifier}" is ready for review`;
66805
+ case "merged":
66806
+ return `Task "${identifier}" has been merged to main`;
66807
+ case "failed":
66808
+ return `Task "${identifier}" has failed and needs attention`;
66809
+ case "awaiting-approval":
66810
+ return `Task "${identifier}" needs your approval before it can proceed`;
66811
+ case "awaiting-user-review":
66812
+ return `Task "${identifier}" needs human review before it can proceed`;
66813
+ case "planning-awaiting-input":
66814
+ return `Task "${identifier}" is awaiting your input during planning`;
66815
+ case "gridlock":
66816
+ return "Pipeline gridlocked";
66817
+ default:
66818
+ return `Event "${event}" for task ${identifier}`;
66819
+ }
66820
+ }
66821
+ formatTaskIdentifier(payload) {
66822
+ if (payload.taskTitle?.trim()) {
66823
+ return payload.taskTitle;
66824
+ }
66825
+ const description = payload.taskDescription ?? "";
66826
+ const snippet = description.length > 200 ? `${description.slice(0, 200)}...` : description;
66827
+ return `${payload.taskId}: ${snippet}`;
66828
+ }
66829
+ formatPayload(payload, message) {
66830
+ if (!this.config) {
66831
+ return {};
66832
+ }
66833
+ if (this.config.webhookFormat === "slack") {
66834
+ return { text: message };
66835
+ }
66836
+ if (this.config.webhookFormat === "discord") {
66837
+ return { content: message };
66838
+ }
66839
+ return {
66840
+ event: payload.event,
66841
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
66842
+ task: {
66843
+ id: payload.taskId,
66844
+ title: payload.taskTitle
66845
+ },
66846
+ metadata: payload.metadata
66847
+ };
66848
+ }
66849
+ };
66850
+ }
66851
+ });
66852
+
66853
+ // ../engine/src/notification/notification-service.ts
66854
+ var NotificationService;
66855
+ var init_notification_service = __esm({
66856
+ "../engine/src/notification/notification-service.ts"() {
66857
+ "use strict";
66858
+ init_src();
66859
+ init_notifier();
66860
+ init_logger2();
66861
+ init_ntfy_provider();
66862
+ init_webhook_provider();
66863
+ NotificationService = class {
66864
+ constructor(store, options = {}) {
66865
+ this.store = store;
66866
+ this.options = options;
66867
+ }
66868
+ dispatcher = new NotificationDispatcher();
66869
+ notifiedEvents = /* @__PURE__ */ new Set();
66870
+ started = false;
66871
+ notificationsEnabled = false;
66872
+ ntfyProvider;
66873
+ webhookProvider;
66874
+ registerProvider(provider) {
66875
+ this.dispatcher.registerProvider(provider);
66876
+ }
66877
+ async start() {
66878
+ if (this.started) {
66879
+ return;
66880
+ }
66881
+ const settings = await this.store.getSettings();
66882
+ this.setNotificationsEnabledFromSettings(settings);
66883
+ await this.syncNtfyProvider(settings);
66884
+ await this.syncWebhookProvider(settings);
66885
+ await this.dispatcher.initializeAll();
66886
+ this.store.on("task:moved", this.handleTaskMoved);
66887
+ this.store.on("task:updated", this.handleTaskUpdated);
66888
+ this.store.on("task:merged", this.handleTaskMerged);
66889
+ this.store.on("settings:updated", this.handleSettingsUpdated);
66890
+ this.started = true;
66891
+ schedulerLog.log("NotificationService started");
66892
+ }
66893
+ async stop() {
66894
+ if (!this.started) {
66895
+ return;
66896
+ }
66897
+ if (typeof this.store.off === "function") {
66898
+ this.store.off("task:moved", this.handleTaskMoved);
66899
+ this.store.off("task:updated", this.handleTaskUpdated);
66900
+ this.store.off("task:merged", this.handleTaskMerged);
66901
+ this.store.off("settings:updated", this.handleSettingsUpdated);
66902
+ }
66903
+ await this.dispatcher.shutdownAll();
66904
+ this.started = false;
66905
+ schedulerLog.log("NotificationService stopped");
66906
+ }
66907
+ handleTaskMoved = (data) => {
66908
+ if (!this.notificationsEnabled || data.to !== "in-review") {
66909
+ return;
66910
+ }
66911
+ const payload = this.createTaskPayload(data.task, "in-review");
66912
+ this.maybeNotify(data.task.id, "in-review", payload);
66913
+ };
66914
+ handleTaskUpdated = (task) => {
66915
+ if (!this.notificationsEnabled) {
66916
+ return;
66917
+ }
66918
+ if (task.status === "failed") {
66919
+ this.maybeNotify(task.id, "failed", this.createTaskPayload(task, "failed"));
66920
+ }
66921
+ if (task.status === "awaiting-approval") {
66922
+ this.maybeNotify(
66923
+ task.id,
66924
+ "awaiting-approval",
66925
+ this.createTaskPayload(task, "awaiting-approval")
66926
+ );
66927
+ }
66928
+ if (task.status === "awaiting-user-review") {
66929
+ this.maybeNotify(
66930
+ task.id,
66931
+ "awaiting-user-review",
66932
+ this.createTaskPayload(task, "awaiting-user-review")
66933
+ );
66934
+ }
66935
+ };
66936
+ handleTaskMerged = (result) => {
66937
+ if (!this.notificationsEnabled || !result.merged) {
66938
+ return;
66939
+ }
66940
+ this.maybeNotify(
66941
+ result.task.id,
66942
+ "merged",
66943
+ this.createTaskPayload(result.task, "merged")
66944
+ );
66945
+ };
66946
+ handleSettingsUpdated = async (data) => {
66947
+ const { settings, previous } = data;
66948
+ this.setNotificationsEnabledFromSettings(settings);
66949
+ 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)) {
66950
+ const wasEnabled = Boolean(previous.ntfyEnabled && previous.ntfyTopic);
66951
+ const isEnabled = Boolean(settings.ntfyEnabled && settings.ntfyTopic);
66952
+ await this.syncNtfyProvider(settings);
66953
+ if (isEnabled && !wasEnabled) {
66954
+ schedulerLog.log("NotificationService ntfy enabled");
66955
+ } else if (!isEnabled && wasEnabled) {
66956
+ schedulerLog.log("NotificationService ntfy disabled");
66957
+ } else if (settings.ntfyTopic !== previous.ntfyTopic) {
66958
+ schedulerLog.log("NotificationService ntfy topic updated");
66959
+ } else if (settings.ntfyBaseUrl !== previous.ntfyBaseUrl) {
66960
+ schedulerLog.log("NotificationService ntfy base URL updated");
66961
+ } else if (settings.ntfyDashboardHost !== previous.ntfyDashboardHost) {
66962
+ schedulerLog.log("NotificationService ntfy dashboard host updated");
66963
+ } else if (JSON.stringify(settings.ntfyEvents) !== JSON.stringify(previous.ntfyEvents)) {
66964
+ schedulerLog.log("NotificationService ntfy events updated");
66965
+ }
66966
+ }
66967
+ if (settings.webhookEnabled !== previous.webhookEnabled || settings.webhookUrl !== previous.webhookUrl || settings.webhookFormat !== previous.webhookFormat || JSON.stringify(settings.webhookEvents) !== JSON.stringify(previous.webhookEvents)) {
66968
+ await this.syncWebhookProvider(settings);
66969
+ schedulerLog.log("WebhookNotificationProvider config updated");
66970
+ }
66971
+ };
66972
+ async syncNtfyProvider(settings) {
66973
+ const enabled = Boolean(settings.ntfyEnabled && settings.ntfyTopic);
66974
+ if (!enabled) {
66975
+ if (this.ntfyProvider) {
66976
+ await this.ntfyProvider.shutdown?.();
66977
+ this.dispatcher.unregisterProvider(this.ntfyProvider.getProviderId());
66978
+ this.ntfyProvider = void 0;
66979
+ }
66980
+ return;
66981
+ }
66982
+ if (!this.ntfyProvider) {
66983
+ this.ntfyProvider = new NtfyNotificationProvider();
66984
+ this.registerProvider(this.ntfyProvider);
66985
+ }
66986
+ await this.ntfyProvider.initialize?.({
66987
+ topic: settings.ntfyTopic,
66988
+ ntfyBaseUrl: settings.ntfyBaseUrl ?? this.options.ntfyBaseUrl,
66989
+ dashboardHost: settings.ntfyDashboardHost,
66990
+ events: settings.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS],
66991
+ projectId: this.options.projectId
66992
+ });
66993
+ }
66994
+ async syncWebhookProvider(settings) {
66995
+ const enabled = Boolean(settings.webhookEnabled && settings.webhookUrl);
66996
+ if (!enabled) {
66997
+ if (this.webhookProvider) {
66998
+ await this.webhookProvider.shutdown?.();
66999
+ this.dispatcher.unregisterProvider(this.webhookProvider.getProviderId());
67000
+ this.webhookProvider = void 0;
67001
+ }
67002
+ return;
67003
+ }
67004
+ if (!this.webhookProvider) {
67005
+ this.webhookProvider = new WebhookNotificationProvider();
67006
+ this.registerProvider(this.webhookProvider);
67007
+ }
67008
+ await this.webhookProvider.initialize?.({
67009
+ webhookUrl: settings.webhookUrl,
67010
+ webhookFormat: settings.webhookFormat ?? "generic",
67011
+ events: settings.webhookEvents ?? []
67012
+ });
67013
+ }
67014
+ setNotificationsEnabledFromSettings(settings) {
67015
+ this.notificationsEnabled = Boolean(
67016
+ settings.ntfyEnabled && settings.ntfyTopic || settings.webhookEnabled && settings.webhookUrl
67017
+ );
67018
+ }
67019
+ createTaskPayload(task, event) {
67020
+ return {
67021
+ taskId: task.id,
67022
+ taskTitle: task.title,
67023
+ taskDescription: task.description,
67024
+ event
67025
+ };
67026
+ }
67027
+ maybeNotify(taskId, eventType, payload) {
67028
+ const key = `${taskId}:${eventType}`;
67029
+ if (this.notifiedEvents.has(key)) {
67030
+ return;
67031
+ }
67032
+ this.notifiedEvents.add(key);
67033
+ this.dispatcher.dispatch(eventType, payload).catch(() => {
67034
+ });
67035
+ }
67036
+ };
67037
+ }
67038
+ });
67039
+
67040
+ // ../engine/src/notification/index.ts
67041
+ var init_notification = __esm({
67042
+ "../engine/src/notification/index.ts"() {
67043
+ "use strict";
67044
+ init_ntfy_provider();
67045
+ init_webhook_provider();
67046
+ init_notification_service();
67047
+ }
67048
+ });
67049
+
65682
67050
  // ../engine/src/notifier.ts
65683
67051
  function formatTaskIdentifier(task) {
65684
67052
  if (task.title) {
@@ -65757,6 +67125,7 @@ var init_notifier = __esm({
65757
67125
  "../engine/src/notifier.ts"() {
65758
67126
  "use strict";
65759
67127
  init_logger2();
67128
+ init_notification();
65760
67129
  DEFAULT_NTFY_BASE_URL = "https://ntfy.sh";
65761
67130
  DEFAULT_NTFY_EVENTS = [
65762
67131
  "in-review",
@@ -65764,7 +67133,8 @@ var init_notifier = __esm({
65764
67133
  "failed",
65765
67134
  "awaiting-approval",
65766
67135
  "awaiting-user-review",
65767
- "planning-awaiting-input"
67136
+ "planning-awaiting-input",
67137
+ "gridlock"
65768
67138
  ];
65769
67139
  NtfyNotifier = class {
65770
67140
  constructor(store, options = {}) {
@@ -65772,6 +67142,10 @@ var init_notifier = __esm({
65772
67142
  this.defaultNtfyBaseUrl = resolveNtfyBaseUrl(options.ntfyBaseUrl);
65773
67143
  this.ntfyBaseUrl = this.defaultNtfyBaseUrl;
65774
67144
  this.projectId = options.projectId;
67145
+ this.notificationService = new NotificationService(store, {
67146
+ projectId: this.projectId,
67147
+ ntfyBaseUrl: options.ntfyBaseUrl
67148
+ });
65775
67149
  }
65776
67150
  config = {
65777
67151
  enabled: false,
@@ -65779,6 +67153,7 @@ var init_notifier = __esm({
65779
67153
  dashboardHost: void 0,
65780
67154
  events: [...DEFAULT_NTFY_EVENTS]
65781
67155
  };
67156
+ notificationService;
65782
67157
  ntfyBaseUrl;
65783
67158
  defaultNtfyBaseUrl;
65784
67159
  projectId;
@@ -65788,154 +67163,23 @@ var init_notifier = __esm({
65788
67163
  this.abortController = new AbortController();
65789
67164
  const settings = await this.store.getSettings();
65790
67165
  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
67166
  this.store.on("settings:updated", this.handleSettingsUpdated);
67167
+ await this.notificationService.start();
65795
67168
  schedulerLog.log("NtfyNotifier started");
65796
67169
  }
65797
67170
  stop() {
65798
67171
  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
67172
  this.store.off("settings:updated", this.handleSettingsUpdated);
65803
67173
  }
65804
67174
  if (this.abortController) {
65805
67175
  this.abortController.abort();
65806
67176
  this.abortController = null;
65807
67177
  }
67178
+ void this.notificationService.stop();
65808
67179
  schedulerLog.log("NtfyNotifier stopped");
65809
67180
  }
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
67181
  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
- }
67182
+ this.loadConfig(data.settings);
65939
67183
  };
65940
67184
  loadConfig(settings) {
65941
67185
  this.config = {
@@ -65946,11 +67190,38 @@ var init_notifier = __esm({
65946
67190
  };
65947
67191
  this.ntfyBaseUrl = resolveNtfyBaseUrl(settings.ntfyBaseUrl, this.defaultNtfyBaseUrl);
65948
67192
  }
67193
+ notifyGridlock(event) {
67194
+ if (!this.config.enabled || !this.config.topic || !this.isEventEnabled("gridlock")) return;
67195
+ const blockedTasks = event.blockedTaskIds.sort();
67196
+ const reasonSummary = Object.values(event.reasons).reduce((acc, reason) => {
67197
+ acc[reason] = (acc[reason] ?? 0) + 1;
67198
+ return acc;
67199
+ }, {});
67200
+ const reasons = [];
67201
+ if (reasonSummary.dependency) reasons.push(`${reasonSummary.dependency} dependency`);
67202
+ if (reasonSummary.overlap) reasons.push(`${reasonSummary.overlap} overlap`);
67203
+ const clickUrl = buildNtfyClickUrl({
67204
+ dashboardHost: this.config.dashboardHost,
67205
+ projectId: this.projectId
67206
+ });
67207
+ const dedupKey = `gridlock:${blockedTasks.join(",")}`;
67208
+ this.maybeNotifyByKey(
67209
+ dedupKey,
67210
+ () => sendNtfyNotification({
67211
+ ntfyBaseUrl: this.ntfyBaseUrl,
67212
+ topic: this.config.topic,
67213
+ title: "Pipeline gridlocked",
67214
+ message: `${event.blockedTaskCount} todo tasks are blocked (${reasons.join(", ")}). Blocked: ${blockedTasks.join(", ")}. Blocking: ${event.blockingTaskIds.join(", ") || "none"}.`,
67215
+ priority: "high",
67216
+ clickUrl,
67217
+ signal: this.abortController?.signal
67218
+ })
67219
+ );
67220
+ }
65949
67221
  isEventEnabled(event) {
65950
67222
  return isNtfyEventEnabled(this.config.events, event);
65951
67223
  }
65952
- maybeNotify(taskId, eventType, notifyFn) {
65953
- const key = `${taskId}:${eventType}`;
67224
+ maybeNotifyByKey(key, notifyFn) {
65954
67225
  if (this.notifiedEvents.has(key)) {
65955
67226
  return;
65956
67227
  }
@@ -65978,11 +67249,10 @@ var init_shell_utils = __esm({
65978
67249
  import { exec as exec6 } from "node:child_process";
65979
67250
  import { promisify as promisify7 } from "node:util";
65980
67251
  async function createAiPromptExecutor(cwd) {
65981
- const { createFnAgent: createFnAgent5, promptWithFallback: promptWithFallback2 } = await Promise.resolve().then(() => (init_pi(), pi_exports));
65982
67252
  const disposeLog = createLogger2("cron-runner");
65983
67253
  return async (prompt, modelProvider, modelId) => {
65984
67254
  let responseText = "";
65985
- const { session } = await createFnAgent5({
67255
+ const { session } = await createFnAgent2({
65986
67256
  cwd,
65987
67257
  systemPrompt: AI_AUTOMATION_SYSTEM_PROMPT,
65988
67258
  tools: "readonly",
@@ -65993,7 +67263,7 @@ async function createAiPromptExecutor(cwd) {
65993
67263
  }
65994
67264
  });
65995
67265
  try {
65996
- await promptWithFallback2(session, prompt);
67266
+ await promptWithFallback(session, prompt);
65997
67267
  return responseText;
65998
67268
  } finally {
65999
67269
  try {
@@ -66019,8 +67289,10 @@ var execAsync6, log6, DEFAULT_TIMEOUT_MS, MAX_BUFFER, MAX_OUTPUT_LENGTH, DEFAULT
66019
67289
  var init_cron_runner = __esm({
66020
67290
  "../engine/src/cron-runner.ts"() {
66021
67291
  "use strict";
67292
+ init_src();
66022
67293
  init_logger2();
66023
67294
  init_shell_utils();
67295
+ init_pi();
66024
67296
  execAsync6 = promisify7(exec6);
66025
67297
  log6 = createLogger2("cron-runner");
66026
67298
  DEFAULT_TIMEOUT_MS = 5 * 60 * 1e3;
@@ -66343,8 +67615,9 @@ var init_cron_runner = __esm({
66343
67615
  };
66344
67616
  }
66345
67617
  const settings = await this.store.getSettings();
66346
- const modelProvider = step.modelProvider?.trim() || settings.defaultProvider;
66347
- const modelId = step.modelId?.trim() || settings.defaultModelId;
67618
+ const defaultModel = resolveProjectDefaultModel(settings);
67619
+ const modelProvider = step.modelProvider?.trim() || defaultModel.provider;
67620
+ const modelId = step.modelId?.trim() || defaultModel.modelId;
66348
67621
  const model = modelProvider && modelId ? `${modelProvider}/${modelId}` : "default";
66349
67622
  log6.log(` AI prompt step "${step.name}" using model: ${model}`);
66350
67623
  log6.log(` Prompt: ${step.prompt.slice(0, 100)}${step.prompt.length > 100 ? "\u2026" : ""}`);
@@ -67341,6 +68614,7 @@ var init_agent_heartbeat = __esm({
67341
68614
  init_agent_instructions();
67342
68615
  init_logger2();
67343
68616
  init_run_audit();
68617
+ init_pi();
67344
68618
  HEARTBEAT_SYSTEM_PROMPT = `You are a heartbeat agent running in a short execution window.
67345
68619
 
67346
68620
  Your job:
@@ -68056,7 +69330,6 @@ When sending messages:
68056
69330
  };
68057
69331
  const previousBlockedState = await this.store.getLastBlockedState(agentId);
68058
69332
  if (previousBlockedState && isBlockedStateDuplicate(currentBlockedState, previousBlockedState)) {
68059
- heartbeatLog.log(`Task ${resolvedTaskId2} is still blocked by ${blockedBy} (duplicate state) \u2014 skipping comment`);
68060
69333
  await this.completeRun(agentId, run.id, {
68061
69334
  status: "completed",
68062
69335
  resultJson: { reason: "blocked_duplicate", taskId: resolvedTaskId2, blockedBy }
@@ -68108,7 +69381,6 @@ When sending messages:
68108
69381
  };
68109
69382
  }
68110
69383
  };
68111
- const { promptWithFallback: promptWithFallback2 } = await Promise.resolve().then(() => (init_pi(), pi_exports));
68112
69384
  const { createResolvedAgentSession: createResolvedAgentSession2, extractRuntimeHint: extractRuntimeHint2 } = await Promise.resolve().then(() => (init_agent_session_helpers(), agent_session_helpers_exports));
68113
69385
  const { buildSessionSkillContextSync: buildSessionSkillContextSync2 } = await Promise.resolve().then(() => (init_session_skill_context(), session_skill_context_exports));
68114
69386
  let heartbeatTools;
@@ -68302,7 +69574,7 @@ ${taskDetail.prompt}` : "No PROMPT.md available.",
68302
69574
  "Review the task status and take appropriate action. Call fn_heartbeat_done when finished."
68303
69575
  ].join("\n");
68304
69576
  }
68305
- await promptWithFallback2(session, executionPrompt);
69577
+ await promptWithFallback(session, executionPrompt);
68306
69578
  let usageInput = 0;
68307
69579
  let usageOutput = Math.ceil(outputLength / 4);
68308
69580
  let usageCached = 0;
@@ -69155,13 +70427,40 @@ var init_self_healing = __esm({
69155
70427
  return Date.now() - updatedAt >= timeoutMs;
69156
70428
  }
69157
70429
  async findLandedTaskCommit(task) {
69158
- const readLog = async (range) => {
70430
+ const storedSha = task.mergeDetails?.commitSha;
70431
+ if (storedSha) {
70432
+ try {
70433
+ await execAsync8(
70434
+ `git merge-base --is-ancestor ${shellQuote(storedSha)} HEAD`,
70435
+ { cwd: this.options.rootDir }
70436
+ );
70437
+ const { stdout: stdout2 } = await execAsync8(
70438
+ `git log -1 --format=%H%x1f%s ${shellQuote(storedSha)}`,
70439
+ { cwd: this.options.rootDir, maxBuffer: 1024 * 1024 }
70440
+ );
70441
+ const [sha2, subject2] = stdout2.trim().split("");
70442
+ if (sha2) {
70443
+ const commit2 = { sha: sha2, subject: subject2 };
70444
+ try {
70445
+ const stats = await execAsync8(`git show --shortstat --format= ${shellQuote(sha2)}`, {
70446
+ cwd: this.options.rootDir,
70447
+ maxBuffer: 1024 * 1024
70448
+ });
70449
+ Object.assign(commit2, parseShortstat(stats.stdout));
70450
+ } catch {
70451
+ }
70452
+ return commit2;
70453
+ }
70454
+ } catch {
70455
+ }
70456
+ }
70457
+ const readLog = async (range, grepArg, fixedStrings) => {
69159
70458
  const command = [
69160
70459
  "git log",
69161
70460
  "--format=%H%x1f%s",
69162
70461
  "--max-count=20",
69163
- "--fixed-strings",
69164
- `--grep=${shellQuote(task.id)}`,
70462
+ ...fixedStrings ? ["--fixed-strings"] : ["-E"],
70463
+ `--grep=${grepArg}`,
69165
70464
  shellQuote(range)
69166
70465
  ].join(" ");
69167
70466
  return execAsync8(command, {
@@ -69169,22 +70468,34 @@ var init_self_healing = __esm({
69169
70468
  maxBuffer: 1024 * 1024
69170
70469
  });
69171
70470
  };
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;
70471
+ const search = async (grepArg, fixedStrings) => {
70472
+ let out;
70473
+ try {
70474
+ const r = await readLog(
70475
+ task.baseCommitSha ? `${task.baseCommitSha}..HEAD` : "HEAD",
70476
+ grepArg,
70477
+ fixedStrings
70478
+ );
70479
+ out = r.stdout;
70480
+ } catch (err) {
70481
+ const errorMessage = err instanceof Error ? err.message : String(err);
70482
+ log8.warn(
70483
+ `Failed to read git log for landed commit lookup (${task.id}): ${errorMessage} \u2014 retrying with HEAD range`
70484
+ );
70485
+ if (!task.baseCommitSha) return "";
70486
+ const r = await readLog("HEAD", grepArg, fixedStrings);
70487
+ out = r.stdout;
70488
+ }
70489
+ if (!out.trim() && task.baseCommitSha) {
70490
+ const r = await readLog("HEAD", grepArg, fixedStrings);
70491
+ out = r.stdout;
70492
+ }
70493
+ return out;
70494
+ };
70495
+ const trailerPattern = `^Fusion-Task-Id: ${task.id}$`;
70496
+ let stdout = await search(shellQuote(trailerPattern), false);
70497
+ if (!stdout.trim()) {
70498
+ stdout = await search(shellQuote(task.id), true);
69188
70499
  }
69189
70500
  const firstLine = stdout.trim().split("\n").find(Boolean);
69190
70501
  if (!firstLine) return null;
@@ -69564,7 +70875,7 @@ var init_self_healing = __esm({
69564
70875
  if (!timeoutMs || timeoutMs <= 0) return 0;
69565
70876
  const tasks = await this.store.listTasks({ column: "in-review", slim: true });
69566
70877
  const candidates = tasks.filter(
69567
- (task) => task.column === "in-review" && Boolean(task.status && ACTIVE_MERGE_STATUSES.has(task.status)) && this.isPastInterruptedMergeGrace(task, timeoutMs)
70878
+ (task) => task.column === "in-review" && !task.paused && Boolean(task.status && ACTIVE_MERGE_STATUSES.has(task.status)) && this.isPastInterruptedMergeGrace(task, timeoutMs)
69568
70879
  );
69569
70880
  if (candidates.length === 0) return 0;
69570
70881
  log8.warn(`Found ${candidates.length} stale merging task(s) in in-review`);
@@ -69605,6 +70916,13 @@ var init_self_healing = __esm({
69605
70916
  "Auto-recovered: stale merge status cleared; merge will be retried"
69606
70917
  );
69607
70918
  log8.log(`Recovered interrupted merge ${task.id}: cleared stale status for retry`);
70919
+ try {
70920
+ this.options.enqueueMerge?.(task.id);
70921
+ } catch (enqueueErr) {
70922
+ log8.warn(
70923
+ `Failed to re-enqueue ${task.id} after stale-merge recovery (will rely on polling sweep): ${enqueueErr instanceof Error ? enqueueErr.message : String(enqueueErr)}`
70924
+ );
70925
+ }
69608
70926
  recovered++;
69609
70927
  } catch (err) {
69610
70928
  const errorMessage = err instanceof Error ? err.message : String(err);
@@ -69635,7 +70953,7 @@ var init_self_healing = __esm({
69635
70953
  try {
69636
70954
  const tasks = await this.store.listTasks({ column: "in-review", slim: true });
69637
70955
  const mergedButNotDone = tasks.filter(
69638
- (t) => t.column === "in-review" && t.mergeDetails?.mergeConfirmed === true
70956
+ (t) => t.column === "in-review" && !t.paused && t.mergeDetails?.mergeConfirmed === true
69639
70957
  );
69640
70958
  if (mergedButNotDone.length === 0) return 0;
69641
70959
  log8.warn(`Found ${mergedButNotDone.length} merged task(s) stuck in in-review`);
@@ -69683,7 +71001,7 @@ var init_self_healing = __esm({
69683
71001
  try {
69684
71002
  const tasks = await this.store.listTasks({ column: "in-review", slim: true });
69685
71003
  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")
71004
+ (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
71005
  );
69688
71006
  if (misclassified.length === 0) return 0;
69689
71007
  log8.warn(`Found ${misclassified.length} misclassified failure(s) with all steps done`);
@@ -70803,6 +72121,12 @@ var init_in_process_runtime = __esm({
70803
72121
  ephemeralCleanupTimers = /* @__PURE__ */ new Map();
70804
72122
  /** Listener for agent:stateChanged events to clean up terminated ephemeral agents */
70805
72123
  ephemeralTerminationListener;
72124
+ /**
72125
+ * Optional callback the runtime forwards to SelfHealingManager so that
72126
+ * stale-merge recovery can re-enqueue tasks immediately. Set by ProjectEngine
72127
+ * before `start()` via `setMergeEnqueuer`.
72128
+ */
72129
+ mergeEnqueuer;
70806
72130
  /**
70807
72131
  * Start the runtime and initialize all subsystems.
70808
72132
  *
@@ -70923,8 +72247,7 @@ var init_in_process_runtime = __esm({
70923
72247
  this.recordActivity();
70924
72248
  runtimeLog.log(`Scheduled task ${task.id}`);
70925
72249
  },
70926
- onBlocked: (task, blockedBy) => {
70927
- runtimeLog.log(`Task ${task.id} blocked by: ${blockedBy.join(", ")}`);
72250
+ onBlocked: () => {
70928
72251
  }
70929
72252
  });
70930
72253
  this.stuckTaskDetector = new StuckTaskDetector(this.taskStore, {
@@ -71244,7 +72567,8 @@ var init_in_process_runtime = __esm({
71244
72567
  getExecutingTaskIds: () => this.executor.getExecutingTaskIds(),
71245
72568
  recoverApprovedTriageTask: (task) => this.triageProcessor?.recoverApprovedTask(task) ?? Promise.resolve(false),
71246
72569
  getPlanningTaskIds: () => this.triageProcessor?.getProcessingTaskIds() ?? /* @__PURE__ */ new Set(),
71247
- evictStaleTriageProcessing: () => this.triageProcessor?.evictStaleProcessing() ?? /* @__PURE__ */ new Set()
72570
+ evictStaleTriageProcessing: () => this.triageProcessor?.evictStaleProcessing() ?? /* @__PURE__ */ new Set(),
72571
+ enqueueMerge: this.mergeEnqueuer ? (taskId) => this.mergeEnqueuer?.(taskId) : void 0
71248
72572
  });
71249
72573
  this.selfHealingManager.start();
71250
72574
  this.stuckTaskDetector.start();
@@ -71421,6 +72745,14 @@ var init_in_process_runtime = __esm({
71421
72745
  getStatus() {
71422
72746
  return this.status;
71423
72747
  }
72748
+ /**
72749
+ * Register a callback used by SelfHealingManager to re-enqueue tasks for
72750
+ * auto-merge after clearing a stale `merging` status. Must be called before
72751
+ * `start()` because SelfHealingManager is constructed during startup.
72752
+ */
72753
+ setMergeEnqueuer(enqueueMerge) {
72754
+ this.mergeEnqueuer = enqueueMerge;
72755
+ }
71424
72756
  /**
71425
72757
  * Get the project's TaskStore instance.
71426
72758
  * @throws Error if runtime has not been started
@@ -73190,6 +74522,132 @@ var init_project_manager = __esm({
73190
74522
  }
73191
74523
  });
73192
74524
 
74525
+ // ../engine/src/gridlock-detector.ts
74526
+ var gridlockLog, GridlockDetector;
74527
+ var init_gridlock_detector = __esm({
74528
+ "../engine/src/gridlock-detector.ts"() {
74529
+ "use strict";
74530
+ init_logger2();
74531
+ init_scheduler();
74532
+ gridlockLog = createLogger2("gridlock-detector");
74533
+ GridlockDetector = class {
74534
+ constructor(store, options = {}) {
74535
+ this.store = store;
74536
+ this.pollIntervalMs = options.pollIntervalMs ?? 3e4;
74537
+ this.missionStore = options.missionStore;
74538
+ this.onGridlock = options.onGridlock;
74539
+ }
74540
+ interval = null;
74541
+ pollIntervalMs;
74542
+ missionStore;
74543
+ onGridlock;
74544
+ lastGridlockKey = null;
74545
+ start() {
74546
+ if (this.interval) return;
74547
+ this.interval = setInterval(() => {
74548
+ this.detectGridlock().catch((error) => {
74549
+ gridlockLog.error("Failed gridlock detection cycle:", error);
74550
+ });
74551
+ }, this.pollIntervalMs);
74552
+ gridlockLog.log(`Started (poll interval: ${this.pollIntervalMs}ms)`);
74553
+ }
74554
+ stop() {
74555
+ if (!this.interval) return;
74556
+ clearInterval(this.interval);
74557
+ this.interval = null;
74558
+ gridlockLog.log("Stopped");
74559
+ }
74560
+ async detectGridlock() {
74561
+ const [tasks, settings] = await Promise.all([
74562
+ this.store.listTasks({ slim: true, includeArchived: false }),
74563
+ this.store.getSettings()
74564
+ ]);
74565
+ const now = Date.now();
74566
+ const schedulable = tasks.filter((task) => {
74567
+ if (task.column !== "todo" || task.paused) return false;
74568
+ if (task.nextRecoveryAt && new Date(task.nextRecoveryAt).getTime() > now) return false;
74569
+ if (this.isMissionBlocked(task)) return false;
74570
+ return true;
74571
+ });
74572
+ if (schedulable.length === 0) {
74573
+ this.lastGridlockKey = null;
74574
+ return null;
74575
+ }
74576
+ const active = tasks.filter((task) => task.column === "in-progress" || task.column === "in-review" && Boolean(task.worktree));
74577
+ if (active.length === 0) {
74578
+ this.lastGridlockKey = null;
74579
+ return null;
74580
+ }
74581
+ const overlapIgnorePaths = settings.overlapIgnorePaths ?? [];
74582
+ const activeScopes = /* @__PURE__ */ new Map();
74583
+ if (settings.groupOverlappingFiles) {
74584
+ for (const task of active) {
74585
+ const scope = filterPathsByIgnoreList(await this.store.parseFileScopeFromPrompt(task.id), overlapIgnorePaths);
74586
+ if (scope.length > 0) {
74587
+ activeScopes.set(task.id, scope);
74588
+ }
74589
+ }
74590
+ }
74591
+ const reasons = {};
74592
+ const blockingTaskIds = /* @__PURE__ */ new Set();
74593
+ for (const task of schedulable) {
74594
+ const unmetDeps = task.dependencies.filter((depId) => {
74595
+ const dep = tasks.find((candidate) => candidate.id === depId);
74596
+ return dep && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived";
74597
+ });
74598
+ if (unmetDeps.length > 0) {
74599
+ reasons[task.id] = "dependency";
74600
+ for (const depId of unmetDeps) blockingTaskIds.add(depId);
74601
+ continue;
74602
+ }
74603
+ if (!settings.groupOverlappingFiles) continue;
74604
+ const taskScope = filterPathsByIgnoreList(await this.store.parseFileScopeFromPrompt(task.id), overlapIgnorePaths);
74605
+ if (taskScope.length === 0) continue;
74606
+ for (const [activeId, activeScope] of activeScopes) {
74607
+ if (pathsOverlap2(taskScope, activeScope)) {
74608
+ reasons[task.id] = "overlap";
74609
+ blockingTaskIds.add(activeId);
74610
+ break;
74611
+ }
74612
+ }
74613
+ }
74614
+ const blockedTaskIds = Object.keys(reasons).sort();
74615
+ if (blockedTaskIds.length !== schedulable.length) {
74616
+ this.lastGridlockKey = null;
74617
+ return null;
74618
+ }
74619
+ const gridlockKey = blockedTaskIds.join(",");
74620
+ const event = {
74621
+ blockedTaskCount: blockedTaskIds.length,
74622
+ reasons,
74623
+ blockedTaskIds,
74624
+ blockingTaskIds: Array.from(blockingTaskIds).sort()
74625
+ };
74626
+ if (this.lastGridlockKey !== gridlockKey) {
74627
+ this.lastGridlockKey = gridlockKey;
74628
+ gridlockLog.warn(`Gridlock detected: blocked=${event.blockedTaskIds.join(",")}; blocking=${event.blockingTaskIds.join(",")}`);
74629
+ this.onGridlock?.(event);
74630
+ }
74631
+ return event;
74632
+ }
74633
+ isMissionBlocked(task) {
74634
+ if (!this.missionStore || !task.sliceId) return false;
74635
+ try {
74636
+ const slice = this.missionStore.getSlice(task.sliceId);
74637
+ if (!slice) return false;
74638
+ const milestone = this.missionStore.getMilestone(slice.milestoneId);
74639
+ if (!milestone) return false;
74640
+ const mission = this.missionStore.getMission(milestone.missionId);
74641
+ return mission?.status === "blocked";
74642
+ } catch (error) {
74643
+ gridlockLog.warn(`Mission lookup failed for ${task.id}:`, error);
74644
+ return false;
74645
+ }
74646
+ }
74647
+ };
74648
+ }
74649
+ });
74650
+
73193
74651
  // ../engine/src/remote-access/provider-adapters.ts
73194
74652
  import { accessSync, constants as fsConstants } from "node:fs";
73195
74653
  function isAbsoluteOrPathLike(input) {
@@ -73772,6 +75230,8 @@ var init_project_engine = __esm({
73772
75230
  init_pr_monitor();
73773
75231
  init_pr_comment_handler();
73774
75232
  init_notifier();
75233
+ init_notification();
75234
+ init_gridlock_detector();
73775
75235
  init_cron_runner();
73776
75236
  init_merger();
73777
75237
  init_concurrency();
@@ -73785,11 +75245,24 @@ var init_project_engine = __esm({
73785
75245
  this.options = options;
73786
75246
  const runtimeConfig = options.externalTaskStore ? { ...config, externalTaskStore: options.externalTaskStore } : config;
73787
75247
  this.runtime = new InProcessRuntime(runtimeConfig, centralCore);
75248
+ this.runtime.setMergeEnqueuer?.((taskId) => {
75249
+ if (this.activeMergeTaskId === taskId) {
75250
+ this.mergeAbortController?.abort();
75251
+ this.mergeAbortController = null;
75252
+ this.activeMergeSession?.dispose();
75253
+ this.activeMergeSession = null;
75254
+ this.activeMergeTaskId = null;
75255
+ }
75256
+ this.mergeActive.delete(taskId);
75257
+ this.internalEnqueueMerge(taskId);
75258
+ });
73788
75259
  }
73789
75260
  runtime;
73790
75261
  prMonitor;
73791
75262
  prCommentHandler;
73792
75263
  notifier;
75264
+ notificationService;
75265
+ gridlockDetector;
73793
75266
  cronRunner;
73794
75267
  automationStore;
73795
75268
  remoteTunnelManager;
@@ -73854,12 +75327,21 @@ var init_project_engine = __esm({
73854
75327
  (taskId, prInfo, comments) => this.prCommentHandler.handleNewComments(taskId, prInfo, comments)
73855
75328
  );
73856
75329
  if (!this.options.skipNotifier) {
75330
+ this.notificationService = new NotificationService(store, {
75331
+ projectId: this.options.projectId,
75332
+ ntfyBaseUrl: this.options.ntfyBaseUrl
75333
+ });
75334
+ await this.notificationService.start();
73857
75335
  this.notifier = new NtfyNotifier(store, {
73858
75336
  projectId: this.options.projectId,
73859
75337
  ntfyBaseUrl: this.options.ntfyBaseUrl
73860
75338
  });
73861
75339
  await this.notifier.start();
73862
75340
  }
75341
+ this.gridlockDetector = new GridlockDetector(store, {
75342
+ onGridlock: (event) => this.notifier?.notifyGridlock(event)
75343
+ });
75344
+ this.gridlockDetector.start();
73863
75345
  this.setAutomationSubsystemHealth(
73864
75346
  "initializing",
73865
75347
  "Initializing AutomationStore and CronRunner"
@@ -73990,7 +75472,9 @@ ${detail}`
73990
75472
  }
73991
75473
  } catch {
73992
75474
  }
75475
+ this.notificationService?.stop();
73993
75476
  this.notifier?.stop();
75477
+ this.gridlockDetector?.stop();
73994
75478
  this.cronRunner?.stop();
73995
75479
  this.setAutomationSubsystemHealth("not-initialized", "Automation subsystem stopped");
73996
75480
  const tunnelManager = this.remoteTunnelManager;
@@ -75809,6 +77293,8 @@ __export(src_exports2, {
75809
77293
  MissionAutopilot: () => MissionAutopilot,
75810
77294
  MissionExecutionLoop: () => MissionExecutionLoop,
75811
77295
  NodeHealthMonitor: () => NodeHealthMonitor,
77296
+ NotificationService: () => NotificationService,
77297
+ NtfyNotificationProvider: () => NtfyNotificationProvider,
75812
77298
  NtfyNotifier: () => NtfyNotifier,
75813
77299
  PRIORITY_EXECUTE: () => PRIORITY_EXECUTE,
75814
77300
  PRIORITY_MERGE: () => PRIORITY_MERGE,
@@ -75833,6 +77319,7 @@ __export(src_exports2, {
75833
77319
  TriageProcessor: () => TriageProcessor,
75834
77320
  TunnelProcessManager: () => TunnelProcessManager,
75835
77321
  UsageLimitPauser: () => UsageLimitPauser,
77322
+ WebhookNotificationProvider: () => WebhookNotificationProvider,
75836
77323
  WorktreePool: () => WorktreePool,
75837
77324
  aiMergeTask: () => aiMergeTask,
75838
77325
  buildAgentChatPrompt: () => buildAgentChatPrompt,
@@ -75851,6 +77338,7 @@ __export(src_exports2, {
75851
77338
  createTaskLogTool: () => createTaskLogTool,
75852
77339
  describeAgentModel: () => describeAgentModel,
75853
77340
  describeModel: () => describeModel,
77341
+ formatTaskIdentifier: () => formatTaskIdentifier,
75854
77342
  getDefaultPiRuntime: () => getDefaultPiRuntime,
75855
77343
  getHostExtensionPaths: () => getHostExtensionPaths,
75856
77344
  getTunnelProviderAdapter: () => getTunnelProviderAdapter,
@@ -75902,6 +77390,7 @@ var init_src2 = __esm({
75902
77390
  init_pr_monitor();
75903
77391
  init_pr_comment_handler();
75904
77392
  init_notifier();
77393
+ init_notification();
75905
77394
  init_cron_runner();
75906
77395
  init_routine_runner();
75907
77396
  init_routine_scheduler();
@@ -76087,6 +77576,7 @@ async function ensureNtfyHelpersReady() {
76087
77576
  }
76088
77577
  try {
76089
77578
  const engine = await Promise.resolve().then(() => (init_src2(), src_exports2));
77579
+ const hasNotificationService = "NotificationService" in engine && typeof engine.NotificationService === "function";
76090
77580
  const hasAllHelpers = "isNtfyEventEnabled" in engine && "buildNtfyClickUrl" in engine && "sendNtfyNotification" in engine && typeof engine.isNtfyEventEnabled === "function" && typeof engine.buildNtfyClickUrl === "function" && typeof engine.sendNtfyNotification === "function";
76091
77581
  if (!hasAllHelpers) {
76092
77582
  return;
@@ -76096,6 +77586,12 @@ async function ensureNtfyHelpersReady() {
76096
77586
  buildNtfyClickUrl: engine.buildNtfyClickUrl,
76097
77587
  sendNtfyNotification: engine.sendNtfyNotification
76098
77588
  };
77589
+ if (hasNotificationService) {
77590
+ diagnostics.info(
77591
+ "NotificationService abstraction detected in engine",
77592
+ { operation: "notification-service-detection" }
77593
+ );
77594
+ }
76099
77595
  } catch {
76100
77596
  }
76101
77597
  }
@@ -76118,6 +77614,11 @@ function cleanupInMemorySession(sessionId) {
76118
77614
  if (!session) {
76119
77615
  return false;
76120
77616
  }
77617
+ const activeGeneration = activeGenerations.get(sessionId);
77618
+ if (activeGeneration) {
77619
+ clearTimeout(activeGeneration.timer);
77620
+ activeGenerations.delete(sessionId);
77621
+ }
76121
77622
  if (session.agent) {
76122
77623
  try {
76123
77624
  session.agent.session.dispose?.();
@@ -76451,109 +77952,157 @@ async function maybeNotifyPlanningAwaitingInput(session, question) {
76451
77952
  });
76452
77953
  }
76453
77954
  }
77955
+ function setSessionError(session, message) {
77956
+ session.error = message;
77957
+ session.updatedAt = /* @__PURE__ */ new Date();
77958
+ persistSession(session, "error", message);
77959
+ planningStreamManager.broadcast(session.id, {
77960
+ type: "error",
77961
+ data: message
77962
+ });
77963
+ }
77964
+ function createAbortError() {
77965
+ const error = new Error("Generation aborted");
77966
+ error.name = "AbortError";
77967
+ return error;
77968
+ }
77969
+ async function runGenerationWithTimeout(session, operation) {
77970
+ const existing = activeGenerations.get(session.id);
77971
+ if (existing) {
77972
+ clearTimeout(existing.timer);
77973
+ existing.abortController.abort();
77974
+ }
77975
+ const abortController = new AbortController();
77976
+ let timeoutTriggered = false;
77977
+ const timer = setTimeout(() => {
77978
+ timeoutTriggered = true;
77979
+ setSessionError(session, "AI generation timed out. You can retry or start a new session.");
77980
+ abortController.abort();
77981
+ }, GENERATION_TIMEOUT_MS);
77982
+ activeGenerations.set(session.id, { abortController, timer });
77983
+ const abortPromise = new Promise((_, reject) => {
77984
+ abortController.signal.addEventListener(
77985
+ "abort",
77986
+ () => reject(createAbortError()),
77987
+ { once: true }
77988
+ );
77989
+ });
77990
+ try {
77991
+ return await Promise.race([operation(), abortPromise]);
77992
+ } catch (error) {
77993
+ if (error instanceof Error && error.name === "AbortError") {
77994
+ if (!timeoutTriggered && !session.error) {
77995
+ setSessionError(session, "Generation stopped by user. You can retry or start a new session.");
77996
+ }
77997
+ }
77998
+ throw error;
77999
+ } finally {
78000
+ clearTimeout(timer);
78001
+ activeGenerations.delete(session.id);
78002
+ }
78003
+ }
76454
78004
  async function continueAgentConversation(session, message) {
76455
78005
  if (!session.agent) {
76456
78006
  throw new InvalidSessionStateError("AI agent not initialized");
76457
78007
  }
76458
78008
  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("");
78009
+ await runGenerationWithTimeout(session, async () => {
78010
+ session.thinkingOutput = "";
78011
+ await session.agent.session.prompt(message);
78012
+ const lastMessage = session.agent.session.state.messages.filter((m) => m.role === "assistant").pop();
78013
+ let responseText = session.thinkingOutput;
78014
+ if (lastMessage?.content) {
78015
+ if (typeof lastMessage.content === "string") {
78016
+ responseText = lastMessage.content;
78017
+ } else if (Array.isArray(lastMessage.content)) {
78018
+ responseText = lastMessage.content.filter((c) => c.type === "text").map((c) => c.text).join("");
78019
+ }
76468
78020
  }
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.'
78021
+ let parsed;
78022
+ let lastError;
78023
+ for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
78024
+ try {
78025
+ parsed = parseAgentResponse(responseText);
78026
+ break;
78027
+ } catch (err) {
78028
+ lastError = err instanceof Error ? err : new Error(String(err));
78029
+ if (attempt < MAX_PARSE_RETRIES) {
78030
+ diagnostics.warn(
78031
+ "Parse attempt failed, requesting reformat",
78032
+ { sessionId: session.id, attempt: attempt + 1, operation: "parse-retry" }
76487
78033
  );
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("");
78034
+ try {
78035
+ session.thinkingOutput = "";
78036
+ await session.agent.session.prompt(
78037
+ '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.'
78038
+ );
78039
+ const retryMessage = session.agent.session.state.messages.filter((m) => m.role === "assistant").pop();
78040
+ let retryText = session.thinkingOutput;
78041
+ if (retryMessage?.content) {
78042
+ if (typeof retryMessage.content === "string") {
78043
+ retryText = retryMessage.content;
78044
+ } else if (Array.isArray(retryMessage.content)) {
78045
+ retryText = retryMessage.content.filter((c) => c.type === "text").map((c) => c.text).join("");
78046
+ }
76495
78047
  }
78048
+ responseText = retryText;
78049
+ } catch (retryErr) {
78050
+ diagnostics.errorFromException(
78051
+ "Retry prompt failed for session",
78052
+ retryErr,
78053
+ { sessionId: session.id, operation: "retry-prompt" }
78054
+ );
78055
+ break;
76496
78056
  }
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
78057
  }
76506
78058
  }
76507
78059
  }
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
- });
78060
+ if (!parsed) {
78061
+ const errorMsg = `${lastError?.message || "Failed to parse AI response"} You can try responding again or start a new planning session.`;
78062
+ diagnostics.error(
78063
+ "All parse attempts exhausted for session",
78064
+ { sessionId: session.id, message: errorMsg, operation: "parse-exhausted" }
78065
+ );
78066
+ session.error = errorMsg;
78067
+ session.updatedAt = /* @__PURE__ */ new Date();
78068
+ persistSession(session, "error", errorMsg);
78069
+ planningStreamManager.broadcast(session.id, {
78070
+ type: "error",
78071
+ data: errorMsg
78072
+ });
78073
+ return;
78074
+ }
78075
+ if (parsed.type === "question") {
78076
+ session.currentQuestion = parsed.data;
78077
+ session.error = void 0;
78078
+ session.lastGeneratedThinking = session.thinkingOutput;
78079
+ session.updatedAt = /* @__PURE__ */ new Date();
78080
+ persistSession(session, "awaiting_input");
78081
+ void maybeNotifyPlanningAwaitingInput(session, parsed.data);
78082
+ planningStreamManager.broadcast(session.id, {
78083
+ type: "question",
78084
+ data: parsed.data
78085
+ });
78086
+ } else if (parsed.type === "complete") {
78087
+ session.summary = parsed.data;
78088
+ session.currentQuestion = void 0;
78089
+ session.error = void 0;
78090
+ session.updatedAt = /* @__PURE__ */ new Date();
78091
+ persistSession(session, "complete");
78092
+ planningStreamManager.broadcast(session.id, {
78093
+ type: "summary",
78094
+ data: parsed.data
78095
+ });
78096
+ planningStreamManager.broadcast(session.id, { type: "complete" });
78097
+ }
78098
+ });
78099
+ } catch (err) {
78100
+ if (err instanceof Error && err.name === "AbortError") {
76522
78101
  return;
76523
78102
  }
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
78103
  const errorMessage = err instanceof Error ? err.message : "AI processing failed";
76549
78104
  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
- });
78105
+ setSessionError(session, errorMessage);
76557
78106
  }
76558
78107
  }
76559
78108
  function extractJsonCandidate(text) {
@@ -76825,7 +78374,7 @@ function getSession(sessionId) {
76825
78374
  return void 0;
76826
78375
  }
76827
78376
  }
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;
78377
+ 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
78378
  var init_planning = __esm({
76830
78379
  "../dashboard/src/planning.ts"() {
76831
78380
  "use strict";
@@ -76896,8 +78445,10 @@ For completion:
76896
78445
  CLEANUP_INTERVAL_MS2 = 5 * 60 * 1e3;
76897
78446
  MAX_SESSIONS_PER_IP_PER_HOUR = 5;
76898
78447
  RATE_LIMIT_WINDOW_MS2 = 60 * 60 * 1e3;
78448
+ GENERATION_TIMEOUT_MS = 12e4;
76899
78449
  sessions = /* @__PURE__ */ new Map();
76900
78450
  rateLimits2 = /* @__PURE__ */ new Map();
78451
+ activeGenerations = /* @__PURE__ */ new Map();
76901
78452
  cleanupInterval2 = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS2);
76902
78453
  cleanupInterval2.unref?.();
76903
78454
  process.on("beforeExit", () => {
@@ -77035,6 +78586,108 @@ var init_ai_session_store = __esm({
77035
78586
  }
77036
78587
  });
77037
78588
 
78589
+ // ../dashboard/src/ai-session-timeout.ts
78590
+ function createAbortError2() {
78591
+ const error = new Error("Generation aborted");
78592
+ error.name = "AbortError";
78593
+ return error;
78594
+ }
78595
+ function isAbortError(err) {
78596
+ return err instanceof Error && err.name === "AbortError";
78597
+ }
78598
+ var GenerationGuard;
78599
+ var init_ai_session_timeout = __esm({
78600
+ "../dashboard/src/ai-session-timeout.ts"() {
78601
+ "use strict";
78602
+ GenerationGuard = class {
78603
+ active = /* @__PURE__ */ new Map();
78604
+ /**
78605
+ * Tracks the cause of a pending abort so the original `run()` can
78606
+ * distinguish a user-initiated stop from re-entrant displacement. The flag
78607
+ * is consumed (deleted) by the catch block of the displaced `run()`.
78608
+ */
78609
+ abortCause = /* @__PURE__ */ new Map();
78610
+ /**
78611
+ * Wrap `op` with a timeout + abort. If a previous generation is still
78612
+ * registered for the same id, it is aborted first (the prior `run()`
78613
+ * rejects with `AbortError`, marked as `displaced` so its `onUserStop`
78614
+ * handler does NOT fire).
78615
+ */
78616
+ async run(sessionId, timeoutMs, handlers, op) {
78617
+ this.cancelInternal(sessionId, "displaced");
78618
+ const abort = new AbortController();
78619
+ const timer = setTimeout(() => {
78620
+ this.abortCause.set(abort, "timeout");
78621
+ try {
78622
+ handlers.onTimeout();
78623
+ } catch {
78624
+ }
78625
+ abort.abort();
78626
+ }, timeoutMs);
78627
+ const entry = { abort, timer };
78628
+ this.active.set(sessionId, entry);
78629
+ const abortPromise = new Promise((_, reject) => {
78630
+ abort.signal.addEventListener(
78631
+ "abort",
78632
+ () => reject(createAbortError2()),
78633
+ { once: true }
78634
+ );
78635
+ });
78636
+ try {
78637
+ return await Promise.race([op(), abortPromise]);
78638
+ } catch (err) {
78639
+ if (isAbortError(err)) {
78640
+ const cause = this.abortCause.get(abort) ?? "user-stop";
78641
+ if (cause === "user-stop") {
78642
+ try {
78643
+ handlers.onUserStop?.();
78644
+ } catch {
78645
+ }
78646
+ }
78647
+ }
78648
+ throw err;
78649
+ } finally {
78650
+ clearTimeout(timer);
78651
+ this.abortCause.delete(abort);
78652
+ if (this.active.get(sessionId) === entry) {
78653
+ this.active.delete(sessionId);
78654
+ }
78655
+ }
78656
+ }
78657
+ /** AbortSignal of the in-flight generation, if any — for tools that honor it. */
78658
+ signal(sessionId) {
78659
+ return this.active.get(sessionId)?.abort.signal;
78660
+ }
78661
+ has(sessionId) {
78662
+ return this.active.has(sessionId);
78663
+ }
78664
+ /**
78665
+ * Manually abort the active generation. Returns true if there was one.
78666
+ * The wrapped operation will reject with `AbortError`, and the `onUserStop`
78667
+ * handler from the original `run()` call will fire.
78668
+ */
78669
+ stop(sessionId) {
78670
+ return this.cancelInternal(sessionId, "user-stop");
78671
+ }
78672
+ /** Reset all in-flight generations (test/shutdown only). */
78673
+ reset() {
78674
+ for (const sessionId of [...this.active.keys()]) {
78675
+ this.cancelInternal(sessionId, "user-stop");
78676
+ }
78677
+ }
78678
+ cancelInternal(sessionId, cause) {
78679
+ const entry = this.active.get(sessionId);
78680
+ if (!entry) return false;
78681
+ clearTimeout(entry.timer);
78682
+ this.abortCause.set(entry.abort, cause);
78683
+ entry.abort.abort();
78684
+ this.active.delete(sessionId);
78685
+ return true;
78686
+ }
78687
+ };
78688
+ }
78689
+ });
78690
+
77038
78691
  // ../dashboard/src/subtask-breakdown.ts
77039
78692
  import { EventEmitter as EventEmitter24 } from "node:events";
77040
78693
  function cleanupInMemorySubtaskSession(sessionId) {
@@ -77042,6 +78695,7 @@ function cleanupInMemorySubtaskSession(sessionId) {
77042
78695
  if (!session) {
77043
78696
  return false;
77044
78697
  }
78698
+ generationGuard.stop(sessionId);
77045
78699
  try {
77046
78700
  session.agent?.session?.dispose?.();
77047
78701
  } catch {
@@ -77058,17 +78712,19 @@ function cleanupExpiredSessions2() {
77058
78712
  }
77059
78713
  }
77060
78714
  }
77061
- var diagnostics3, SESSION_TTL_MS2, CLEANUP_INTERVAL_MS3, sessions2, cleanupInterval3, SubtaskStreamManager, subtaskStreamManager;
78715
+ var diagnostics3, SESSION_TTL_MS2, CLEANUP_INTERVAL_MS3, generationGuard, sessions2, cleanupInterval3, SubtaskStreamManager, subtaskStreamManager;
77062
78716
  var init_subtask_breakdown = __esm({
77063
78717
  "../dashboard/src/subtask-breakdown.ts"() {
77064
78718
  "use strict";
77065
78719
  init_src();
77066
78720
  init_sse_buffer();
77067
78721
  init_ai_session_diagnostics();
78722
+ init_ai_session_timeout();
77068
78723
  init_src2();
77069
78724
  diagnostics3 = createSessionDiagnostics("subtask-breakdown");
77070
78725
  SESSION_TTL_MS2 = 7 * 24 * 60 * 60 * 1e3;
77071
78726
  CLEANUP_INTERVAL_MS3 = 5 * 60 * 1e3;
78727
+ generationGuard = new GenerationGuard();
77072
78728
  sessions2 = /* @__PURE__ */ new Map();
77073
78729
  cleanupInterval3 = setInterval(cleanupExpiredSessions2, CLEANUP_INTERVAL_MS3);
77074
78730
  cleanupInterval3.unref?.();
@@ -77143,6 +78799,7 @@ function cleanupInMemoryMissionSession(sessionId) {
77143
78799
  if (!session) {
77144
78800
  return false;
77145
78801
  }
78802
+ generationGuard2.stop(sessionId);
77146
78803
  if (session.agent) {
77147
78804
  try {
77148
78805
  session.agent.session.dispose?.();
@@ -77167,18 +78824,20 @@ function cleanupExpiredSessions3() {
77167
78824
  }
77168
78825
  }
77169
78826
  }
77170
- var diagnostics4, SESSION_TTL_MS3, CLEANUP_INTERVAL_MS4, RATE_LIMIT_WINDOW_MS3, sessions3, rateLimits3, cleanupInterval4, MissionInterviewStreamManager, missionInterviewStreamManager;
78827
+ var diagnostics4, SESSION_TTL_MS3, CLEANUP_INTERVAL_MS4, RATE_LIMIT_WINDOW_MS3, generationGuard2, sessions3, rateLimits3, cleanupInterval4, MissionInterviewStreamManager, missionInterviewStreamManager;
77171
78828
  var init_mission_interview = __esm({
77172
78829
  "../dashboard/src/mission-interview.ts"() {
77173
78830
  "use strict";
77174
78831
  init_src();
77175
78832
  init_sse_buffer();
77176
78833
  init_ai_session_diagnostics();
78834
+ init_ai_session_timeout();
77177
78835
  init_src2();
77178
78836
  diagnostics4 = createSessionDiagnostics("mission-interview");
77179
78837
  SESSION_TTL_MS3 = 7 * 24 * 60 * 60 * 1e3;
77180
78838
  CLEANUP_INTERVAL_MS4 = 5 * 60 * 1e3;
77181
78839
  RATE_LIMIT_WINDOW_MS3 = 60 * 60 * 1e3;
78840
+ generationGuard2 = new GenerationGuard();
77182
78841
  sessions3 = /* @__PURE__ */ new Map();
77183
78842
  rateLimits3 = /* @__PURE__ */ new Map();
77184
78843
  cleanupInterval4 = setInterval(cleanupExpiredSessions3, CLEANUP_INTERVAL_MS4);
@@ -77258,6 +78917,7 @@ function cleanupInMemorySession2(sessionId) {
77258
78917
  if (!session) {
77259
78918
  return false;
77260
78919
  }
78920
+ generationGuard3.stop(sessionId);
77261
78921
  if (session.agent) {
77262
78922
  try {
77263
78923
  session.agent.session.dispose?.();
@@ -77282,19 +78942,21 @@ function cleanupExpiredSessions4() {
77282
78942
  }
77283
78943
  }
77284
78944
  }
77285
- var diagnostics5, SESSION_TTL_MS4, CLEANUP_INTERVAL_MS5, RATE_LIMIT_WINDOW_MS4, sessions4, rateLimits4, cleanupInterval5, MilestoneSliceInterviewStreamManager, milestoneSliceInterviewStreamManager;
78945
+ var diagnostics5, SESSION_TTL_MS4, CLEANUP_INTERVAL_MS5, RATE_LIMIT_WINDOW_MS4, generationGuard3, sessions4, rateLimits4, cleanupInterval5, MilestoneSliceInterviewStreamManager, milestoneSliceInterviewStreamManager;
77286
78946
  var init_milestone_slice_interview = __esm({
77287
78947
  "../dashboard/src/milestone-slice-interview.ts"() {
77288
78948
  "use strict";
77289
78949
  init_sse_buffer();
77290
78950
  init_mission_interview();
77291
78951
  init_ai_session_diagnostics();
78952
+ init_ai_session_timeout();
77292
78953
  init_mission_interview();
77293
78954
  init_src2();
77294
78955
  diagnostics5 = createSessionDiagnostics("milestone-slice-interview");
77295
78956
  SESSION_TTL_MS4 = 7 * 24 * 60 * 60 * 1e3;
77296
78957
  CLEANUP_INTERVAL_MS5 = 5 * 60 * 1e3;
77297
78958
  RATE_LIMIT_WINDOW_MS4 = 60 * 60 * 1e3;
78959
+ generationGuard3 = new GenerationGuard();
77298
78960
  sessions4 = /* @__PURE__ */ new Map();
77299
78961
  rateLimits4 = /* @__PURE__ */ new Map();
77300
78962
  cleanupInterval5 = setInterval(cleanupExpiredSessions4, CLEANUP_INTERVAL_MS5);
@@ -78139,6 +79801,7 @@ var init_register_task_workflow_routes = __esm({
78139
79801
  var init_register_planning_subtask_routes = __esm({
78140
79802
  "../dashboard/src/routes/register-planning-subtask-routes.ts"() {
78141
79803
  "use strict";
79804
+ init_src();
78142
79805
  init_api_error();
78143
79806
  init_sse_buffer();
78144
79807
  }
@@ -80381,16 +82044,26 @@ var init_register_agents_projects_nodes = __esm({
80381
82044
  }
80382
82045
  });
80383
82046
 
80384
- // ../dashboard/src/routes/register-project-routes.ts
82047
+ // ../dashboard/src/exec-file.ts
80385
82048
  import { execFile as execFile5 } from "node:child_process";
80386
- import * as fsPromises from "node:fs/promises";
80387
82049
  import { promisify as promisify12 } from "node:util";
80388
- var access3, stat6, mkdir12, readdir8, rm2, execFileAsync3;
82050
+ var execFileAsync3;
82051
+ var init_exec_file = __esm({
82052
+ "../dashboard/src/exec-file.ts"() {
82053
+ "use strict";
82054
+ execFileAsync3 = promisify12(execFile5);
82055
+ }
82056
+ });
82057
+
82058
+ // ../dashboard/src/routes/register-project-routes.ts
82059
+ import * as fsPromises from "node:fs/promises";
82060
+ var access3, stat6, mkdir12, readdir8, rm2;
80389
82061
  var init_register_project_routes = __esm({
80390
82062
  "../dashboard/src/routes/register-project-routes.ts"() {
80391
82063
  "use strict";
80392
82064
  init_src();
80393
82065
  init_api_error();
82066
+ init_exec_file();
80394
82067
  init_project_store_resolver();
80395
82068
  ({
80396
82069
  access: access3,
@@ -80399,7 +82072,6 @@ var init_register_project_routes = __esm({
80399
82072
  readdir: readdir8,
80400
82073
  rm: rm2
80401
82074
  } = fsPromises);
80402
- execFileAsync3 = promisify12(execFile5);
80403
82075
  }
80404
82076
  });
80405
82077
 
@@ -85738,12 +87410,29 @@ var init_project_context = __esm({
85738
87410
  }
85739
87411
  });
85740
87412
 
87413
+ // src/commands/node.ts
87414
+ import { createInterface } from "node:readline/promises";
87415
+ async function findNodeByNameOrId(central, nameOrId) {
87416
+ const byId = await central.getNode(nameOrId);
87417
+ if (byId) {
87418
+ return byId;
87419
+ }
87420
+ return central.getNodeByName(nameOrId);
87421
+ }
87422
+ var init_node = __esm({
87423
+ "src/commands/node.ts"() {
87424
+ "use strict";
87425
+ init_src();
87426
+ }
87427
+ });
87428
+
85741
87429
  // src/commands/task.ts
85742
87430
  var task_exports = {};
85743
87431
  __export(task_exports, {
85744
87432
  fetchGitHubIssues: () => fetchGitHubIssues,
85745
87433
  runTaskArchive: () => runTaskArchive,
85746
87434
  runTaskAttach: () => runTaskAttach,
87435
+ runTaskClearNode: () => runTaskClearNode,
85747
87436
  runTaskComment: () => runTaskComment,
85748
87437
  runTaskComments: () => runTaskComments,
85749
87438
  runTaskCreate: () => runTaskCreate,
@@ -85761,13 +87450,14 @@ __export(task_exports, {
85761
87450
  runTaskPrCreate: () => runTaskPrCreate,
85762
87451
  runTaskRefine: () => runTaskRefine,
85763
87452
  runTaskRetry: () => runTaskRetry,
87453
+ runTaskSetNode: () => runTaskSetNode,
85764
87454
  runTaskShow: () => runTaskShow,
85765
87455
  runTaskSteer: () => runTaskSteer,
85766
87456
  runTaskUnarchive: () => runTaskUnarchive,
85767
87457
  runTaskUnpause: () => runTaskUnpause,
85768
87458
  runTaskUpdate: () => runTaskUpdate
85769
87459
  });
85770
- import { createInterface } from "node:readline/promises";
87460
+ import { createInterface as createInterface2 } from "node:readline/promises";
85771
87461
  import { watchFile, unwatchFile, statSync as statSync6, existsSync as existsSync30, readFileSync as readFileSync9 } from "node:fs";
85772
87462
  import { join as join37 } from "node:path";
85773
87463
  function asLocalProjectContext(store) {
@@ -85832,11 +87522,28 @@ async function getProjectContext(projectName) {
85832
87522
  async function getProjectPath(projectName) {
85833
87523
  return (await getCommandContext(projectName)).projectPath;
85834
87524
  }
85835
- async function runTaskCreate(descriptionArg, attachFiles, depends, projectName) {
87525
+ async function resolveNodeByNameOrId(nodeNameOrId) {
87526
+ const central = new CentralCore();
87527
+ await central.init();
87528
+ try {
87529
+ const looksLikeNodeId = nodeNameOrId.includes("-") && nodeNameOrId.length > 20;
87530
+ let node = looksLikeNodeId ? await central.getNode(nodeNameOrId) : await central.getNodeByName(nodeNameOrId);
87531
+ if (!node) {
87532
+ node = await findNodeByNameOrId(central, nodeNameOrId);
87533
+ }
87534
+ if (!node) {
87535
+ throw new Error(`Node not found: ${nodeNameOrId}`);
87536
+ }
87537
+ return { id: node.id, name: node.name };
87538
+ } finally {
87539
+ await central.close();
87540
+ }
87541
+ }
87542
+ async function runTaskCreate(descriptionArg, attachFiles, depends, projectName, nodeName) {
85836
87543
  let description = descriptionArg;
85837
87544
  const projectContext = await getProjectContext(projectName);
85838
87545
  if (!description) {
85839
- const rl = createInterface({ input: process.stdin, output: process.stdout });
87546
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
85840
87547
  description = await rl.question("Task description: ");
85841
87548
  rl.close();
85842
87549
  }
@@ -85846,6 +87553,16 @@ async function runTaskCreate(descriptionArg, attachFiles, depends, projectName)
85846
87553
  }
85847
87554
  const store = projectContext?.store ?? await getStore(projectName);
85848
87555
  const task = await store.createTask({ description: description.trim(), dependencies: depends });
87556
+ let resolvedNode;
87557
+ if (nodeName) {
87558
+ try {
87559
+ resolvedNode = await resolveNodeByNameOrId(nodeName);
87560
+ await store.updateTask(task.id, { nodeId: resolvedNode.id });
87561
+ } catch (error) {
87562
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
87563
+ process.exit(1);
87564
+ }
87565
+ }
85849
87566
  const label = task.description.length > 60 ? task.description.slice(0, 60) + "\u2026" : task.description;
85850
87567
  console.log();
85851
87568
  if (projectContext) {
@@ -85856,6 +87573,9 @@ async function runTaskCreate(descriptionArg, attachFiles, depends, projectName)
85856
87573
  if (task.dependencies.length > 0) {
85857
87574
  console.log(` Dependencies: ${task.dependencies.join(", ")}`);
85858
87575
  }
87576
+ if (resolvedNode) {
87577
+ console.log(` Node: ${resolvedNode.name || resolvedNode.id}`);
87578
+ }
85859
87579
  console.log(` Path: .fusion/tasks/${task.id}/`);
85860
87580
  if (attachFiles && attachFiles.length > 0) {
85861
87581
  const { readFile: readFile19 } = await import("node:fs/promises");
@@ -86047,15 +87767,62 @@ async function runTaskLogs(id, options = {}, projectName) {
86047
87767
  });
86048
87768
  }
86049
87769
  }
87770
+ async function runTaskSetNode(id, nodeNameOrId, projectName) {
87771
+ const store = await getStore(projectName);
87772
+ const task = await store.getTask(id);
87773
+ if (task.column === "in-progress") {
87774
+ console.error(`Cannot change node override: task ${id} is in progress`);
87775
+ process.exit(1);
87776
+ }
87777
+ let resolvedNode;
87778
+ try {
87779
+ resolvedNode = await resolveNodeByNameOrId(nodeNameOrId);
87780
+ } catch (error) {
87781
+ console.error(error instanceof Error ? error.message : String(error));
87782
+ process.exit(1);
87783
+ return;
87784
+ }
87785
+ await store.updateTask(id, { nodeId: resolvedNode.id });
87786
+ console.log(`\u2713 Set node override for ${id}: ${resolvedNode.name || resolvedNode.id}`);
87787
+ }
87788
+ async function runTaskClearNode(id, projectName) {
87789
+ const store = await getStore(projectName);
87790
+ const task = await store.getTask(id);
87791
+ if (task.column === "in-progress") {
87792
+ console.error(`Cannot change node override: task ${id} is in progress`);
87793
+ process.exit(1);
87794
+ }
87795
+ await store.updateTask(id, { nodeId: null });
87796
+ console.log(`\u2713 Cleared node override for ${id}`);
87797
+ }
86050
87798
  async function runTaskShow(id, projectName) {
86051
87799
  const store = await getStore(projectName);
86052
87800
  const task = await store.getTask(id);
87801
+ const settings = "getSettings" in store ? await store.getSettings() : {};
87802
+ let nodeSummary = "(default local)";
87803
+ if (task.nodeId) {
87804
+ let nodeName;
87805
+ const central = new CentralCore();
87806
+ await central.init();
87807
+ try {
87808
+ nodeName = (await central.getNode(task.nodeId))?.name;
87809
+ } finally {
87810
+ await central.close();
87811
+ }
87812
+ nodeSummary = nodeName ? `${nodeName} (${task.nodeId})` : task.nodeId;
87813
+ } else if (settings.defaultNodeId) {
87814
+ nodeSummary = `project default: ${settings.defaultNodeId}`;
87815
+ }
86053
87816
  console.log();
86054
87817
  console.log(` ${task.id}: ${task.title || task.description}`);
86055
87818
  console.log(` Column: ${COLUMN_LABELS[task.column]}${task.size ? ` \xB7 Size: ${task.size}` : ""}${task.reviewLevel !== void 0 ? ` \xB7 Review: ${task.reviewLevel}` : ""}`);
86056
87819
  if (task.dependencies.length) {
86057
87820
  console.log(` Dependencies: ${task.dependencies.join(", ")}`);
86058
87821
  }
87822
+ console.log(` Node: ${nodeSummary}`);
87823
+ if (settings.unavailableNodePolicy) {
87824
+ console.log(` Unavailable Node Policy: ${settings.unavailableNodePolicy}`);
87825
+ }
86059
87826
  console.log();
86060
87827
  if (task.steps.length > 0) {
86061
87828
  console.log(` Steps (${task.steps.filter((s) => s.status === "done").length}/${task.steps.length}):`);
@@ -86172,7 +87939,7 @@ async function runTaskRefine(id, feedbackArg, projectName) {
86172
87939
  const store = await getStore(projectName);
86173
87940
  let feedback = feedbackArg;
86174
87941
  if (feedback === void 0) {
86175
- const rl = createInterface({ input: process.stdin, output: process.stdout });
87942
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86176
87943
  feedback = await rl.question("What needs to be refined? ");
86177
87944
  rl.close();
86178
87945
  }
@@ -86243,7 +88010,7 @@ async function runTaskDelete(id, force, projectName) {
86243
88010
  return;
86244
88011
  }
86245
88012
  if (!force) {
86246
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88013
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86247
88014
  const answer = await rl.question(`Are you sure you want to delete ${id}? [y/N] `);
86248
88015
  rl.close();
86249
88016
  const trimmed = answer.trim().toLowerCase();
@@ -86307,7 +88074,7 @@ async function runTaskImportGitHubInteractive(ownerRepo, options = {}, projectNa
86307
88074
  console.log(` ${i + 1}. #${issue.number} ${issue.title.slice(0, 80)}${issue.title.length > 80 ? "\u2026" : ""}${status}`);
86308
88075
  }
86309
88076
  console.log();
86310
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88077
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86311
88078
  let selectedIndices = [];
86312
88079
  let validInput = false;
86313
88080
  while (!validInput) {
@@ -86464,7 +88231,7 @@ async function runTaskComment(id, message, author = "user", projectName) {
86464
88231
  const store = await getStore(projectName);
86465
88232
  let text = message;
86466
88233
  if (text === void 0) {
86467
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88234
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86468
88235
  text = await rl.question("Comment: ");
86469
88236
  rl.close();
86470
88237
  }
@@ -86507,7 +88274,7 @@ async function runTaskSteer(id, message, projectName) {
86507
88274
  const store = await getStore(projectName);
86508
88275
  let text = message;
86509
88276
  if (text === void 0) {
86510
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88277
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86511
88278
  text = await rl.question("Message: ");
86512
88279
  rl.close();
86513
88280
  }
@@ -86638,7 +88405,7 @@ async function promptText(question) {
86638
88405
  console.log(` ${question.description}`);
86639
88406
  }
86640
88407
  console.log(" (Enter your response. Type DONE on its own line when finished):\n");
86641
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88408
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86642
88409
  const lines = [];
86643
88410
  return new Promise((resolve17) => {
86644
88411
  const askLine = () => {
@@ -86672,7 +88439,7 @@ async function promptSingleSelect(question) {
86672
88439
  console.log(` ${opt.description}`);
86673
88440
  }
86674
88441
  }
86675
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88442
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86676
88443
  while (true) {
86677
88444
  const answer = await rl.question("\n Select (1-" + question.options.length + "): ");
86678
88445
  const num = parseInt(answer.trim(), 10);
@@ -86700,7 +88467,7 @@ async function promptMultiSelect(question) {
86700
88467
  console.log(` ${opt.description}`);
86701
88468
  }
86702
88469
  }
86703
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88470
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86704
88471
  while (true) {
86705
88472
  const answer = await rl.question("\n Select (comma-separated): ");
86706
88473
  const nums = answer.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
@@ -86723,7 +88490,7 @@ async function promptConfirm(question) {
86723
88490
  if (question.description) {
86724
88491
  console.log(` ${question.description}`);
86725
88492
  }
86726
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88493
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86727
88494
  const answer = await rl.question("\n [Y/n]: ");
86728
88495
  rl.close();
86729
88496
  const trimmed = answer.trim().toLowerCase();
@@ -86788,7 +88555,7 @@ function wrapText(text, width) {
86788
88555
  async function runTaskPlan(initialPlanArg, yesFlag = false, projectName) {
86789
88556
  let initialPlan = initialPlanArg;
86790
88557
  if (!initialPlan) {
86791
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88558
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86792
88559
  console.log("\n Let's plan your task. What would you like to accomplish?\n");
86793
88560
  initialPlan = await rl.question(" Describe your idea: ");
86794
88561
  rl.close();
@@ -86889,7 +88656,7 @@ async function runTaskPlan(initialPlanArg, yesFlag = false, projectName) {
86889
88656
  displaySummary(result.data);
86890
88657
  let confirmed = yesFlag;
86891
88658
  if (!yesFlag) {
86892
- const rl = createInterface({ input: process.stdin, output: process.stdout });
88659
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
86893
88660
  const answer = await rl.question(" Create this task? [Y/n]: ");
86894
88661
  rl.close();
86895
88662
  const trimmed = answer.trim().toLowerCase();
@@ -86931,6 +88698,7 @@ var init_task = __esm({
86931
88698
  init_src4();
86932
88699
  init_gh_cli();
86933
88700
  init_project_context();
88701
+ init_node();
86934
88702
  STEP_STATUSES2 = ["pending", "in-progress", "done", "skipped"];
86935
88703
  ANSI = {
86936
88704
  reset: "\x1B[0m",
@@ -87143,17 +88911,17 @@ async function fetchGitHubIssuesViaGh(owner, repo, options = {}) {
87143
88911
  }
87144
88912
  const path2 = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${queryParams.toString()}`;
87145
88913
  try {
87146
- const issues = await runGhJsonAsync(["api", path2]);
88914
+ const issues = await runGhJsonAsync(["api", path2], { signal: options.signal });
87147
88915
  return issues.filter((issue) => !issue.pull_request);
87148
88916
  } catch (error) {
87149
88917
  throw new Error(getGhErrorMessage(error));
87150
88918
  }
87151
88919
  }
87152
- async function fetchGitHubIssueViaGh(owner, repo, issueNumber) {
88920
+ async function fetchGitHubIssueViaGh(owner, repo, issueNumber, options = {}) {
87153
88921
  ensureGhCliAuth();
87154
88922
  const path2 = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`;
87155
88923
  try {
87156
- return await runGhJsonAsync(["api", path2]);
88924
+ return await runGhJsonAsync(["api", path2], { signal: options.signal });
87157
88925
  } catch (error) {
87158
88926
  throw new Error(getGhErrorMessage(error));
87159
88927
  }
@@ -87234,12 +89002,18 @@ Column: triage
87234
89002
  ], {
87235
89003
  description: "Agent ID to assign this task to, or null to clear (e.g. 'agent-abc123')"
87236
89004
  })
89005
+ ),
89006
+ nodeId: Type7.Optional(
89007
+ Type7.Union([Type7.String(), Type7.Null()], {
89008
+ description: "Node ID override for this task, or null to clear"
89009
+ })
87237
89010
  )
87238
89011
  }),
87239
89012
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
87240
89013
  const store = await getStore2(ctx.cwd);
89014
+ let task;
87241
89015
  try {
87242
- await store.getTask(params.id);
89016
+ task = await store.getTask(params.id);
87243
89017
  } catch {
87244
89018
  return {
87245
89019
  content: [{ type: "text", text: `Task ${params.id} not found` }],
@@ -87265,9 +89039,21 @@ Column: triage
87265
89039
  updates.assignedAgentId = params.agentId;
87266
89040
  updatedFields.push("agentId");
87267
89041
  }
89042
+ if (params.nodeId !== void 0) {
89043
+ const validation = validateNodeOverrideChange(task, params.nodeId ?? null);
89044
+ if (!validation.allowed) {
89045
+ return {
89046
+ content: [{ type: "text", text: validation.message ?? "Node override change blocked" }],
89047
+ isError: true,
89048
+ details: { error: validation.reason }
89049
+ };
89050
+ }
89051
+ updates.nodeId = params.nodeId;
89052
+ updatedFields.push("nodeId");
89053
+ }
87268
89054
  if (updatedFields.length === 0) {
87269
89055
  return {
87270
- content: [{ type: "text", text: "No fields to update. Provide at least one of: title, description, depends, agentId." }],
89056
+ content: [{ type: "text", text: "No fields to update. Provide at least one of: title, description, depends, agentId, nodeId." }],
87271
89057
  isError: true,
87272
89058
  details: { error: "No fields provided" }
87273
89059
  };
@@ -87648,11 +89434,11 @@ Path: .fusion/tasks/${params.id}/attachments/${attachment.filename}`
87648
89434
  })
87649
89435
  )
87650
89436
  }),
87651
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
89437
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
87652
89438
  const [owner, repo] = params.ownerRepo.split("/");
87653
89439
  const limit = params.limit ?? 30;
87654
89440
  const labels = params.labels;
87655
- const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels });
89441
+ const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels, signal });
87656
89442
  if (issues.length === 0) {
87657
89443
  return {
87658
89444
  content: [{ type: "text", text: `No open issues found in ${owner}/${repo}.` }],
@@ -87727,9 +89513,9 @@ ${createdTasks.map((task) => ` ${task.id}: ${task.title}`).join("\n") || " Non
87727
89513
  minimum: 1
87728
89514
  })
87729
89515
  }),
87730
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
89516
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
87731
89517
  const { owner, repo, issueNumber } = params;
87732
- const issue = await fetchGitHubIssueViaGh(owner, repo, issueNumber);
89518
+ const issue = await fetchGitHubIssueViaGh(owner, repo, issueNumber, { signal });
87733
89519
  if (issue.pull_request) {
87734
89520
  throw new Error(`#${issueNumber} is a pull request, not an issue`);
87735
89521
  }
@@ -87813,9 +89599,9 @@ ${sourceUrl}`
87813
89599
  })
87814
89600
  )
87815
89601
  }),
87816
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
89602
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
87817
89603
  const { owner, repo, limit = 30, labels } = params;
87818
- const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels });
89604
+ const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels, signal });
87819
89605
  if (issues.length === 0) {
87820
89606
  return {
87821
89607
  content: [{ type: "text", text: `No open issues found in ${owner}/${repo}.` }],