@harness-engineering/orchestrator 0.2.13 → 0.2.15

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.
package/dist/index.js CHANGED
@@ -676,7 +676,26 @@ function handleTick(state, event, config) {
676
676
  continue;
677
677
  }
678
678
  const scopeTier = detectScopeTier(issue, artifactPresenceFromIssue(issue));
679
- const signals = event.concernSignals?.get(issue.id) ?? [];
679
+ const signals = [...event.concernSignals?.get(issue.id) ?? []];
680
+ let suggestedPersona;
681
+ try {
682
+ const personaRecs = event.personaRecommendations?.get(issue.id);
683
+ if (personaRecs && personaRecs.length > 0) {
684
+ suggestedPersona = personaRecs[0].persona;
685
+ if (personaRecs[0].weightedScore < 0.3) {
686
+ signals.push({
687
+ name: "lowExpertise",
688
+ reason: `Top persona "${suggestedPersona}" scored ${personaRecs[0].weightedScore.toFixed(2)} (below 0.3 threshold)`
689
+ });
690
+ }
691
+ } else if (personaRecs && personaRecs.length === 0) {
692
+ signals.push({
693
+ name: "noPersonaMatch",
694
+ reason: "No persona recommendations available for this issue's systems"
695
+ });
696
+ }
697
+ } catch {
698
+ }
680
699
  const decision = routeIssue(scopeTier, signals, escalationConfig);
681
700
  if (decision.action === "needs-human") {
682
701
  next.claimed.add(issue.id);
@@ -692,6 +711,12 @@ function handleTick(state, event, config) {
692
711
  }
693
712
  const backend = resolveBackend(decision.action, !!config.agent.localBackend);
694
713
  claimAndDispatch(next, issue, backend, nowMs, effects);
714
+ if (suggestedPersona) {
715
+ const lastEffect = effects[effects.length - 1];
716
+ if (lastEffect && lastEffect.type === "claim") {
717
+ lastEffect.suggestedPersona = suggestedPersona;
718
+ }
719
+ }
695
720
  }
696
721
  pruneCompleted(next);
697
722
  return { nextState: next, effects };
@@ -818,7 +843,7 @@ function handleAgentUpdate(state, issueId, event) {
818
843
  }
819
844
  return { nextState: next, effects };
820
845
  }
