@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.mjs CHANGED
@@ -601,7 +601,26 @@ function handleTick(state, event, config) {
601
601
  continue;
602
602
  }
603
603
  const scopeTier = detectScopeTier(issue, artifactPresenceFromIssue(issue));
604
- const signals = event.concernSignals?.get(issue.id) ?? [];
604
+ const signals = [...event.concernSignals?.get(issue.id) ?? []];
605
+ let suggestedPersona;
606
+ try {
607
+ const personaRecs = event.personaRecommendations?.get(issue.id);
608
+ if (personaRecs && personaRecs.length > 0) {
609
+ suggestedPersona = personaRecs[0].persona;
610
+ if (personaRecs[0].weightedScore < 0.3) {
611
+ signals.push({
612
+ name: "lowExpertise",
613
+ reason: `Top persona "${suggestedPersona}" scored ${personaRecs[0].weightedScore.toFixed(2)} (below 0.3 threshold)`
614
+ });
615
+ }
616
+ } else if (personaRecs && personaRecs.length === 0) {
617
+ signals.push({
618
+ name: "noPersonaMatch",
619
+ reason: "No persona recommendations available for this issue's systems"
620
+ });
621
+ }
622
+ } catch {
623
+ }
605
624
  const decision = routeIssue(scopeTier, signals, escalationConfig);
606
625
  if (decision.action === "needs-human") {
607
626
  next.claimed.add(issue.id);
@@ -617,6 +636,12 @@ function handleTick(state, event, config) {
617
636
  }
618
637
  const backend = resolveBackend(decision.action, !!config.agent.localBackend);
619
638
  claimAndDispatch(next, issue, backend, nowMs, effects);
639
+ if (suggestedPersona) {
640
+ const lastEffect = effects[effects.length - 1];
641
+ if (lastEffect && lastEffect.type === "claim") {
642
+ lastEffect.suggestedPersona = suggestedPersona;
643
+ }
644
+ }
620
645
  }
621
646
  pruneCompleted(next);
622
647
  return { nextState: next, effects };
@@ -743,7 +768,7 @@ function handleAgentUpdate(state, issueId, event) {
743
768
  }
744
769
  return { nextState: next, effects };
745
770
  }