821
- function handleRetryFired(state, issueId, candidates, config, nowMs) {
846
+ function handleRetryFired(state, issueId, candidates, config, nowMs, concernSignals) {
822
847
  const next = cloneState(state);
823
848
  const effects = [];
824
849
  const retryEntry = next.retryAttempts.get(issueId);
@@ -875,7 +900,8 @@ function handleRetryFired(state, issueId, candidates, config, nowMs) {
875
900
  }
876
901
  const escalationConfig = resolveEscalationConfig(config);
877
902
  const scopeTier = detectScopeTier(issue, artifactPresenceFromIssue(issue));
878
- const decision = routeIssue(scopeTier, [], escalationConfig);
903
+ const signals = [...concernSignals?.get(issue.id) ?? []];
904
+ const decision = routeIssue(scopeTier, signals, escalationConfig);
879
905
  if (decision.action === "needs-human") {
880
906
  effects.push(
881
907
  buildEscalateEffect(issue.id, issue.identifier, decision.reasons, {
@@ -955,7 +981,14 @@ function applyEvent(state, event, config) {
955
981
  case "agent_update":
956
982
  return handleAgentUpdate(state, event.issueId, event.event);
957
983
  case "retry_fired":
958
- return handleRetryFired(state, event.issueId, event.candidates, config, event.nowMs);
984
+ return handleRetryFired(
985
+ state,
986
+ event.issueId,
987
+ event.candidates,
988
+ config,
989
+ event.nowMs,
990
+ event.concernSignals
991
+ );
959
992
  case "stall_detected":
960
993
  return handleStallDetected(state, event.issueId, config);
961
994
  case "claim_rejected":
@@ -2407,10 +2440,15 @@ var WorkspaceHooks = class {
2407
2440
  return (0, import_types7.Ok)(void 0);
2408
2441
  }
2409
2442
  return new Promise((resolve6) => {
2410
- const child = (0, import_node_child_process3.spawn)(command, {
2411
- shell: true,
2443
+ const filteredEnv = {};
2444
+ for (const [k, v] of Object.entries(process.env)) {
2445
+ if (v != null && !k.includes("SECRET") && !k.includes("TOKEN") && !k.includes("PASSWORD")) {
2446
+ filteredEnv[k] = v;
2447
+ }
2448
+ }
2449
+ const child = (0, import_node_child_process3.spawn)("/bin/sh", ["-c", command], {
2412
2450
  cwd,
2413
- env: process.env
2451
+ env: filteredEnv
2414
2452
  });
2415
2453
  const timeout = setTimeout(() => {
2416
2454
  child.kill();
@@ -2519,11 +2557,12 @@ var import_node_events = require("events");
2519
2557
  var path15 = __toESM(require("path"));
2520
2558
  var import_node_crypto7 = require("crypto");
2521
2559
  var import_core9 = require("@harness-engineering/core");
2522
- var import_intelligence2 = require("@harness-engineering/intelligence");
2560
+ var import_intelligence3 = require("@harness-engineering/intelligence");
2523
2561
  var import_graph = require("@harness-engineering/graph");
2524
2562
 
2525
2563
  // src/intelligence/pipeline-runner.ts
2526
2564
  var path7 = __toESM(require("path"));
2565
+ var import_intelligence = require("@harness-engineering/intelligence");
2527
2566
  var import_core2 = require("@harness-engineering/core");
2528
2567
  var CONNECTION_ERROR_PATTERNS = [
2529
2568
  "Connection error",
@@ -2552,6 +2591,7 @@ var IntelligencePipelineRunner = class {
2552
2591
  this.graphLoaded = true;
2553
2592
  await this.loadGraphStore();
2554
2593
  await this.hydrateSpecCache();
2594
+ this.refreshSpecializationProfiles();
2555
2595
  }
2556
2596
  /**
2557
2597
  * Runs the full intelligence pipeline for the given candidates:
@@ -2605,11 +2645,39 @@ var IntelligencePipelineRunner = class {
2605
2645
  } catch (err) {
2606
2646
  this.ctx.logger.warn("Auto-publish analyses failed", { error: String(err) });
2607
2647
  }
2608
- return { concernSignals, enrichedSpecs, complexityScores, simulationResults };
2648
+ this.refreshSpecializationProfiles();
2649
+ setTickActivity("analyzing", "Scoring persona recommendations");
2650
+ const personaRecommendations = this.computePersonaRecommendations(candidates);
2651
+ return {
2652
+ concernSignals,
2653
+ enrichedSpecs,
2654
+ complexityScores,
2655
+ simulationResults,
2656
+ personaRecommendations
2657
+ };
2609
2658
  }
2610
2659
  // ---------------------------------------------------------------------------
2611
2660
  // Private helpers
2612
2661
  // ---------------------------------------------------------------------------
2662
+ /**
2663
+ * Refresh specialization profiles from the graph store. This recomputes
2664
+ * expertise scores for all personas with execution_outcome nodes and
2665
+ * persists them to .harness/specialization-profiles.json. Non-fatal.
2666
+ */
2667
+ refreshSpecializationProfiles() {
2668
+ if (!this.ctx.graphStore) return;
2669
+ try {
2670
+ const store = (0, import_intelligence.refreshProfiles)(this.ctx.projectRoot, this.ctx.graphStore);
2671
+ const personaCount = Object.keys(store.profiles).length;
2672
+ if (personaCount > 0) {
2673
+ this.ctx.logger.info(`Refreshed specialization profiles for ${personaCount} persona(s)`);
2674
+ }
2675
+ } catch (err) {
2676
+ this.ctx.logger.warn("Failed to refresh specialization profiles", {
2677
+ error: String(err)
2678
+ });
2679
+ }
2680
+ }
2613
2681
  async loadGraphStore() {
2614
2682
  try {
2615
2683
  const graphDir = path7.join(this.ctx.config.workspace.root, "..", "graph");
@@ -2768,7 +2836,22 @@ var IntelligencePipelineRunner = class {
2768
2836
  const scopeTier = detectScopeTier(issue, artifactPresenceFromIssue(issue));
2769
2837
  try {
2770
2838
  const result = await this.ctx.pipeline.preprocessIssue(issue, scopeTier, escalationConfig);
2771
- if (result.signals.length > 0) concernSignals.set(issue.id, result.signals);
2839
+ const signals = [...result.signals];
2840
+ if (result.spec) {
2841
+ if (result.spec.unknowns.length > 3) {
2842
+ signals.push({
2843
+ name: "high-unknowns",
2844
+ reason: `Enriched spec has ${result.spec.unknowns.length} unknowns (threshold: 3)`
2845
+ });
2846
+ }
2847
+ if (result.spec.ambiguities.length > 5) {
2848
+ signals.push({
2849
+ name: "high-ambiguities",
2850
+ reason: `Enriched spec has ${result.spec.ambiguities.length} ambiguities (threshold: 5)`
2851
+ });
2852
+ }
2853
+ }
2854
+ if (signals.length > 0) concernSignals.set(issue.id, signals);
2772
2855
  if (result.spec) {
2773
2856
  enrichedSpecs.set(issue.id, result.spec);
2774
2857
  this.ctx.enrichedSpecsByIssue.set(issue.id, result.spec);
@@ -2815,6 +2898,30 @@ var IntelligencePipelineRunner = class {
2815
2898
  return true;
2816
2899
  });
2817
2900
  }
2901
+ /**
2902
+ * Compute persona recommendations for each candidate using the specialization
2903
+ * scorer. Extracts system node IDs from issue labels prefixed with `system:`
2904
+ * or `module:`. Failures are non-fatal — returns an empty map on error.
2905
+ */
2906
+ computePersonaRecommendations(candidates) {
2907
+ const results = /* @__PURE__ */ new Map();
2908
+ if (!this.ctx.graphStore) return results;
2909
+ try {
2910
+ for (const issue of candidates) {
2911
+ const systemNodeIds = issue.labels.filter((l) => l.startsWith("system:") || l.startsWith("module:")).map((l) => l.split(":")[1]).filter((id) => id.length > 0);
2912
+ if (systemNodeIds.length === 0) continue;
2913
+ const recs = (0, import_intelligence.weightedRecommendPersona)(this.ctx.graphStore, { systemNodeIds });
2914
+ if (recs.length > 0) {
2915
+ results.set(issue.id, recs);
2916
+ }
2917
+ }
2918
+ } catch (err) {
2919
+ this.ctx.logger.warn("Persona recommendation scoring failed", {
2920
+ error: String(err)
2921
+ });
2922
+ }
2923
+ return results;
2924
+ }
2818
2925
  async analyzeCandidates(eligibleCandidates, escalationConfig, nowForCache, failureTtl, circuitBreakerThreshold, concernSignals, enrichedSpecs, complexityScores, setTickActivity) {
2819
2926
  let processed = 0;
2820
2927
  let consecutiveConnErrors = 0;
@@ -2892,10 +2999,27 @@ var CompletionHandler = class {
2892
2999
  // ---------------------------------------------------------------------------
2893
3000
  // Private helpers
2894
3001
  // ---------------------------------------------------------------------------
3002
+ /**
3003
+ * Infer a TaskType from issue labels.
3004
+ * Looks for common label patterns: bug/bugfix → 'bugfix', feat/feature → 'feature', etc.
3005
+ */
3006
+ inferTaskType(labels) {
3007
+ const joined = labels.map((l) => l.toLowerCase()).join(" ");
3008
+ if (/\bbug(fix)?\b/.test(joined)) return "bugfix";
3009
+ if (/\bfeat(ure)?\b/.test(joined)) return "feature";
3010
+ if (/\brefactor\b/.test(joined)) return "refactor";
3011
+ if (/\bdoc(s|umentation)?\b/.test(joined)) return "docs";
3012
+ if (/\btest(s|ing)?\b/.test(joined)) return "test";
3013
+ if (/\bchore\b/.test(joined)) return "chore";
3014
+ return void 0;
3015
+ }
2895
3016
  async recordOutcomeIfPipelineEnabled(issueId, reason, attempt, error, entry) {
2896
3017
  if (!this.ctx.pipeline) return;
2897
3018
  const enrichedSpec = this.ctx.enrichedSpecsByIssue.get(issueId);
2898
3019
  const affectedSystemNodeIds = enrichedSpec ? enrichedSpec.affectedSystems.filter((s) => s.graphNodeId !== null).map((s) => s.graphNodeId) : [];
3020
+ const agentPersona = entry?.session?.backendName ?? this.ctx.config.agent.backend ?? "default";
3021
+ const labels = entry?.issue?.labels ?? [];
3022
+ const taskType = this.inferTaskType(labels);
2899
3023
  const outcome = {
2900
3024
  id: `outcome:${issueId}:${attempt ?? 0}`,
2901
3025
  issueId,
@@ -2906,7 +3030,9 @@ var CompletionHandler = class {
2906
3030
  durationMs: entry ? Date.now() - new Date(entry.startedAt).getTime() : 0,
2907
3031
  linkedSpecId: enrichedSpec?.id ?? null,
2908
3032
  affectedSystemNodeIds,
2909
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
3033
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3034
+ agentPersona,
3035
+ ...taskType ? { taskType } : {}
2910
3036
  };
2911
3037
  try {
2912
3038
  this.ctx.pipeline.recordOutcome(outcome);
@@ -4948,7 +5074,7 @@ function extractChunks(event) {
4948
5074
  }
4949
5075
 
4950
5076
  // src/server/routes/analyze.ts
4951
- var import_intelligence = require("@harness-engineering/intelligence");
5077
+ var import_intelligence2 = require("@harness-engineering/intelligence");
4952
5078
  var import_zod4 = require("zod");
4953
5079
  var AnalyzeRequestSchema = import_zod4.z.object({
4954
5080
  title: import_zod4.z.string().min(1),
@@ -4978,7 +5104,7 @@ async function runPipeline(res, pipeline, parsed) {
4978
5104
  disconnected = true;
4979
5105
  });
4980
5106
  emit2(res, { type: "status", text: "Converting to work item..." });
4981
- const rawItem = (0, import_intelligence.manualToRawWorkItem)({
5107
+ const rawItem = (0, import_intelligence2.manualToRawWorkItem)({
4982
5108
  title: parsed.title,
4983
5109
  description: parsed.description ?? "",
4984
5110
  labels: parsed.labels ?? []
@@ -5021,7 +5147,7 @@ async function runPipeline(res, pipeline, parsed) {
5021
5147
  }
5022
5148
  }
5023
5149
  if (disconnected) return;
5024
- const signals = (0, import_intelligence.scoreToConcernSignals)(score);
5150
+ const signals = (0, import_intelligence2.scoreToConcernSignals)(score);
5025
5151
  if (signals.length > 0) {
5026
5152
  emit2(res, { type: "signals", data: signals });
5027
5153
  }
@@ -5619,6 +5745,37 @@ var PlanWatcher = class {
5619
5745
  };
5620
5746
 
5621
5747
  // src/server/http.ts
5748
+ var RATE_LIMIT = Number(process.env["HARNESS_RATE_LIMIT"]) || 100;
5749
+ var WINDOW_MS = 6e4;
5750
+ var rateBuckets = /* @__PURE__ */ new Map();
5751
+ var ratePruneTimer = setInterval(() => {
5752
+ const now = Date.now();
5753
+ for (const [ip, bucket] of rateBuckets) {
5754
+ if (bucket.resetAt <= now) rateBuckets.delete(ip);
5755
+ }
5756
+ }, 6e4);
5757
+ ratePruneTimer.unref();
5758
+ function isLocalhost(ip) {
5759
+ return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
5760
+ }
5761
+ function checkRateLimit(req, res) {
5762
+ const ip = req.socket.remoteAddress ?? "unknown";
5763
+ if (!process.env["HARNESS_RATE_LIMIT_LOCALHOST"] && isLocalhost(ip)) return true;
5764
+ const now = Date.now();
5765
+ let bucket = rateBuckets.get(ip);
5766
+ if (!bucket || bucket.resetAt <= now) {
5767
+ bucket = { count: 0, resetAt: now + WINDOW_MS };
5768
+ rateBuckets.set(ip, bucket);
5769
+ }
5770
+ bucket.count++;
5771
+ if (bucket.count > RATE_LIMIT) {
5772
+ const retryAfter = Math.ceil((bucket.resetAt - now) / 1e3);
5773
+ res.writeHead(429, { "Content-Type": "application/json", "Retry-After": String(retryAfter) });
5774
+ res.end(JSON.stringify({ error: "Too Many Requests" }));
5775
+ return false;
5776
+ }
5777
+ return true;
5778
+ }
5622
5779
  function getBindHost() {
5623
5780
  return process.env["HOST"] ?? "127.0.0.1";
5624
5781
  }
@@ -5706,6 +5863,9 @@ var OrchestratorServer = class {
5706
5863
  if (this.handleStateEndpoint(req, res)) {
5707
5864
  return;
5708
5865
  }
5866
+ if (!checkRateLimit(req, res)) {
5867
+ return;
5868
+ }
5709
5869
  if (this.handleApiRoutes(req, res)) {
5710
5870
  return;
5711
5871
  }
@@ -6326,6 +6486,18 @@ var MaintenanceScheduler = class {
6326
6486
  // src/maintenance/reporter.ts
6327
6487
  var fs14 = __toESM(require("fs"));
6328
6488
  var path14 = __toESM(require("path"));
6489
+ var import_zod9 = require("zod");
6490
+ var RunResultSchema = import_zod9.z.object({
6491
+ taskId: import_zod9.z.string(),
6492
+ startedAt: import_zod9.z.string(),
6493
+ completedAt: import_zod9.z.string(),
6494
+ status: import_zod9.z.enum(["success", "failure", "skipped", "no-issues"]),
6495
+ findings: import_zod9.z.number(),
6496
+ fixed: import_zod9.z.number(),
6497
+ prUrl: import_zod9.z.string().nullable(),
6498
+ prUpdated: import_zod9.z.boolean(),
6499
+ error: import_zod9.z.string().optional()
6500
+ });
6329
6501
  var MAX_HISTORY = 500;
6330
6502
  var fallbackLogger = {
6331
6503
  info: () => {
@@ -6351,9 +6523,9 @@ var MaintenanceReporter = class {
6351
6523
  await fs14.promises.mkdir(this.persistDir, { recursive: true });
6352
6524
  const filePath = path14.join(this.persistDir, "history.json");
6353
6525
  const data = await fs14.promises.readFile(filePath, "utf-8");
6354
- const parsed = JSON.parse(data);
6355
- if (Array.isArray(parsed)) {
6356
- this.history = parsed.slice(0, MAX_HISTORY);
6526
+ const parsed = import_zod9.z.array(RunResultSchema).safeParse(JSON.parse(data));
6527
+ if (parsed.success) {
6528
+ this.history = parsed.data.slice(0, MAX_HISTORY);
6357
6529
  }
6358
6530
  } catch (err) {
6359
6531
  if (err instanceof Error && "code" in err && err.code === "ENOENT") {
@@ -6504,7 +6676,8 @@ var TaskRunner = class {
6504
6676
  const prResult = await this.prManager.ensurePR(task, summary);
6505
6677
  prUrl = prResult.prUrl;
6506
6678
  prUpdated = prResult.prUpdated;
6507
- } catch {
6679
+ } catch (err) {
6680
+ console.warn(`[maintenance] PR creation failed for task ${task.id}: ${String(err)}`);
6508
6681
  }
6509
6682
  }
6510
6683
  return {
@@ -6555,7 +6728,8 @@ var TaskRunner = class {
6555
6728
  const prResult = await this.prManager.ensurePR(task, summary);
6556
6729
  prUrl = prResult.prUrl;
6557
6730
  prUpdated = prResult.prUpdated;
6558
- } catch {
6731
+ } catch (err) {
6732
+ console.warn(`[maintenance] PR creation failed for task ${task.id}: ${String(err)}`);
6559
6733
  }
6560
6734
  }
6561
6735
  return {
@@ -6665,6 +6839,10 @@ var Orchestrator = class extends import_node_events.EventEmitter {
6665
6839
  enrichedSpecsByIssue = /* @__PURE__ */ new Map();
6666
6840
  /** Tracks recently-failed intelligence analysis to avoid re-requesting every tick */
6667
6841
  analysisFailureCache = /* @__PURE__ */ new Map();
6842
+ /** Abort controllers and PIDs for running agent tasks — used by stopIssue to cancel in-flight work.
6843
+ * The PID is stored here because the running entry may be deleted by the state machine
6844
+ * before the stop effect executes (e.g., stall_detected removes the entry first). */
6845
+ abortControllers = /* @__PURE__ */ new Map();
6668
6846
  /** Guards against overlapping ticks when a tick takes longer than the polling interval */
6669
6847
  tickInProgress = false;
6670
6848
  /** Timestamp of the last stale branch sweep (at most once per hour) */
@@ -6795,33 +6973,63 @@ var Orchestrator = class extends import_node_events.EventEmitter {
6795
6973
  }
6796
6974
  /**
6797
6975
  * Creates a TaskRunner for the maintenance scheduler.
6798
- * Provides stub implementations for check/agent/command execution.
6799
- * Phase 4 (PRManager) and Phase 5 (Reporter) will enhance these.
6976
+ * CheckCommandRunner and CommandExecutor use real child_process execution.
6977
+ * AgentDispatcher remains stubbed (requires full skill dispatch integration).
6800
6978
  */
6801
6979
  createMaintenanceTaskRunner(maintenanceConfig) {
6802
- this.logger.warn(
6803
- "Maintenance task runner using stub implementations \u2014 tasks will not execute real checks or dispatch agents. Real implementations will be wired in a follow-up."
6804
- );
6980
+ const logger = this.logger;
6805
6981
  const checkRunner = {
6806
6982
  run: async (command, cwd) => {
6807
- this.logger.info("Maintenance check runner invoked (stub)", { command, cwd });
6808
- return { passed: true, findings: 0, output: "" };
6983
+ const { execFile: execFile6 } = await import("child_process");
6984
+ const { promisify: promisify3 } = await import("util");
6985
+ const execFileAsync = promisify3(execFile6);
6986
+ const [cmd, ...args] = command;
6987
+ if (!cmd) return { passed: true, findings: 0, output: "" };
6988
+ try {
6989
+ const { stdout } = await execFileAsync(cmd, args, { cwd, timeout: 12e4 });
6990
+ const findingsMatch = stdout.match(/(\d+)\s+(?:finding|issue|violation|error)/i);
6991
+ const findings = findingsMatch ? parseInt(findingsMatch[1], 10) : 0;
6992
+ return { passed: findings === 0, findings, output: stdout };
6993
+ } catch (err) {
6994
+ const error = err;
6995
+ const output = [error.stdout, error.stderr].filter(Boolean).join("\n");
6996
+ const findingsMatch = output.match(/(\d+)\s+(?:finding|issue|violation|error)/i);
6997
+ const findings = findingsMatch ? parseInt(findingsMatch[1], 10) : 1;
6998
+ return { passed: false, findings, output };
6999
+ }
6809
7000
  }
6810
7001
  };
6811
7002
  const agentDispatcher = {
6812
7003
  dispatch: async (skill, branch, backendName, cwd) => {
6813
- this.logger.info("Maintenance agent dispatcher invoked (stub)", {
6814
- skill,
6815
- branch,
6816
- backendName,
6817
- cwd
6818
- });
7004
+ logger.info(
7005
+ "Maintenance agent dispatcher invoked (stub \u2014 skill dispatch integration pending)",
7006
+ {
7007
+ skill,
7008
+ branch,
7009
+ backendName,
7010
+ cwd
7011
+ }
7012
+ );
6819
7013
  return { producedCommits: false, fixed: 0 };
6820
7014
  }
6821
7015
  };
6822
7016
  const commandExecutor = {
6823
7017
  exec: async (command, cwd) => {
6824
- this.logger.info("Maintenance command executor invoked (stub)", { command, cwd });
7018
+ const { execFile: execFile6 } = await import("child_process");
7019
+ const { promisify: promisify3 } = await import("util");
7020
+ const execFileAsync = promisify3(execFile6);
7021
+ const [cmd, ...args] = command;
7022
+ if (!cmd) return;
7023
+ try {
7024
+ await execFileAsync(cmd, args, { cwd, timeout: 12e4 });
7025
+ } catch (err) {
7026
+ logger.warn("Maintenance command execution failed", {
7027
+ command,
7028
+ cwd,
7029
+ error: String(err)
7030
+ });
7031
+ throw err;
7032
+ }
6825
7033
  }
6826
7034
  };
6827
7035
  return new TaskRunner({
@@ -6851,19 +7059,18 @@ var Orchestrator = class extends import_node_events.EventEmitter {
6851
7059
  historyProvider: reporter,
6852
7060
  onTaskDue: async (task) => {
6853
7061
  this.logger.info(`Maintenance task due: ${task.id}`, { taskId: task.id });
6854
- this.server?.broadcastMaintenance("maintenance:started", {
6855
- taskId: task.id,
6856
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
6857
- });
7062
+ const startPayload = { taskId: task.id, startedAt: (/* @__PURE__ */ new Date()).toISOString() };
7063
+ this.server?.broadcastMaintenance("maintenance:started", startPayload);
7064
+ this.emit("maintenance:started", startPayload);
6858
7065
  const result = await taskRunner.run(task);
6859
7066
  await reporter.record(result);
6860
7067
  if (result.status === "failure") {
6861
- this.server?.broadcastMaintenance("maintenance:error", {
6862
- taskId: task.id,
6863
- error: result.error
6864
- });
7068
+ const errorPayload = { taskId: task.id, error: result.error };
7069
+ this.server?.broadcastMaintenance("maintenance:error", errorPayload);
7070
+ this.emit("maintenance:error", errorPayload);
6865
7071
  } else {
6866
7072
  this.server?.broadcastMaintenance("maintenance:completed", result);
7073
+ this.emit("maintenance:completed", result);
6867
7074
  }
6868
7075
  this.logger.info(`Maintenance task completed: ${task.id}`, {
6869
7076
  taskId: task.id,
@@ -6916,7 +7123,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
6916
7123
  const peslModel = intel.models?.pesl ?? this.config.agent.model;
6917
7124
  const store = new import_graph.GraphStore();
6918
7125
  this.graphStore = store;
6919
- return new import_intelligence2.IntelligencePipeline(provider, store, {
7126
+ return new import_intelligence3.IntelligencePipeline(provider, store, {
6920
7127
  ...peslModel !== void 0 && { peslModel }
6921
7128
  });
6922
7129
  }
@@ -6939,7 +7146,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
6939
7146
  const apiKey = this.config.agent.localApiKey ?? "ollama";
6940
7147
  const model = selModel ?? this.config.agent.localModel;
6941
7148
  this.logger.info(`Intelligence pipeline using local backend at ${endpoint}`);
6942
- return new import_intelligence2.OpenAICompatibleAnalysisProvider({
7149
+ return new import_intelligence3.OpenAICompatibleAnalysisProvider({
6943
7150
  apiKey,
6944
7151
  baseUrl: endpoint,
6945
7152
  ...model !== void 0 && { defaultModel: model },
@@ -6952,7 +7159,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
6952
7159
  if (backend === "anthropic" || backend === "claude") {
6953
7160
  const apiKey = this.config.agent.apiKey ?? process.env.ANTHROPIC_API_KEY;
6954
7161
  if (apiKey) {
6955
- return new import_intelligence2.AnthropicAnalysisProvider({
7162
+ return new import_intelligence3.AnthropicAnalysisProvider({
6956
7163
  apiKey,
6957
7164
  ...selModel !== void 0 && { defaultModel: selModel }
6958
7165
  });
@@ -6961,7 +7168,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
6961
7168
  if (backend === "openai") {
6962
7169
  const apiKey = this.config.agent.apiKey ?? process.env.OPENAI_API_KEY;
6963
7170
  if (apiKey) {
6964
- return new import_intelligence2.OpenAICompatibleAnalysisProvider({
7171
+ return new import_intelligence3.OpenAICompatibleAnalysisProvider({
6965
7172
  apiKey,
6966
7173
  baseUrl: "https://api.openai.com/v1",
6967
7174
  ...selModel !== void 0 && { defaultModel: selModel }
@@ -6970,7 +7177,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
6970
7177
  }
6971
7178
  if (backend === "claude" || backend === "anthropic") {
6972
7179
  this.logger.info("Intelligence pipeline using Claude CLI (no API key configured)");
6973
- return new import_intelligence2.ClaudeCliAnalysisProvider({
7180
+ return new import_intelligence3.ClaudeCliAnalysisProvider({
6974
7181
  command: this.config.agent.command,
6975
7182
  ...selModel !== void 0 && { defaultModel: selModel },
6976
7183
  ...intel?.requestTimeoutMs !== void 0 && { timeoutMs: intel.requestTimeoutMs }
@@ -6987,13 +7194,13 @@ var Orchestrator = class extends import_node_events.EventEmitter {
6987
7194
  if (!apiKey2) {
6988
7195
  throw new Error("Intelligence pipeline: no Anthropic API key found.");
6989
7196
  }
6990
- return new import_intelligence2.AnthropicAnalysisProvider({
7197
+ return new import_intelligence3.AnthropicAnalysisProvider({
6991
7198
  apiKey: apiKey2,
6992
7199
  ...selModel !== void 0 && { defaultModel: selModel }
6993
7200
  });
6994
7201
  }
6995
7202
  if (provider.kind === "claude-cli") {
6996
- return new import_intelligence2.ClaudeCliAnalysisProvider({
7203
+ return new import_intelligence3.ClaudeCliAnalysisProvider({
6997
7204
  command: this.config.agent.command,
6998
7205
  ...selModel !== void 0 && { defaultModel: selModel },
6999
7206
  ...this.config.intelligence?.requestTimeoutMs !== void 0 && {
@@ -7004,7 +7211,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7004
7211
  const apiKey = provider.apiKey ?? this.config.agent.apiKey ?? "ollama";
7005
7212
  const baseUrl = provider.baseUrl ?? "http://localhost:11434/v1";
7006
7213
  const intel = this.config.intelligence;
7007
- return new import_intelligence2.OpenAICompatibleAnalysisProvider({
7214
+ return new import_intelligence3.OpenAICompatibleAnalysisProvider({
7008
7215
  apiKey,
7009
7216
  baseUrl,
7010
7217
  ...selModel !== void 0 && { defaultModel: selModel },
@@ -7051,7 +7258,13 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7051
7258
  (phase, detail, progress) => this.setTickActivity(phase, detail, progress)
7052
7259
  ) : void 0;
7053
7260
  this.setTickActivity("dispatching", "Applying state machine");
7054
- const { concernSignals, enrichedSpecs, complexityScores, simulationResults } = pipelineResult ?? {};
7261
+ const {
7262
+ concernSignals,
7263
+ enrichedSpecs,
7264
+ complexityScores,
7265
+ simulationResults,
7266
+ personaRecommendations
7267
+ } = pipelineResult ?? {};
7055
7268
  const tickEvent = {
7056
7269
  type: "tick",
7057
7270
  candidates,
@@ -7060,7 +7273,8 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7060
7273
  ...concernSignals !== void 0 && { concernSignals },
7061
7274
  ...enrichedSpecs !== void 0 && { enrichedSpecs },
7062
7275
  ...complexityScores !== void 0 && { complexityScores },
7063
- ...simulationResults !== void 0 && { simulationResults }
7276
+ ...simulationResults !== void 0 && { simulationResults },
7277
+ ...personaRecommendations !== void 0 && { personaRecommendations }
7064
7278
  };
7065
7279
  let { nextState, effects } = applyEvent(this.state, tickEvent, this.config);
7066
7280
  this.state = nextState;
@@ -7070,7 +7284,8 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7070
7284
  type: "retry_fired",
7071
7285
  issueId,
7072
7286
  candidates,
7073
- nowMs
7287
+ nowMs,
7288
+ ...concernSignals !== void 0 && { concernSignals }
7074
7289
  };
7075
7290
  const result = applyEvent(this.state, retryEvent, this.config);
7076
7291
  this.state = result.nextState;
@@ -7079,6 +7294,35 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7079
7294
  for (const effect of effects) {
7080
7295
  await this.handleEffect(effect);
7081
7296
  }
7297
+ const stallTimeoutMs = this.config.agent.stallTimeoutMs;
7298
+ if (stallTimeoutMs > 0) {
7299
+ const stalledIds = [];
7300
+ for (const [runId, runEntry] of this.state.running) {
7301
+ const lastTs = runEntry.session?.lastTimestamp;
7302
+ if (!lastTs) continue;
7303
+ const silentMs = nowMs - new Date(lastTs).getTime();
7304
+ if (silentMs >= stallTimeoutMs) {
7305
+ stalledIds.push(runId);
7306
+ }
7307
+ }
7308
+ for (const runId of stalledIds) {
7309
+ const runEntry = this.state.running.get(runId);
7310
+ if (!runEntry) continue;
7311
+ this.logger.warn(
7312
+ `Agent stalled for ${runEntry.identifier}: ${Math.round((nowMs - new Date(runEntry.session?.lastTimestamp ?? 0).getTime()) / 1e3)}s since last event`,
7313
+ { issueId: runId }
7314
+ );
7315
+ const stallEvent = {
7316
+ type: "stall_detected",
7317
+ issueId: runId
7318
+ };
7319
+ const stallResult = applyEvent(this.state, stallEvent, this.config);
7320
+ this.state = stallResult.nextState;
7321
+ for (const eff of stallResult.effects) {
7322
+ await this.handleEffect(eff);
7323
+ }
7324
+ }
7325
+ }
7082
7326
  const openPrNumbers = [];
7083
7327
  for (const [, runEntry] of this.state.running) {
7084
7328
  const externalId = runEntry.issue.externalId;
@@ -7125,9 +7369,6 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7125
7369
  */
7126
7370
  async handleEffect(effect) {
7127
7371
  switch (effect.type) {
7128
- case "dispatch":
7129
- await this.dispatchIssue(effect.issue, effect.attempt, effect.backend);
7130
- break;
7131
7372
  case "stop":
7132
7373
  await this.stopIssue(effect.issueId);
7133
7374
  break;
@@ -7138,6 +7379,11 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7138
7379
  break;
7139
7380
  case "releaseClaim":
7140
7381
  break;
7382
+ case "scheduleRetry":
7383
+ this.logger.info(
7384
+ `Retry scheduled for ${effect.issueId} (attempt ${effect.attempt}, delay ${effect.delayMs}ms)`
7385
+ );
7386
+ break;
7141
7387
  case "cleanWorkspace":
7142
7388
  await this.cleanWorkspaceWithGuard(effect.identifier, effect.issueId);
7143
7389
  break;
@@ -7163,6 +7409,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7163
7409
  `Branch "${branch}" not found on remote for ${identifier}, cleaning up worktree`,
7164
7410
  { issueId }
7165
7411
  );
7412
+ await this.runBeforeRemoveHook(identifier);
7166
7413
  await this.workspace.removeWorkspace(identifier);
7167
7414
  return;
7168
7415
  }
@@ -7197,8 +7444,17 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7197
7444
  return;
7198
7445
  }
7199
7446
  }
7447
+ await this.runBeforeRemoveHook(identifier);
7200
7448
  await this.workspace.removeWorkspace(identifier);
7201
7449
  }
7450
+ /** Run the beforeRemove hook for a workspace. Failures are logged but non-fatal. */
7451
+ async runBeforeRemoveHook(identifier) {
7452
+ const wsPath = this.workspace.resolvePath(identifier);
7453
+ const result = await this.hooks.beforeRemove(wsPath);
7454
+ if (!result.ok) {
7455
+ this.logger.warn(`beforeRemove hook failed for ${identifier}: ${result.error.message}`);
7456
+ }
7457
+ }
7202
7458
  /**
7203
7459
  * Delegates to PRDetector.filterCandidatesWithOpenPRs.
7204
7460
  * @see PRDetector#filterCandidatesWithOpenPRs
@@ -7368,7 +7624,11 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7368
7624
  if (!result.ok) {
7369
7625
  this.logger.warn(`Lifecycle comment failed for ${identifier}: ${result.error.message}`);
7370
7626
  }
7371
- } catch {
7627
+ } catch (err) {
7628
+ this.logger.debug("Lifecycle comment failed (best-effort)", {
7629
+ identifier,
7630
+ error: String(err)
7631
+ });
7372
7632
  }
7373
7633
  }
7374
7634
  /**
@@ -7385,6 +7645,12 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7385
7645
  const workspaceResult = await this.workspace.ensureWorkspace(issue.identifier);
7386
7646
  if (!workspaceResult.ok) throw workspaceResult.error;
7387
7647
  const workspacePath = workspaceResult.value;
7648
+ const afterCreateResult = await this.hooks.afterCreate(workspacePath);
7649
+ if (!afterCreateResult.ok) {
7650
+ this.logger.warn(
7651
+ `afterCreate hook failed for ${issue.identifier}: ${afterCreateResult.error.message}`
7652
+ );
7653
+ }
7388
7654
  const hookResult = await this.hooks.beforeRun(workspacePath);
7389
7655
  if (!hookResult.ok) throw hookResult.error;
7390
7656
  const scanResult = await scanWorkspaceConfig(workspacePath);
@@ -7494,21 +7760,54 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7494
7760
  runAgentInBackgroundTask(issue, workspacePath, prompt, attempt, runner) {
7495
7761
  const activeRunner = runner ?? this.runner;
7496
7762
  this.logger.info(`Starting background task for ${issue.identifier}`);
7763
+ const abortController = new AbortController();
7764
+ this.abortControllers.set(issue.id, { controller: abortController, pid: null });
7497
7765
  (async () => {
7498
7766
  try {
7499
7767
  this.logger.info(`Calling runner.runSession for ${issue.identifier}`);
7500
7768
  const sessionGen = activeRunner.runSession(issue, workspacePath, prompt);
7501
7769
  for await (const event of sessionGen) {
7770
+ if (abortController.signal.aborted) {
7771
+ this.logger.info(`Agent session aborted for ${issue.identifier}`);
7772
+ break;
7773
+ }
7774
+ if (event.type === "session_started" && event.content) {
7775
+ const pid = event.content.pid;
7776
+ if (pid) {
7777
+ const tracked = this.abortControllers.get(issue.id);
7778
+ if (tracked) tracked.pid = pid;
7779
+ }
7780
+ }
7502
7781
  await this.processAgentEvent(issue, event);
7503
7782
  if (event.type === "turn_start") {
7504
7783
  await this.awaitRateLimitClearance(issue.identifier);
7505
7784
  }
7506
7785
  }
7507
7786
  this.logger.info(`Session generator finished for ${issue.identifier}`);
7508
- await this.emitWorkerExit(issue.id, "normal", attempt);
7787
+ const afterRunResult = await this.hooks.afterRun(workspacePath);
7788
+ if (!afterRunResult.ok) {
7789
+ this.logger.warn(
7790
+ `afterRun hook failed for ${issue.identifier}: ${afterRunResult.error.message}`
7791
+ );
7792
+ }
7793
+ if (abortController.signal.aborted) {
7794
+ if (this.state.running.has(issue.id)) {
7795
+ await this.emitWorkerExit(issue.id, "error", attempt, "Stopped by reconciliation");
7796
+ }
7797
+ } else {
7798
+ await this.emitWorkerExit(issue.id, "normal", attempt);
7799
+ }
7509
7800
  } catch (error) {
7510
7801
  this.logger.error(`Agent runner failed for ${issue.identifier}`, { error: String(error) });
7802
+ const afterRunResult = await this.hooks.afterRun(workspacePath);
7803
+ if (!afterRunResult.ok) {
7804
+ this.logger.warn(
7805
+ `afterRun hook failed for ${issue.identifier}: ${afterRunResult.error.message}`
7806
+ );
7807
+ }
7511
7808
  await this.emitWorkerExit(issue.id, "error", attempt, String(error));
7809
+ } finally {
7810
+ this.abortControllers.delete(issue.id);
7512
7811
  }
7513
7812
  })().catch((err) => {
7514
7813
  this.logger.error("Fatal error in background task", { error: String(err) });
@@ -7534,6 +7833,19 @@ var Orchestrator = class extends import_node_events.EventEmitter {
7534
7833
  */
7535
7834
  async stopIssue(issueId) {
7536
7835
  this.logger.info(`Stopping issue: ${issueId}`);
7836
+ const tracked = this.abortControllers.get(issueId);
7837
+ if (tracked) {
7838
+ tracked.controller.abort();
7839
+ this.logger.info(`Abort signal sent for ${issueId}`);
7840
+ }
7841
+ const pid = tracked?.pid ?? this.state.running.get(issueId)?.session?.agentPid;
7842
+ if (pid) {
7843
+ try {
7844
+ process.kill(pid, "SIGTERM");
7845
+ this.logger.info(`Sent SIGTERM to agent PID ${pid} for ${issueId}`);
7846
+ } catch {
7847
+ }
7848
+ }
7537
7849
  }
7538
7850
  /**
7539
7851
  * Dispatch a work item immediately, bypassing the normal tick → roadmap cycle.