746
- function handleRetryFired(state, issueId, candidates, config, nowMs) {
771
+ function handleRetryFired(state, issueId, candidates, config, nowMs, concernSignals) {
747
772
  const next = cloneState(state);
748
773
  const effects = [];
749
774
  const retryEntry = next.retryAttempts.get(issueId);
@@ -800,7 +825,8 @@ function handleRetryFired(state, issueId, candidates, config, nowMs) {
800
825
  }
801
826
  const escalationConfig = resolveEscalationConfig(config);
802
827
  const scopeTier = detectScopeTier(issue, artifactPresenceFromIssue(issue));
803
- const decision = routeIssue(scopeTier, [], escalationConfig);
828
+ const signals = [...concernSignals?.get(issue.id) ?? []];
829
+ const decision = routeIssue(scopeTier, signals, escalationConfig);
804
830
  if (decision.action === "needs-human") {
805
831
  effects.push(
806
832
  buildEscalateEffect(issue.id, issue.identifier, decision.reasons, {
@@ -880,7 +906,14 @@ function applyEvent(state, event, config) {
880
906
  case "agent_update":
881
907
  return handleAgentUpdate(state, event.issueId, event.event);
882
908
  case "retry_fired":
883
- return handleRetryFired(state, event.issueId, event.candidates, config, event.nowMs);
909
+ return handleRetryFired(
910
+ state,
911
+ event.issueId,
912
+ event.candidates,
913
+ config,
914
+ event.nowMs,
915
+ event.concernSignals
916
+ );
884
917
  case "stall_detected":
885
918
  return handleStallDetected(state, event.issueId, config);
886
919
  case "claim_rejected":
@@ -2335,10 +2368,15 @@ var WorkspaceHooks = class {
2335
2368
  return Ok7(void 0);
2336
2369
  }
2337
2370
  return new Promise((resolve6) => {
2338
- const child = spawn(command, {
2339
- shell: true,
2371
+ const filteredEnv = {};
2372
+ for (const [k, v] of Object.entries(process.env)) {
2373
+ if (v != null && !k.includes("SECRET") && !k.includes("TOKEN") && !k.includes("PASSWORD")) {
2374
+ filteredEnv[k] = v;
2375
+ }
2376
+ }
2377
+ const child = spawn("/bin/sh", ["-c", command], {
2340
2378
  cwd,
2341
- env: process.env
2379
+ env: filteredEnv
2342
2380
  });
2343
2381
  const timeout = setTimeout(() => {
2344
2382
  child.kill();
@@ -2459,6 +2497,7 @@ import { GraphStore } from "@harness-engineering/graph";
2459
2497
 
2460
2498
  // src/intelligence/pipeline-runner.ts
2461
2499
  import * as path7 from "path";
2500
+ import { weightedRecommendPersona, refreshProfiles } from "@harness-engineering/intelligence";
2462
2501
  import {
2463
2502
  GitHubIssuesSyncAdapter,
2464
2503
  loadTrackerSyncConfig
@@ -2490,6 +2529,7 @@ var IntelligencePipelineRunner = class {
2490
2529
  this.graphLoaded = true;
2491
2530
  await this.loadGraphStore();
2492
2531
  await this.hydrateSpecCache();
2532
+ this.refreshSpecializationProfiles();
2493
2533
  }
2494
2534
  /**
2495
2535
  * Runs the full intelligence pipeline for the given candidates:
@@ -2543,11 +2583,39 @@ var IntelligencePipelineRunner = class {
2543
2583
  } catch (err) {
2544
2584
  this.ctx.logger.warn("Auto-publish analyses failed", { error: String(err) });
2545
2585
  }
2546
- return { concernSignals, enrichedSpecs, complexityScores, simulationResults };
2586
+ this.refreshSpecializationProfiles();
2587
+ setTickActivity("analyzing", "Scoring persona recommendations");
2588
+ const personaRecommendations = this.computePersonaRecommendations(candidates);
2589
+ return {
2590
+ concernSignals,
2591
+ enrichedSpecs,
2592
+ complexityScores,
2593
+ simulationResults,
2594
+ personaRecommendations
2595
+ };
2547
2596
  }
2548
2597
  // ---------------------------------------------------------------------------
2549
2598
  // Private helpers
2550
2599
  // ---------------------------------------------------------------------------
2600
+ /**
2601
+ * Refresh specialization profiles from the graph store. This recomputes
2602
+ * expertise scores for all personas with execution_outcome nodes and
2603
+ * persists them to .harness/specialization-profiles.json. Non-fatal.
2604
+ */
2605
+ refreshSpecializationProfiles() {
2606
+ if (!this.ctx.graphStore) return;
2607
+ try {
2608
+ const store = refreshProfiles(this.ctx.projectRoot, this.ctx.graphStore);
2609
+ const personaCount = Object.keys(store.profiles).length;
2610
+ if (personaCount > 0) {
2611
+ this.ctx.logger.info(`Refreshed specialization profiles for ${personaCount} persona(s)`);
2612
+ }
2613
+ } catch (err) {
2614
+ this.ctx.logger.warn("Failed to refresh specialization profiles", {
2615
+ error: String(err)
2616
+ });
2617
+ }
2618
+ }
2551
2619
  async loadGraphStore() {
2552
2620
  try {
2553
2621
  const graphDir = path7.join(this.ctx.config.workspace.root, "..", "graph");
@@ -2706,7 +2774,22 @@ var IntelligencePipelineRunner = class {
2706
2774
  const scopeTier = detectScopeTier(issue, artifactPresenceFromIssue(issue));
2707
2775
  try {
2708
2776
  const result = await this.ctx.pipeline.preprocessIssue(issue, scopeTier, escalationConfig);
2709
- if (result.signals.length > 0) concernSignals.set(issue.id, result.signals);
2777
+ const signals = [...result.signals];
2778
+ if (result.spec) {
2779
+ if (result.spec.unknowns.length > 3) {
2780
+ signals.push({
2781
+ name: "high-unknowns",
2782
+ reason: `Enriched spec has ${result.spec.unknowns.length} unknowns (threshold: 3)`
2783
+ });
2784
+ }
2785
+ if (result.spec.ambiguities.length > 5) {
2786
+ signals.push({
2787
+ name: "high-ambiguities",
2788
+ reason: `Enriched spec has ${result.spec.ambiguities.length} ambiguities (threshold: 5)`
2789
+ });
2790
+ }
2791
+ }
2792
+ if (signals.length > 0) concernSignals.set(issue.id, signals);
2710
2793
  if (result.spec) {
2711
2794
  enrichedSpecs.set(issue.id, result.spec);
2712
2795
  this.ctx.enrichedSpecsByIssue.set(issue.id, result.spec);
@@ -2753,6 +2836,30 @@ var IntelligencePipelineRunner = class {
2753
2836
  return true;
2754
2837
  });
2755
2838
  }
2839
+ /**
2840
+ * Compute persona recommendations for each candidate using the specialization
2841
+ * scorer. Extracts system node IDs from issue labels prefixed with `system:`
2842
+ * or `module:`. Failures are non-fatal — returns an empty map on error.
2843
+ */
2844
+ computePersonaRecommendations(candidates) {
2845
+ const results = /* @__PURE__ */ new Map();
2846
+ if (!this.ctx.graphStore) return results;
2847
+ try {
2848
+ for (const issue of candidates) {
2849
+ const systemNodeIds = issue.labels.filter((l) => l.startsWith("system:") || l.startsWith("module:")).map((l) => l.split(":")[1]).filter((id) => id.length > 0);
2850
+ if (systemNodeIds.length === 0) continue;
2851
+ const recs = weightedRecommendPersona(this.ctx.graphStore, { systemNodeIds });
2852
+ if (recs.length > 0) {
2853
+ results.set(issue.id, recs);
2854
+ }
2855
+ }
2856
+ } catch (err) {
2857
+ this.ctx.logger.warn("Persona recommendation scoring failed", {
2858
+ error: String(err)
2859
+ });
2860
+ }
2861
+ return results;
2862
+ }
2756
2863
  async analyzeCandidates(eligibleCandidates, escalationConfig, nowForCache, failureTtl, circuitBreakerThreshold, concernSignals, enrichedSpecs, complexityScores, setTickActivity) {
2757
2864
  let processed = 0;
2758
2865
  let consecutiveConnErrors = 0;
@@ -2830,10 +2937,27 @@ var CompletionHandler = class {
2830
2937
  // ---------------------------------------------------------------------------
2831
2938
  // Private helpers
2832
2939
  // ---------------------------------------------------------------------------
2940
+ /**
2941
+ * Infer a TaskType from issue labels.
2942
+ * Looks for common label patterns: bug/bugfix → 'bugfix', feat/feature → 'feature', etc.
2943
+ */
2944
+ inferTaskType(labels) {
2945
+ const joined = labels.map((l) => l.toLowerCase()).join(" ");
2946
+ if (/\bbug(fix)?\b/.test(joined)) return "bugfix";
2947
+ if (/\bfeat(ure)?\b/.test(joined)) return "feature";
2948
+ if (/\brefactor\b/.test(joined)) return "refactor";
2949
+ if (/\bdoc(s|umentation)?\b/.test(joined)) return "docs";
2950
+ if (/\btest(s|ing)?\b/.test(joined)) return "test";
2951
+ if (/\bchore\b/.test(joined)) return "chore";
2952
+ return void 0;
2953
+ }
2833
2954
  async recordOutcomeIfPipelineEnabled(issueId, reason, attempt, error, entry) {
2834
2955
  if (!this.ctx.pipeline) return;
2835
2956
  const enrichedSpec = this.ctx.enrichedSpecsByIssue.get(issueId);
2836
2957
  const affectedSystemNodeIds = enrichedSpec ? enrichedSpec.affectedSystems.filter((s) => s.graphNodeId !== null).map((s) => s.graphNodeId) : [];
2958
+ const agentPersona = entry?.session?.backendName ?? this.ctx.config.agent.backend ?? "default";
2959
+ const labels = entry?.issue?.labels ?? [];
2960
+ const taskType = this.inferTaskType(labels);
2837
2961
  const outcome = {
2838
2962
  id: `outcome:${issueId}:${attempt ?? 0}`,
2839
2963
  issueId,
@@ -2844,7 +2968,9 @@ var CompletionHandler = class {
2844
2968
  durationMs: entry ? Date.now() - new Date(entry.startedAt).getTime() : 0,
2845
2969
  linkedSpecId: enrichedSpec?.id ?? null,
2846
2970
  affectedSystemNodeIds,
2847
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2971
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2972
+ agentPersona,
2973
+ ...taskType ? { taskType } : {}
2848
2974
  };
2849
2975
  try {
2850
2976
  this.ctx.pipeline.recordOutcome(outcome);
@@ -5577,6 +5703,37 @@ var PlanWatcher = class {
5577
5703
  };
5578
5704
 
5579
5705
  // src/server/http.ts
5706
+ var RATE_LIMIT = Number(process.env["HARNESS_RATE_LIMIT"]) || 100;
5707
+ var WINDOW_MS = 6e4;
5708
+ var rateBuckets = /* @__PURE__ */ new Map();
5709
+ var ratePruneTimer = setInterval(() => {
5710
+ const now = Date.now();
5711
+ for (const [ip, bucket] of rateBuckets) {
5712
+ if (bucket.resetAt <= now) rateBuckets.delete(ip);
5713
+ }
5714
+ }, 6e4);
5715
+ ratePruneTimer.unref();
5716
+ function isLocalhost(ip) {
5717
+ return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
5718
+ }
5719
+ function checkRateLimit(req, res) {
5720
+ const ip = req.socket.remoteAddress ?? "unknown";
5721
+ if (!process.env["HARNESS_RATE_LIMIT_LOCALHOST"] && isLocalhost(ip)) return true;
5722
+ const now = Date.now();
5723
+ let bucket = rateBuckets.get(ip);
5724
+ if (!bucket || bucket.resetAt <= now) {
5725
+ bucket = { count: 0, resetAt: now + WINDOW_MS };
5726
+ rateBuckets.set(ip, bucket);
5727
+ }
5728
+ bucket.count++;
5729
+ if (bucket.count > RATE_LIMIT) {
5730
+ const retryAfter = Math.ceil((bucket.resetAt - now) / 1e3);
5731
+ res.writeHead(429, { "Content-Type": "application/json", "Retry-After": String(retryAfter) });
5732
+ res.end(JSON.stringify({ error: "Too Many Requests" }));
5733
+ return false;
5734
+ }
5735
+ return true;
5736
+ }
5580
5737
  function getBindHost() {
5581
5738
  return process.env["HOST"] ?? "127.0.0.1";
5582
5739
  }
@@ -5664,6 +5821,9 @@ var OrchestratorServer = class {
5664
5821
  if (this.handleStateEndpoint(req, res)) {
5665
5822
  return;
5666
5823
  }
5824
+ if (!checkRateLimit(req, res)) {
5825
+ return;
5826
+ }
5667
5827
  if (this.handleApiRoutes(req, res)) {
5668
5828
  return;
5669
5829
  }
@@ -6292,6 +6452,18 @@ var MaintenanceScheduler = class {
6292
6452
  // src/maintenance/reporter.ts
6293
6453
  import * as fs14 from "fs";
6294
6454
  import * as path14 from "path";
6455
+ import { z as z9 } from "zod";
6456
+ var RunResultSchema = z9.object({
6457
+ taskId: z9.string(),
6458
+ startedAt: z9.string(),
6459
+ completedAt: z9.string(),
6460
+ status: z9.enum(["success", "failure", "skipped", "no-issues"]),
6461
+ findings: z9.number(),
6462
+ fixed: z9.number(),
6463
+ prUrl: z9.string().nullable(),
6464
+ prUpdated: z9.boolean(),
6465
+ error: z9.string().optional()
6466
+ });
6295
6467
  var MAX_HISTORY = 500;
6296
6468
  var fallbackLogger = {
6297
6469
  info: () => {
@@ -6317,9 +6489,9 @@ var MaintenanceReporter = class {
6317
6489
  await fs14.promises.mkdir(this.persistDir, { recursive: true });
6318
6490
  const filePath = path14.join(this.persistDir, "history.json");
6319
6491
  const data = await fs14.promises.readFile(filePath, "utf-8");
6320
- const parsed = JSON.parse(data);
6321
- if (Array.isArray(parsed)) {
6322
- this.history = parsed.slice(0, MAX_HISTORY);
6492
+ const parsed = z9.array(RunResultSchema).safeParse(JSON.parse(data));
6493
+ if (parsed.success) {
6494
+ this.history = parsed.data.slice(0, MAX_HISTORY);
6323
6495
  }
6324
6496
  } catch (err) {
6325
6497
  if (err instanceof Error && "code" in err && err.code === "ENOENT") {
@@ -6470,7 +6642,8 @@ var TaskRunner = class {
6470
6642
  const prResult = await this.prManager.ensurePR(task, summary);
6471
6643
  prUrl = prResult.prUrl;
6472
6644
  prUpdated = prResult.prUpdated;
6473
- } catch {
6645
+ } catch (err) {
6646
+ console.warn(`[maintenance] PR creation failed for task ${task.id}: ${String(err)}`);
6474
6647
  }
6475
6648
  }
6476
6649
  return {
@@ -6521,7 +6694,8 @@ var TaskRunner = class {
6521
6694
  const prResult = await this.prManager.ensurePR(task, summary);
6522
6695
  prUrl = prResult.prUrl;
6523
6696
  prUpdated = prResult.prUpdated;
6524
- } catch {
6697
+ } catch (err) {
6698
+ console.warn(`[maintenance] PR creation failed for task ${task.id}: ${String(err)}`);
6525
6699
  }
6526
6700
  }
6527
6701
  return {
@@ -6631,6 +6805,10 @@ var Orchestrator = class extends EventEmitter {
6631
6805
  enrichedSpecsByIssue = /* @__PURE__ */ new Map();
6632
6806
  /** Tracks recently-failed intelligence analysis to avoid re-requesting every tick */
6633
6807
  analysisFailureCache = /* @__PURE__ */ new Map();
6808
+ /** Abort controllers and PIDs for running agent tasks — used by stopIssue to cancel in-flight work.
6809
+ * The PID is stored here because the running entry may be deleted by the state machine
6810
+ * before the stop effect executes (e.g., stall_detected removes the entry first). */
6811
+ abortControllers = /* @__PURE__ */ new Map();
6634
6812
  /** Guards against overlapping ticks when a tick takes longer than the polling interval */
6635
6813
  tickInProgress = false;
6636
6814
  /** Timestamp of the last stale branch sweep (at most once per hour) */
@@ -6761,33 +6939,63 @@ var Orchestrator = class extends EventEmitter {
6761
6939
  }
6762
6940
  /**
6763
6941
  * Creates a TaskRunner for the maintenance scheduler.
6764
- * Provides stub implementations for check/agent/command execution.
6765
- * Phase 4 (PRManager) and Phase 5 (Reporter) will enhance these.
6942
+ * CheckCommandRunner and CommandExecutor use real child_process execution.
6943
+ * AgentDispatcher remains stubbed (requires full skill dispatch integration).
6766
6944
  */
6767
6945
  createMaintenanceTaskRunner(maintenanceConfig) {
6768
- this.logger.warn(
6769
- "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."
6770
- );
6946
+ const logger = this.logger;
6771
6947
  const checkRunner = {
6772
6948
  run: async (command, cwd) => {
6773
- this.logger.info("Maintenance check runner invoked (stub)", { command, cwd });
6774
- return { passed: true, findings: 0, output: "" };
6949
+ const { execFile: execFile6 } = await import("child_process");
6950
+ const { promisify: promisify3 } = await import("util");
6951
+ const execFileAsync = promisify3(execFile6);
6952
+ const [cmd, ...args] = command;
6953
+ if (!cmd) return { passed: true, findings: 0, output: "" };
6954
+ try {
6955
+ const { stdout } = await execFileAsync(cmd, args, { cwd, timeout: 12e4 });
6956
+ const findingsMatch = stdout.match(/(\d+)\s+(?:finding|issue|violation|error)/i);
6957
+ const findings = findingsMatch ? parseInt(findingsMatch[1], 10) : 0;
6958
+ return { passed: findings === 0, findings, output: stdout };
6959
+ } catch (err) {
6960
+ const error = err;
6961
+ const output = [error.stdout, error.stderr].filter(Boolean).join("\n");
6962
+ const findingsMatch = output.match(/(\d+)\s+(?:finding|issue|violation|error)/i);
6963
+ const findings = findingsMatch ? parseInt(findingsMatch[1], 10) : 1;
6964
+ return { passed: false, findings, output };
6965
+ }
6775
6966
  }
6776
6967
  };
6777
6968
  const agentDispatcher = {
6778
6969
  dispatch: async (skill, branch, backendName, cwd) => {
6779
- this.logger.info("Maintenance agent dispatcher invoked (stub)", {
6780
- skill,
6781
- branch,
6782
- backendName,
6783
- cwd
6784
- });
6970
+ logger.info(
6971
+ "Maintenance agent dispatcher invoked (stub \u2014 skill dispatch integration pending)",
6972
+ {
6973
+ skill,
6974
+ branch,
6975
+ backendName,
6976
+ cwd
6977
+ }
6978
+ );
6785
6979
  return { producedCommits: false, fixed: 0 };
6786
6980
  }
6787
6981
  };
6788
6982
  const commandExecutor = {
6789
6983
  exec: async (command, cwd) => {
6790
- this.logger.info("Maintenance command executor invoked (stub)", { command, cwd });
6984
+ const { execFile: execFile6 } = await import("child_process");
6985
+ const { promisify: promisify3 } = await import("util");
6986
+ const execFileAsync = promisify3(execFile6);
6987
+ const [cmd, ...args] = command;
6988
+ if (!cmd) return;
6989
+ try {
6990
+ await execFileAsync(cmd, args, { cwd, timeout: 12e4 });
6991
+ } catch (err) {
6992
+ logger.warn("Maintenance command execution failed", {
6993
+ command,
6994
+ cwd,
6995
+ error: String(err)
6996
+ });
6997
+ throw err;
6998
+ }
6791
6999
  }
6792
7000
  };
6793
7001
  return new TaskRunner({
@@ -6817,19 +7025,18 @@ var Orchestrator = class extends EventEmitter {
6817
7025
  historyProvider: reporter,
6818
7026
  onTaskDue: async (task) => {
6819
7027
  this.logger.info(`Maintenance task due: ${task.id}`, { taskId: task.id });
6820
- this.server?.broadcastMaintenance("maintenance:started", {
6821
- taskId: task.id,
6822
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
6823
- });
7028
+ const startPayload = { taskId: task.id, startedAt: (/* @__PURE__ */ new Date()).toISOString() };
7029
+ this.server?.broadcastMaintenance("maintenance:started", startPayload);
7030
+ this.emit("maintenance:started", startPayload);
6824
7031
  const result = await taskRunner.run(task);
6825
7032
  await reporter.record(result);
6826
7033
  if (result.status === "failure") {
6827
- this.server?.broadcastMaintenance("maintenance:error", {
6828
- taskId: task.id,
6829
- error: result.error
6830
- });
7034
+ const errorPayload = { taskId: task.id, error: result.error };
7035
+ this.server?.broadcastMaintenance("maintenance:error", errorPayload);
7036
+ this.emit("maintenance:error", errorPayload);
6831
7037
  } else {
6832
7038
  this.server?.broadcastMaintenance("maintenance:completed", result);
7039
+ this.emit("maintenance:completed", result);
6833
7040
  }
6834
7041
  this.logger.info(`Maintenance task completed: ${task.id}`, {
6835
7042
  taskId: task.id,
@@ -7017,7 +7224,13 @@ var Orchestrator = class extends EventEmitter {
7017
7224
  (phase, detail, progress) => this.setTickActivity(phase, detail, progress)
7018
7225
  ) : void 0;
7019
7226
  this.setTickActivity("dispatching", "Applying state machine");
7020
- const { concernSignals, enrichedSpecs, complexityScores, simulationResults } = pipelineResult ?? {};
7227
+ const {
7228
+ concernSignals,
7229
+ enrichedSpecs,
7230
+ complexityScores,
7231
+ simulationResults,
7232
+ personaRecommendations
7233
+ } = pipelineResult ?? {};
7021
7234
  const tickEvent = {
7022
7235
  type: "tick",
7023
7236
  candidates,
@@ -7026,7 +7239,8 @@ var Orchestrator = class extends EventEmitter {
7026
7239
  ...concernSignals !== void 0 && { concernSignals },
7027
7240
  ...enrichedSpecs !== void 0 && { enrichedSpecs },
7028
7241
  ...complexityScores !== void 0 && { complexityScores },
7029
- ...simulationResults !== void 0 && { simulationResults }
7242
+ ...simulationResults !== void 0 && { simulationResults },
7243
+ ...personaRecommendations !== void 0 && { personaRecommendations }
7030
7244
  };
7031
7245
  let { nextState, effects } = applyEvent(this.state, tickEvent, this.config);
7032
7246
  this.state = nextState;
@@ -7036,7 +7250,8 @@ var Orchestrator = class extends EventEmitter {
7036
7250
  type: "retry_fired",
7037
7251
  issueId,
7038
7252
  candidates,
7039
- nowMs
7253
+ nowMs,
7254
+ ...concernSignals !== void 0 && { concernSignals }
7040
7255
  };
7041
7256
  const result = applyEvent(this.state, retryEvent, this.config);
7042
7257
  this.state = result.nextState;
@@ -7045,6 +7260,35 @@ var Orchestrator = class extends EventEmitter {
7045
7260
  for (const effect of effects) {
7046
7261
  await this.handleEffect(effect);
7047
7262
  }
7263
+ const stallTimeoutMs = this.config.agent.stallTimeoutMs;
7264
+ if (stallTimeoutMs > 0) {
7265
+ const stalledIds = [];
7266
+ for (const [runId, runEntry] of this.state.running) {
7267
+ const lastTs = runEntry.session?.lastTimestamp;
7268
+ if (!lastTs) continue;
7269
+ const silentMs = nowMs - new Date(lastTs).getTime();
7270
+ if (silentMs >= stallTimeoutMs) {
7271
+ stalledIds.push(runId);
7272
+ }
7273
+ }
7274
+ for (const runId of stalledIds) {
7275
+ const runEntry = this.state.running.get(runId);
7276
+ if (!runEntry) continue;
7277
+ this.logger.warn(
7278
+ `Agent stalled for ${runEntry.identifier}: ${Math.round((nowMs - new Date(runEntry.session?.lastTimestamp ?? 0).getTime()) / 1e3)}s since last event`,
7279
+ { issueId: runId }
7280
+ );
7281
+ const stallEvent = {
7282
+ type: "stall_detected",
7283
+ issueId: runId
7284
+ };
7285
+ const stallResult = applyEvent(this.state, stallEvent, this.config);
7286
+ this.state = stallResult.nextState;
7287
+ for (const eff of stallResult.effects) {
7288
+ await this.handleEffect(eff);
7289
+ }
7290
+ }
7291
+ }
7048
7292
  const openPrNumbers = [];
7049
7293
  for (const [, runEntry] of this.state.running) {
7050
7294
  const externalId = runEntry.issue.externalId;
@@ -7091,9 +7335,6 @@ var Orchestrator = class extends EventEmitter {
7091
7335
  */
7092
7336
  async handleEffect(effect) {
7093
7337
  switch (effect.type) {
7094
- case "dispatch":
7095
- await this.dispatchIssue(effect.issue, effect.attempt, effect.backend);
7096
- break;
7097
7338
  case "stop":
7098
7339
  await this.stopIssue(effect.issueId);
7099
7340
  break;
@@ -7104,6 +7345,11 @@ var Orchestrator = class extends EventEmitter {
7104
7345
  break;
7105
7346
  case "releaseClaim":
7106
7347
  break;
7348
+ case "scheduleRetry":
7349
+ this.logger.info(
7350
+ `Retry scheduled for ${effect.issueId} (attempt ${effect.attempt}, delay ${effect.delayMs}ms)`
7351
+ );
7352
+ break;
7107
7353
  case "cleanWorkspace":
7108
7354
  await this.cleanWorkspaceWithGuard(effect.identifier, effect.issueId);
7109
7355
  break;
@@ -7129,6 +7375,7 @@ var Orchestrator = class extends EventEmitter {
7129
7375
  `Branch "${branch}" not found on remote for ${identifier}, cleaning up worktree`,
7130
7376
  { issueId }
7131
7377
  );
7378
+ await this.runBeforeRemoveHook(identifier);
7132
7379
  await this.workspace.removeWorkspace(identifier);
7133
7380
  return;
7134
7381
  }
@@ -7163,8 +7410,17 @@ var Orchestrator = class extends EventEmitter {
7163
7410
  return;
7164
7411
  }
7165
7412
  }
7413
+ await this.runBeforeRemoveHook(identifier);
7166
7414
  await this.workspace.removeWorkspace(identifier);
7167
7415
  }
7416
+ /** Run the beforeRemove hook for a workspace. Failures are logged but non-fatal. */
7417
+ async runBeforeRemoveHook(identifier) {
7418
+ const wsPath = this.workspace.resolvePath(identifier);
7419
+ const result = await this.hooks.beforeRemove(wsPath);
7420
+ if (!result.ok) {
7421
+ this.logger.warn(`beforeRemove hook failed for ${identifier}: ${result.error.message}`);
7422
+ }
7423
+ }
7168
7424
  /**
7169
7425
  * Delegates to PRDetector.filterCandidatesWithOpenPRs.
7170
7426
  * @see PRDetector#filterCandidatesWithOpenPRs
@@ -7334,7 +7590,11 @@ var Orchestrator = class extends EventEmitter {
7334
7590
  if (!result.ok) {
7335
7591
  this.logger.warn(`Lifecycle comment failed for ${identifier}: ${result.error.message}`);
7336
7592
  }
7337
- } catch {
7593
+ } catch (err) {
7594
+ this.logger.debug("Lifecycle comment failed (best-effort)", {
7595
+ identifier,
7596
+ error: String(err)
7597
+ });
7338
7598
  }
7339
7599
  }
7340
7600
  /**
@@ -7351,6 +7611,12 @@ var Orchestrator = class extends EventEmitter {
7351
7611
  const workspaceResult = await this.workspace.ensureWorkspace(issue.identifier);
7352
7612
  if (!workspaceResult.ok) throw workspaceResult.error;
7353
7613
  const workspacePath = workspaceResult.value;
7614
+ const afterCreateResult = await this.hooks.afterCreate(workspacePath);
7615
+ if (!afterCreateResult.ok) {
7616
+ this.logger.warn(
7617
+ `afterCreate hook failed for ${issue.identifier}: ${afterCreateResult.error.message}`
7618
+ );
7619
+ }
7354
7620
  const hookResult = await this.hooks.beforeRun(workspacePath);
7355
7621
  if (!hookResult.ok) throw hookResult.error;
7356
7622
  const scanResult = await scanWorkspaceConfig(workspacePath);
@@ -7460,21 +7726,54 @@ var Orchestrator = class extends EventEmitter {
7460
7726
  runAgentInBackgroundTask(issue, workspacePath, prompt, attempt, runner) {
7461
7727
  const activeRunner = runner ?? this.runner;
7462
7728
  this.logger.info(`Starting background task for ${issue.identifier}`);
7729
+ const abortController = new AbortController();
7730
+ this.abortControllers.set(issue.id, { controller: abortController, pid: null });
7463
7731
  (async () => {
7464
7732
  try {
7465
7733
  this.logger.info(`Calling runner.runSession for ${issue.identifier}`);
7466
7734
  const sessionGen = activeRunner.runSession(issue, workspacePath, prompt);
7467
7735
  for await (const event of sessionGen) {
7736
+ if (abortController.signal.aborted) {
7737
+ this.logger.info(`Agent session aborted for ${issue.identifier}`);
7738
+ break;
7739
+ }
7740
+ if (event.type === "session_started" && event.content) {
7741
+ const pid = event.content.pid;
7742
+ if (pid) {
7743
+ const tracked = this.abortControllers.get(issue.id);
7744
+ if (tracked) tracked.pid = pid;
7745
+ }
7746
+ }
7468
7747
  await this.processAgentEvent(issue, event);
7469
7748
  if (event.type === "turn_start") {
7470
7749
  await this.awaitRateLimitClearance(issue.identifier);
7471
7750
  }
7472
7751
  }
7473
7752
  this.logger.info(`Session generator finished for ${issue.identifier}`);
7474
- await this.emitWorkerExit(issue.id, "normal", attempt);
7753
+ const afterRunResult = await this.hooks.afterRun(workspacePath);
7754
+ if (!afterRunResult.ok) {
7755
+ this.logger.warn(
7756
+ `afterRun hook failed for ${issue.identifier}: ${afterRunResult.error.message}`
7757
+ );
7758
+ }
7759
+ if (abortController.signal.aborted) {
7760
+ if (this.state.running.has(issue.id)) {
7761
+ await this.emitWorkerExit(issue.id, "error", attempt, "Stopped by reconciliation");
7762
+ }
7763
+ } else {
7764
+ await this.emitWorkerExit(issue.id, "normal", attempt);
7765
+ }
7475
7766
  } catch (error) {
7476
7767
  this.logger.error(`Agent runner failed for ${issue.identifier}`, { error: String(error) });
7768
+ const afterRunResult = await this.hooks.afterRun(workspacePath);
7769
+ if (!afterRunResult.ok) {
7770
+ this.logger.warn(
7771
+ `afterRun hook failed for ${issue.identifier}: ${afterRunResult.error.message}`
7772
+ );
7773
+ }
7477
7774
  await this.emitWorkerExit(issue.id, "error", attempt, String(error));
7775
+ } finally {
7776
+ this.abortControllers.delete(issue.id);
7478
7777
  }
7479
7778
  })().catch((err) => {
7480
7779
  this.logger.error("Fatal error in background task", { error: String(err) });
@@ -7500,6 +7799,19 @@ var Orchestrator = class extends EventEmitter {
7500
7799
  */
7501
7800
  async stopIssue(issueId) {
7502
7801
  this.logger.info(`Stopping issue: ${issueId}`);
7802
+ const tracked = this.abortControllers.get(issueId);
7803
+ if (tracked) {
7804
+ tracked.controller.abort();
7805
+ this.logger.info(`Abort signal sent for ${issueId}`);
7806
+ }
7807
+ const pid = tracked?.pid ?? this.state.running.get(issueId)?.session?.agentPid;
7808
+ if (pid) {
7809
+ try {
7810
+ process.kill(pid, "SIGTERM");
7811
+ this.logger.info(`Sent SIGTERM to agent PID ${pid} for ${issueId}`);
7812
+ } catch {
7813
+ }
7814
+ }
7503
7815
  }
7504
7816
  /**
7505
7817
  * Dispatch a work item immediately, bypassing the normal tick → roadmap cycle.