@harness-engineering/orchestrator 0.2.6 → 0.2.8

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.d.mts CHANGED
@@ -1,11 +1,13 @@
1
- import { Issue, AgentEvent, TokenUsage, WorkflowConfig, Result, WorkflowDefinition, IssueTrackerClient, TrackerConfig, WorkspaceConfig, HooksConfig, AgentBackend, SessionStartParams, AgentSession, AgentError, TurnParams, TurnResult } from '@harness-engineering/types';
1
+ import { Issue, AgentEvent, TokenUsage, ConcernSignal, ScopeTier, EscalationConfig, RoutingDecision, WorkflowConfig, IssueTrackerClient, Result, WorkflowDefinition, TrackerConfig, WorkspaceConfig, HooksConfig, AgentBackend, SessionStartParams, AgentSession, AgentError, TurnParams, TurnResult } from '@harness-engineering/types';
2
+ import { EnrichedSpec, ComplexityScore, SimulationResult } from '@harness-engineering/intelligence';
3
+ import { execFile } from 'node:child_process';
2
4
  import { EventEmitter } from 'node:events';
3
5
 
4
6
  /**
5
7
  * Run attempt lifecycle phases (internal to orchestrator).
6
8
  * Tracks the current phase of a single run attempt.
7
9
  */
8
- type RunAttemptPhase = 'PreparingWorkspace' | 'BuildingPrompt' | 'LaunchingAgent' | 'InitializingSession' | 'StreamingTurn' | 'Finishing' | 'Succeeded' | 'Failed' | 'TimedOut' | 'Stalled' | 'CanceledByReconciliation';
10
+ type RunAttemptPhase = 'PreparingWorkspace' | 'BuildingPrompt' | 'LaunchingAgent' | 'InitializingSession' | 'StreamingTurn' | 'RateLimitSleeping' | 'Finishing' | 'Succeeded' | 'Failed' | 'TimedOut' | 'Stalled' | 'CanceledByReconciliation';
9
11
  /**
10
12
  * Live session metadata tracked while an agent subprocess is running.
11
13
  */
@@ -55,12 +57,14 @@ interface TokenTotals {
55
57
  inputTokens: number;
56
58
  outputTokens: number;
57
59
  totalTokens: number;
60
+ cacheReadTokens: number;
61
+ cacheCreationTokens: number;
58
62
  secondsRunning: number;
59
63
  }
60
64
  /**
61
65
  * Rate limit snapshot (populated by agent events).
62
66
  */
63
- interface RateLimitSnapshot {
67
+ interface RateLimitSnapshot$1 {
64
68
  requestsRemaining: number | null;
65
69
  requestsLimit: number | null;
66
70
  tokensRemaining: number | null;
@@ -72,25 +76,50 @@ interface RateLimitSnapshot {
72
76
  interface OrchestratorState {
73
77
  pollIntervalMs: number;
74
78
  maxConcurrentAgents: number;
79
+ globalCooldownUntilMs: number | null;
80
+ recentRequestTimestamps: number[];
81
+ recentInputTokens: {
82
+ timestamp: number;
83
+ tokens: number;
84
+ }[];
85
+ recentOutputTokens: {
86
+ timestamp: number;
87
+ tokens: number;
88
+ }[];
89
+ globalCooldownMs: number;
90
+ maxRequestsPerMinute: number;
91
+ maxRequestsPerSecond: number;
92
+ maxInputTokensPerMinute: number;
93
+ maxOutputTokensPerMinute: number;
75
94
  running: Map<string, RunningEntry>;
76
95
  claimed: Set<string>;
77
96
  retryAttempts: Map<string, RetryEntry>;
78
97
  completed: Set<string>;
79
98
  tokenTotals: TokenTotals;
80
- rateLimits: RateLimitSnapshot;
99
+ rateLimits: RateLimitSnapshot$1;
100
+ /** Running count of claim rejections (another orchestrator won the race). */
101
+ claimRejections: number;
81
102
  }
82
103
 
83
104
  /**
84
105
  * Discriminated union of events that drive the orchestrator state machine.
85
106
  * All events are data -- the caller constructs them from I/O results.
86
107
  */
87
- type OrchestratorEvent = TickEvent | WorkerExitEvent | AgentUpdateEvent | RetryFiredEvent | StallDetectedEvent;
108
+ type OrchestratorEvent = TickEvent | WorkerExitEvent | AgentUpdateEvent | RetryFiredEvent | StallDetectedEvent | ClaimRejectedEvent;
88
109
  interface TickEvent {
89
110
  type: 'tick';
90
111
  candidates: Issue[];
91
112
  runningStates: Map<string, Issue>;
92
113
  /** Caller-supplied wall clock (ms since epoch). Keeps state machine pure. */
93
114
  nowMs: number;
115
+ /** Pre-computed concern signals from intelligence pipeline (issueId → signals) */
116
+ concernSignals?: Map<string, ConcernSignal[]>;
117
+ /** Pre-computed enriched specs from intelligence pipeline (issueId → spec) */
118
+ enrichedSpecs?: Map<string, EnrichedSpec>;
119
+ /** Pre-computed complexity scores from intelligence pipeline (issueId → score) */
120
+ complexityScores?: Map<string, ComplexityScore>;
121
+ /** Pre-computed PESL simulation results from intelligence pipeline (issueId -> result) */
122
+ simulationResults?: Map<string, SimulationResult>;
94
123
  }
95
124
  interface WorkerExitEvent {
96
125
  type: 'worker_exit';
@@ -115,15 +144,21 @@ interface StallDetectedEvent {
115
144
  type: 'stall_detected';
116
145
  issueId: string;
117
146
  }
147
+ interface ClaimRejectedEvent {
148
+ type: 'claim_rejected';
149
+ issueId: string;
150
+ }
118
151
  /**
119
152
  * Discriminated union of side effects returned by the state machine.
120
153
  * These are data describing what to do -- the orchestrator loop executes them.
121
154
  */
122
- type SideEffect = DispatchEffect | StopEffect | ScheduleRetryEffect | ReleaseClaimEffect | CleanWorkspaceEffect | UpdateTokensEffect | EmitLogEffect;
155
+ type SideEffect = DispatchEffect | StopEffect | ScheduleRetryEffect | ReleaseClaimEffect | CleanWorkspaceEffect | UpdateTokensEffect | EmitLogEffect | EscalateEffect | ClaimEffect;
123
156
  interface DispatchEffect {
124
157
  type: 'dispatch';
125
158
  issue: Issue;
126
159
  attempt: number | null;
160
+ /** Which backend to dispatch to. Defaults to 'primary' for backward compat. */
161
+ backend?: 'local' | 'primary';
127
162
  }
128
163
  interface StopEffect {
129
164
  type: 'stop';
@@ -158,6 +193,28 @@ interface EmitLogEffect {
158
193
  message: string;
159
194
  context?: Record<string, unknown>;
160
195
  }
196
+ interface EscalateEffect {
197
+ type: 'escalate';
198
+ issueId: string;
199
+ identifier: string;
200
+ reasons: string[];
201
+ /** Issue title for context in the interaction queue */
202
+ issueTitle?: string;
203
+ /** Issue description for context in the interaction queue */
204
+ issueDescription?: string | null;
205
+ /** Enriched spec from intelligence pipeline, if available */
206
+ enrichedSpec?: EnrichedSpec;
207
+ /** Complexity score from intelligence pipeline, if available */
208
+ complexityScore?: ComplexityScore;
209
+ }
210
+ interface ClaimEffect {
211
+ type: 'claim';
212
+ issue: Issue;
213
+ /** Which backend to dispatch to after a successful claim */
214
+ backend?: 'local' | 'primary';
215
+ /** Retry attempt number, if this is a retry dispatch */
216
+ attempt: number | null;
217
+ }
161
218
 
162
219
  /**
163
220
  * Calculate retry delay based on attempt number and retry type.
@@ -211,10 +268,245 @@ declare function canDispatch(state: OrchestratorState, issueState: string, maxCo
211
268
  */
212
269
  declare function reconcile(state: OrchestratorState, runningStates: ReadonlyMap<string, Issue>, activeStates: string[], terminalStates: string[]): SideEffect[];
213
270
 
271
+ /**
272
+ * Artifact presence metadata for scope tier detection.
273
+ */
274
+ interface ArtifactPresence {
275
+ hasSpec: boolean;
276
+ hasPlans: boolean;
277
+ }
278
+ /**
279
+ * Derive artifact presence from an Issue's spec and plans fields.
280
+ */
281
+ declare function artifactPresenceFromIssue(issue: Issue): ArtifactPresence;
282
+ /**
283
+ * Detect the scope tier for an issue based on label overrides and artifact presence.
284
+ *
285
+ * Label override (e.g., `scope:quick-fix`) takes precedence.
286
+ * Otherwise, infer from spec/plan presence:
287
+ * - No spec, no plan -> full-exploration
288
+ * - Spec or plan exists -> guided-change
289
+ */
290
+ declare function detectScopeTier(issue: Issue, artifacts: ArtifactPresence): ScopeTier;
291
+ /**
292
+ * Pure routing function. Determines whether an issue should be dispatched
293
+ * to the local backend, the primary backend, or escalated to needs-human.
294
+ *
295
+ * Routing rules (in order):
296
+ * 1. If tier is in alwaysHuman -> needs-human
297
+ * 2. If tier is in primaryExecute -> dispatch-primary
298
+ * 3. If tier is in autoExecute -> dispatch-local
299
+ * 4. If tier is in signalGated -> check concern signals
300
+ * 5. Otherwise -> dispatch-local (safe default)
301
+ */
302
+ declare function routeIssue(scopeTier: ScopeTier, concernSignals: ConcernSignal[], config: EscalationConfig): RoutingDecision;
303
+
304
+ /**
305
+ * Candidate skills the orchestrator may dispatch to. Keep this set
306
+ * small on purpose — the triage router is a coarse routing layer, not
307
+ * a skill registry.
308
+ */
309
+ type TriageSkill = 'code-review' | 'security-review' | 'planning' | 'debugging' | 'refactoring' | 'docs';
310
+ /**
311
+ * Shape-of-work signals the triage router consumes. These are derived
312
+ * from the Issue plus diff metadata by the caller.
313
+ */
314
+ interface TriageSignals {
315
+ /** Raw title/body prefix, e.g. "feat:", "fix:", "docs:", "security:" */
316
+ titlePrefix?: string;
317
+ /** True if the diff touches auth, crypto, session, or similar paths */
318
+ touchesSecuritySensitivePaths?: boolean;
319
+ /** True if the diff touches a migrations or schema directory */
320
+ touchesMigrationPaths?: boolean;
321
+ /** Count of changed files in the diff */
322
+ changedFileCount?: number;
323
+ /** Whether tests are failing on the issue branch */
324
+ hasFailingTests?: boolean;
325
+ /** Whether the issue is marked or labeled as a rollback */
326
+ isRollback?: boolean;
327
+ /** True if every changed file is `.md` */
328
+ isDocsOnly?: boolean;
329
+ }
330
+ /**
331
+ * Structured decision the orchestrator persists and consumes.
332
+ */
333
+ interface TriageDecision {
334
+ /** Target skill for dispatch. */
335
+ skill: TriageSkill;
336
+ /** Specific agent within the skill, optional. */
337
+ agent?: string;
338
+ /** Router confidence in the decision. */
339
+ confidence: 'high' | 'medium' | 'low';
340
+ /** Human-readable justifications (one entry per rule that matched). */
341
+ reasons: string[];
342
+ }
343
+ /**
344
+ * Optional user/project overrides for path heuristics and rule thresholds.
345
+ */
346
+ interface TriageConfig {
347
+ /** Max changed files for a fix to still route to code-review (default 3). */
348
+ smallFixChangedFileMax?: number;
349
+ }
350
+ /**
351
+ * Extract the conventional-commit prefix (lowercased) from a title.
352
+ * Returns undefined if no prefix is present.
353
+ */
354
+ declare function extractTitlePrefix(title: string | null | undefined): string | undefined;
355
+ /**
356
+ * Decide which skill/agent the orchestrator should dispatch this issue to.
357
+ *
358
+ * Rule order (first match wins):
359
+ * 1. isRollback → debugging (high)
360
+ * 2. security prefix or security paths → security-review (high)
361
+ * 3. docs prefix or docs-only diff → docs (high)
362
+ * 4. hasFailingTests → debugging (medium)
363
+ * 5. touchesMigrationPaths → planning (high)
364
+ * 6. fix: with small change → code-review (high)
365
+ * 7. feat: → planning (medium)
366
+ * 8. refactor: → refactoring (medium)
367
+ * 9. default → code-review (low)
368
+ *
369
+ * Triage is meant to run BEFORE `routeIssue()`; the selected skill is
370
+ * persisted on the live session so downstream dispatch does not
371
+ * re-derive it.
372
+ */
373
+ declare function triageIssue(issue: Issue, signals: TriageSignals, config?: TriageConfig): TriageDecision;
374
+
375
+ /**
376
+ * A pending human interaction, typically from an escalation.
377
+ */
378
+ interface PendingInteraction {
379
+ /** Unique interaction ID */
380
+ id: string;
381
+ /** ID of the related issue */
382
+ issueId: string;
383
+ /** Interaction type */
384
+ type: 'needs-human';
385
+ /** Reasons for escalation */
386
+ reasons: string[];
387
+ /** Context for the human */
388
+ context: {
389
+ issueTitle: string;
390
+ issueDescription: string | null;
391
+ specPath: string | null;
392
+ planPath: string | null;
393
+ relatedFiles: string[];
394
+ /** Enriched spec from intelligence pipeline, if available */
395
+ enrichedSpec?: {
396
+ intent: string;
397
+ summary: string;
398
+ affectedSystems: unknown[];
399
+ unknowns: string[];
400
+ ambiguities: string[];
401
+ riskSignals: string[];
402
+ };
403
+ /** Complexity score from intelligence pipeline, if available */
404
+ complexityScore?: {
405
+ overall: number;
406
+ confidence: number;
407
+ riskLevel: string;
408
+ blastRadius: {
409
+ services: number;
410
+ modules: number;
411
+ filesEstimated: number;
412
+ testFilesAffected: number;
413
+ };
414
+ dimensions: {
415
+ structural: number;
416
+ semantic: number;
417
+ historical: number;
418
+ };
419
+ reasoning: string[];
420
+ recommendedRoute: string;
421
+ };
422
+ };
423
+ /** ISO timestamp of creation */
424
+ createdAt: string;
425
+ /** Current status */
426
+ status: 'pending' | 'claimed' | 'resolved';
427
+ }
428
+ /**
429
+ * Persistent queue of pending human interactions.
430
+ * Each interaction is stored as a separate JSON file in the configured directory.
431
+ */
432
+ declare class InteractionQueue {
433
+ private dir;
434
+ private pushListeners;
435
+ /**
436
+ * @param dir - Directory path for storing interaction JSON files
437
+ */
438
+ constructor(dir: string);
439
+ /**
440
+ * Register a listener that fires after each push.
441
+ */
442
+ onPush(listener: (interaction: PendingInteraction) => void): void;
443
+ /**
444
+ * Push a new interaction to the queue.
445
+ * If a pending interaction for the same issueId already exists, it is
446
+ * replaced (upsert) so the directory never accumulates duplicates.
447
+ */
448
+ push(interaction: PendingInteraction): Promise<void>;
449
+ /**
450
+ * List all interactions (regardless of status).
451
+ */
452
+ list(): Promise<PendingInteraction[]>;
453
+ /**
454
+ * List only pending interactions.
455
+ */
456
+ listPending(): Promise<PendingInteraction[]>;
457
+ /**
458
+ * Update the status of an interaction.
459
+ */
460
+ updateStatus(id: string, status: PendingInteraction['status']): Promise<void>;
461
+ }
462
+
463
+ /**
464
+ * A persisted record of a single intelligence pipeline analysis run.
465
+ */
466
+ interface AnalysisRecord {
467
+ /** Issue ID this analysis belongs to */
468
+ issueId: string;
469
+ /** Issue identifier (human-readable) */
470
+ identifier: string;
471
+ /** Enriched spec from SEL, or null if skipped */
472
+ spec: EnrichedSpec | null;
473
+ /** Complexity score from CML, or null if skipped */
474
+ score: ComplexityScore | null;
475
+ /** PESL simulation result, or null if not run */
476
+ simulation: SimulationResult | null;
477
+ /** ISO timestamp when this analysis was recorded */
478
+ analyzedAt: string;
479
+ /** External tracker ID (e.g., "github:owner/repo#42"), populated at analysis time. Null if no tracker configured. */
480
+ externalId: string | null;
481
+ }
482
+ /**
483
+ * Persistent archive of intelligence pipeline analysis results.
484
+ * Each analysis is stored as a separate JSON file keyed by issue ID.
485
+ * Overwrites previous analysis for the same issue (latest result wins).
486
+ */
487
+ declare class AnalysisArchive {
488
+ private dir;
489
+ constructor(dir: string);
490
+ private safePath;
491
+ /**
492
+ * Write an analysis record to disk, overwriting any previous record for the same issue.
493
+ */
494
+ save(record: AnalysisRecord): Promise<void>;
495
+ /**
496
+ * Read the analysis record for a specific issue.
497
+ */
498
+ get(issueId: string): Promise<AnalysisRecord | null>;
499
+ /**
500
+ * List all archived analysis records.
501
+ */
502
+ list(): Promise<AnalysisRecord[]>;
503
+ }
504
+
214
505
  interface ApplyEventResult {
215
506
  nextState: OrchestratorState;
216
507
  effects: SideEffect[];
217
508
  }
509
+ declare function resolveEscalationConfig(config: WorkflowConfig): EscalationConfig;
218
510
  /**
219
511
  * Pure state machine transition function.
220
512
  *
@@ -229,6 +521,189 @@ declare function applyEvent(state: OrchestratorState, event: OrchestratorEvent,
229
521
  */
230
522
  declare function createEmptyState(config: WorkflowConfig): OrchestratorState;
231
523
 
524
+ /**
525
+ * Computes the delay (in ms) needed before the next API request can be made,
526
+ * based on a snapshot of recent request/token activity and rate-limit config.
527
+ *
528
+ * Returns 0 when no throttling is needed.
529
+ */
530
+ interface RateLimitSnapshot {
531
+ globalCooldownUntilMs: number | null;
532
+ recentRequestTimestamps: number[];
533
+ recentInputTokens: {
534
+ timestamp: number;
535
+ tokens: number;
536
+ }[];
537
+ recentOutputTokens: {
538
+ timestamp: number;
539
+ tokens: number;
540
+ }[];
541
+ }
542
+ interface RateLimitConfig {
543
+ maxRequestsPerMinute: number;
544
+ maxRequestsPerSecond: number;
545
+ maxInputTokensPerMinute: number;
546
+ maxOutputTokensPerMinute: number;
547
+ }
548
+ declare function computeRateLimitDelay(snapshot: RateLimitSnapshot, config: RateLimitConfig): number;
549
+
550
+ /**
551
+ * Renders an AnalysisRecord as a structured markdown comment.
552
+ * Format: summary header + reasoning bullets + collapsible JSON with discriminator.
553
+ *
554
+ * Used by both the orchestrator auto-publish and the CLI publish-analyses command.
555
+ */
556
+ declare function renderAnalysisComment(record: AnalysisRecord): string;
557
+
558
+ interface PublishedIndex {
559
+ [issueId: string]: string;
560
+ }
561
+ /**
562
+ * Load the published analyses index from disk.
563
+ * Returns an empty object if the file does not exist or is unparseable.
564
+ */
565
+ declare function loadPublishedIndex(projectRoot: string): PublishedIndex;
566
+ /**
567
+ * Persist the published analyses index to disk.
568
+ * Creates parent directories if they do not exist.
569
+ */
570
+ declare function savePublishedIndex(projectRoot: string, index: PublishedIndex): void;
571
+
572
+ /**
573
+ * Resolves the orchestrator identity. Uses the explicit configId if
574
+ * provided; otherwise reads or creates a persisted machine UUID at
575
+ * ~/.harness/orchestrator-id and derives a stable hash.
576
+ *
577
+ * Format: `orchestrator-{first8charsOfSha256(uuid)}`
578
+ * Example: `orchestrator-a7f3b2c1`
579
+ */
580
+ declare function resolveOrchestratorId(configId?: string): Promise<string>;
581
+ /** Exposed for testing only -- returns the path to the identity file. */
582
+ declare const ORCHESTRATOR_IDENTITY_FILE: string;
583
+
584
+ interface ClaimManagerConfig {
585
+ /** Delay in ms between claim write and verification read. Default: 2000 */
586
+ verifyDelayMs?: number;
587
+ /** State name used for claimed/in-progress issues. Default: 'in-progress' */
588
+ claimedState?: string;
589
+ }
590
+ /**
591
+ * Manages claim coordination for multi-orchestrator dispatch.
592
+ *
593
+ * Uses the tracker as the shared state layer. Claims are optimistic:
594
+ * write then verify after a delay to detect races.
595
+ */
596
+ declare class ClaimManager {
597
+ private tracker;
598
+ private orchestratorId;
599
+ private verifyDelayMs;
600
+ private claimedState;
601
+ constructor(tracker: IssueTrackerClient, orchestratorId: string, config?: ClaimManagerConfig);
602
+ /**
603
+ * Optimistically claims an issue then verifies ownership after a delay.
604
+ *
605
+ * Returns 'claimed' if the assignee matches this orchestrator after
606
+ * the verify delay, 'rejected' if another orchestrator won the race.
607
+ */
608
+ claimAndVerify(issueId: string): Promise<Result<'claimed' | 'rejected', Error>>;
609
+ /**
610
+ * Releases a claimed issue back to the available pool.
611
+ */
612
+ release(issueId: string): Promise<Result<void, Error>>;
613
+ /**
614
+ * Refreshes claim timestamps for all running issues in parallel.
615
+ * Failures are non-fatal — individual heartbeat failures are swallowed
616
+ * so one failing claim does not block others.
617
+ */
618
+ heartbeat(issueIds: string[]): Promise<void>;
619
+ /**
620
+ * Checks whether an issue's claim is stale based on its updatedAt
621
+ * timestamp and the configured TTL.
622
+ *
623
+ * Returns true if the claim should be considered expired (the
624
+ * owning orchestrator may have crashed).
625
+ */
626
+ isStale(issue: Issue, ttlMs: number): boolean;
627
+ /**
628
+ * Scans the tracker for "in-progress" issues assigned to this orchestrator
629
+ * and releases any that are not currently running in memory.
630
+ *
631
+ * Called once during orchestrator startup to clean up orphaned claims
632
+ * from a previous crash or unclean shutdown.
633
+ *
634
+ * @param runningIssueIds - Set of issue IDs currently in the running map
635
+ * @returns List of issue IDs that were released
636
+ */
637
+ reconcileOnStartup(runningIssueIds: ReadonlySet<string>): Promise<Result<string[], Error>>;
638
+ }
639
+
640
+ /**
641
+ * Minimal logger interface for PR detection.
642
+ * Accepts any structured logger that provides debug/info/warn.
643
+ */
644
+ interface PRDetectorLogger {
645
+ debug(message: string, meta?: Record<string, unknown>): void;
646
+ info(message: string, meta?: Record<string, unknown>): void;
647
+ warn(message: string, meta?: Record<string, unknown>): void;
648
+ }
649
+ /**
650
+ * Function signature compatible with Node's `child_process.execFile`.
651
+ * Allows injection for testing.
652
+ */
653
+ type ExecFileFn = typeof execFile;
654
+ /**
655
+ * Detects whether GitHub issues or branches already have open pull requests.
656
+ *
657
+ * Uses the `gh` CLI under the hood. All checks are fail-open: if `gh` is not
658
+ * installed, auth is missing, or the network is down, candidates pass through
659
+ * rather than being incorrectly blocked.
660
+ */
661
+ declare class PRDetector {
662
+ private logger;
663
+ private execFileFn;
664
+ private projectRoot;
665
+ constructor(opts: {
666
+ logger: PRDetectorLogger;
667
+ execFileFn?: ExecFileFn;
668
+ projectRoot: string;
669
+ });
670
+ /**
671
+ * Parse a `github:owner/repo#N` externalId into its parts.
672
+ * Returns null for invalid or non-GitHub formats.
673
+ */
674
+ parseExternalId(externalId: string): {
675
+ owner: string;
676
+ repo: string;
677
+ number: number;
678
+ } | null;
679
+ /**
680
+ * Checks whether a remote branch has an open pull request via `gh`.
681
+ * Returns true if a PR exists, false otherwise. Failures are treated
682
+ * as "no PR" to err on the side of preserving work.
683
+ */
684
+ branchHasPullRequest(branch: string): Promise<boolean>;
685
+ /**
686
+ * Checks whether a GitHub issue (identified by externalId) has an open PR
687
+ * linked to it via `closes #N` or similar keywords. Fail-open on API errors
688
+ * or non-GitHub externalId formats.
689
+ */
690
+ hasOpenPRForExternalId(externalId: string): Promise<boolean>;
691
+ /**
692
+ * Checks whether an issue identifier has an open GitHub PR by searching
693
+ * for a branch matching the `feat/<identifier>` naming convention used
694
+ * by dispatched agents. Fail-open on API errors.
695
+ */
696
+ hasOpenPRForIdentifier(identifier: string): Promise<boolean>;
697
+ /**
698
+ * Filters out candidates that already have an open GitHub PR, running
699
+ * checks with limited concurrency to avoid overwhelming the GitHub API.
700
+ * For candidates with an externalId, searches for PRs linked to the
701
+ * GitHub issue. Falls back to `feat/<identifier>` branch lookup otherwise.
702
+ * Fail-open on API errors.
703
+ */
704
+ filterCandidatesWithOpenPRs(candidates: Issue[]): Promise<Issue[]>;
705
+ }
706
+
232
707
  declare class WorkflowLoader {
233
708
  loadWorkflow(filePath: string): Promise<Result<WorkflowDefinition, Error>>;
234
709
  }
@@ -261,6 +736,28 @@ declare class RoadmapTrackerAdapter implements IssueTrackerClient {
261
736
  * @param stateNames - List of statuses to filter by
262
737
  */
263
738
  fetchIssuesByStates(stateNames: string[]): Promise<Result<Issue[], Error>>;
739
+ /**
740
+ * Transitions a roadmap feature into the first configured terminal state
741
+ * and rewrites the markdown file. Idempotent: if the feature is already
742
+ * in a terminal state, this is a no-op.
743
+ *
744
+ * The orchestrator calls this after a successful agent exit so the feature
745
+ * is no longer returned by `fetchCandidateIssues` on the next tick — and,
746
+ * critically, after an orchestrator restart.
747
+ */
748
+ markIssueComplete(issueId: string): Promise<Result<void, Error>>;
749
+ /**
750
+ * Claims an issue by transitioning its status to "in-progress" and
751
+ * writing the orchestratorId into the assignee field. Idempotent if
752
+ * already claimed by the same orchestratorId.
753
+ */
754
+ claimIssue(issueId: string, orchestratorId: string): Promise<Result<void, Error>>;
755
+ /**
756
+ * Releases a claimed issue by transitioning back to the first active
757
+ * state and clearing the assignee field.
758
+ */
759
+ releaseIssue(issueId: string): Promise<Result<void, Error>>;
760
+ private findFeatureById;
264
761
  /**
265
762
  * Fetches full issue details for a list of identifiers.
266
763
  *
@@ -290,7 +787,11 @@ declare class LinearGraphQLStub implements LinearGraphQLExtension {
290
787
 
291
788
  declare class WorkspaceManager {
292
789
  private config;
790
+ /** Absolute path to the git repository root (resolved lazily). */
791
+ private repoRoot;
293
792
  constructor(config: WorkspaceConfig);
793
+ /** Runs a git command and returns stdout. Extracted for testability. */
794
+ protected git(args: string[], cwd: string): Promise<string>;
294
795
  /**
295
796
  * Sanitizes an issue identifier to be safe for use as a directory name.
296
797
  */
@@ -300,15 +801,45 @@ declare class WorkspaceManager {
300
801
  */
301
802
  resolvePath(identifier: string): string;
302
803
  /**
303
- * Ensures the workspace directory exists.
804
+ * Discovers the git repository root from the workspace root directory.
805
+ */
806
+ private getRepoRoot;
807
+ /**
808
+ * Ensures the workspace exists as a git worktree so the agent has
809
+ * access to the full project source.
304
810
  */
305
811
  ensureWorkspace(identifier: string): Promise<Result<string, Error>>;
812
+ /**
813
+ * Best-effort `git fetch origin` so subsequent ref resolution sees the
814
+ * latest remote state. Failures (offline, no remote, auth errors) are
815
+ * swallowed — dispatch should not be blocked by transient network issues.
816
+ */
817
+ private tryFetch;
818
+ /**
819
+ * Resolves the ref that new worktrees should be based on.
820
+ *
821
+ * Priority order:
822
+ * 1. `config.baseRef` (explicit override). Throws if it doesn't resolve.
823
+ * 2. Default branch via `git symbolic-ref --short refs/remotes/origin/HEAD`.
824
+ * 3. Common fallbacks: `origin/main`, `origin/master`, `main`, `master`.
825
+ * 4. `HEAD` as an ultimate fallback (preserves old behavior for unusual
826
+ * repos without any of the above).
827
+ */
828
+ private resolveBaseRef;
829
+ /** Returns true iff `git rev-parse --verify` accepts the ref. */
830
+ private refExists;
306
831
  /**
307
832
  * Checks if a workspace exists.
308
833
  */
309
834
  exists(identifier: string): Promise<boolean>;
310
835
  /**
311
- * Removes a workspace directory.
836
+ * Checks whether a worktree has commits ahead of the base branch that have
837
+ * been pushed to a remote branch. Returns the remote branch name if found,
838
+ * or null if the worktree is on a detached HEAD with no pushed branch.
839
+ */
840
+ findPushedBranch(identifier: string): Promise<string | null>;
841
+ /**
842
+ * Removes a workspace directory and its git worktree registration.
312
843
  */
313
844
  removeWorkspace(identifier: string): Promise<Result<void, Error>>;
314
845
  }
@@ -334,22 +865,65 @@ declare class MockBackend implements AgentBackend {
334
865
  healthCheck(): Promise<Result<void, AgentError>>;
335
866
  }
336
867
 
337
- declare class ClaudeBackend implements AgentBackend {
338
- readonly name = "claude";
339
- private command;
340
- constructor(command?: string);
341
- startSession(params: SessionStartParams): Promise<Result<AgentSession, AgentError>>;
342
- runTurn(session: AgentSession, params: TurnParams): AsyncGenerator<AgentEvent, TurnResult, void>;
343
- stopSession(_session: AgentSession): Promise<Result<void, AgentError>>;
344
- healthCheck(): Promise<Result<void, AgentError>>;
345
- }
346
-
347
868
  declare class PromptRenderer {
348
869
  private engine;
349
870
  constructor();
350
871
  render(template: string, context: Record<string, unknown>): Promise<string>;
351
872
  }
352
873
 
874
+ /**
875
+ * Result of a single maintenance task run.
876
+ */
877
+ interface RunResult {
878
+ /** ID of the task that was run */
879
+ taskId: string;
880
+ /** ISO timestamp when the run started */
881
+ startedAt: string;
882
+ /** ISO timestamp when the run completed */
883
+ completedAt: string;
884
+ /** Outcome of the run */
885
+ status: 'success' | 'failure' | 'skipped' | 'no-issues';
886
+ /** Number of issues/findings detected */
887
+ findings: number;
888
+ /** Number of issues fixed */
889
+ fixed: number;
890
+ /** URL of the created/updated PR, or null if no PR was created */
891
+ prUrl: string | null;
892
+ /** Whether an existing PR was updated (vs newly created) */
893
+ prUpdated: boolean;
894
+ /** Error message if status is 'failure' */
895
+ error?: string;
896
+ }
897
+ /**
898
+ * Schedule entry for a single task, used in MaintenanceStatus.
899
+ */
900
+ interface ScheduleEntry {
901
+ /** Task identifier */
902
+ taskId: string;
903
+ /** ISO timestamp of the next scheduled run */
904
+ nextRun: string;
905
+ /** Result of the most recent run, or null if never run */
906
+ lastRun: RunResult | null;
907
+ }
908
+ /**
909
+ * Overall maintenance module status, exposed via dashboard API.
910
+ */
911
+ interface MaintenanceStatus {
912
+ /** Whether this orchestrator instance is the maintenance leader */
913
+ isLeader: boolean;
914
+ /** ISO timestamp of the last successful leader claim, or null */
915
+ lastLeaderClaim: string | null;
916
+ /** Schedule state for all enabled tasks */
917
+ schedule: ScheduleEntry[];
918
+ /** Currently executing task, or null if idle */
919
+ activeRun: {
920
+ taskId: string;
921
+ startedAt: string;
922
+ } | null;
923
+ /** History of completed runs (most recent first) */
924
+ history: RunResult[];
925
+ }
926
+
353
927
  /**
354
928
  * The central orchestrator that manages the lifecycle of coding agents.
355
929
  *
@@ -370,7 +944,28 @@ declare class Orchestrator extends EventEmitter {
370
944
  private promptTemplate;
371
945
  private server?;
372
946
  private interval?;
947
+ private heartbeatInterval?;
373
948
  private logger;
949
+ private interactionQueue;
950
+ private localRunner;
951
+ private pipeline;
952
+ private analysisArchive;
953
+ private graphStore;
954
+ private claimManager;
955
+ private prDetector;
956
+ private maintenanceScheduler;
957
+ private maintenanceReporter;
958
+ private orchestratorIdPromise;
959
+ /** Project root directory, derived from workspace root. */
960
+ private get projectRoot();
961
+ private graphLoaded;
962
+ private enrichedSpecsByIssue;
963
+ /** Tracks recently-failed intelligence analysis to avoid re-requesting every tick */
964
+ private analysisFailureCache;
965
+ /** Guards against overlapping ticks when a tick takes longer than the polling interval */
966
+ private tickInProgress;
967
+ /** Current tick-phase activity visible to the dashboard */
968
+ private tickActivity;
374
969
  /**
375
970
  * Creates a new Orchestrator instance.
376
971
  *
@@ -381,15 +976,71 @@ declare class Orchestrator extends EventEmitter {
381
976
  constructor(config: WorkflowConfig, promptTemplate: string, overrides?: {
382
977
  tracker?: IssueTrackerClient;
383
978
  backend?: AgentBackend;
979
+ execFileFn?: ExecFileFn;
384
980
  });
385
981
  private createTracker;
386
982
  private createBackend;
387
983
  /**
388
- * Run a single tick of the orchestrator loop.
984
+ * Creates a TaskRunner for the maintenance scheduler.
985
+ * Provides stub implementations for check/agent/command execution.
986
+ * Phase 4 (PRManager) and Phase 5 (Reporter) will enhance these.
987
+ */
988
+ private createMaintenanceTaskRunner;
989
+ /**
990
+ * Initializes the maintenance subsystem: reporter, scheduler, and server route wiring.
991
+ * Extracted from start() to keep function length under threshold.
992
+ */
993
+ private initMaintenance;
994
+ private createLocalBackend;
995
+ private createIntelligencePipeline;
996
+ /**
997
+ * Create the AnalysisProvider for the intelligence pipeline.
389
998
  *
390
- * This method fetches the latest issue states, applies them to the state machine,
391
- * and executes any resulting side effects (dispatching new agents, stopping agents, etc.).
999
+ * Resolution order:
1000
+ * 1. Explicit `intelligence.provider` config (separate key/endpoint)
1001
+ * 2. Local backend config (agent.localBackend + localEndpoint/localModel)
1002
+ * 3. Primary agent backend config (agent.apiKey + agent.backend)
392
1003
  */
1004
+ private createAnalysisProvider;
1005
+ private createProviderFromExplicitConfig;
1006
+ private runPeslSimulations;
1007
+ private archiveAnalysisResults;
1008
+ /**
1009
+ * Auto-publish analysis results to the external tracker as structured comments.
1010
+ * Fires only when:
1011
+ * - A tracker is configured in harness.config.json (roadmap.tracker)
1012
+ * - GITHUB_TOKEN env var is available
1013
+ * - The record has a non-null externalId
1014
+ * - The record has not already been published (per the published index)
1015
+ *
1016
+ * Errors are non-fatal: a failed publish logs a warning but does not block
1017
+ * the orchestrator tick.
1018
+ */
1019
+ private autoPublishAnalyses;
1020
+ private publishAnalysisForIssue;
1021
+ private runIntelligencePipeline;
1022
+ /**
1023
+ * Analyzes a single candidate issue through the intelligence pipeline.
1024
+ * Returns connection error state and whether the circuit breaker tripped.
1025
+ */
1026
+ private analyzeCandidate;
1027
+ private evictExpiredFailures;
1028
+ private filterEligibleForAnalysis;
1029
+ private analyzeCandidates;
1030
+ /**
1031
+ * Lazily initializes the ClaimManager if it hasn't been created yet.
1032
+ * Called from both start() and asyncTick() to avoid duplicating the init block.
1033
+ */
1034
+ private ensureClaimManager;
1035
+ /**
1036
+ * Loads the graph store and hydrates the enriched spec cache from the analysis
1037
+ * archive on the first tick. Subsequent calls are no-ops. All failures are
1038
+ * non-fatal — empty graph / empty cache are valid fallbacks.
1039
+ */
1040
+ private loadPersistedData;
1041
+ private loadGraphStore;
1042
+ private hydrateSpecCache;
1043
+ asyncTick(): Promise<void>;
393
1044
  tick(): Promise<void>;
394
1045
  /**
395
1046
  * Processes a side effect generated by the state machine.
@@ -397,6 +1048,53 @@ declare class Orchestrator extends EventEmitter {
397
1048
  * @param effect - The effect to handle
398
1049
  */
399
1050
  private handleEffect;
1051
+ /**
1052
+ * Guards workspace cleanup by checking whether the agent pushed a branch
1053
+ * that does not yet have a pull request. If so, the worktree is preserved
1054
+ * and an interaction is queued so a human can create the PR manually.
1055
+ */
1056
+ private cleanWorkspaceWithGuard;
1057
+ /**
1058
+ * Delegates to PRDetector.branchHasPullRequest.
1059
+ * @see PRDetector#branchHasPullRequest
1060
+ */
1061
+ private branchHasPullRequest;
1062
+ /**
1063
+ * Delegates to PRDetector.filterCandidatesWithOpenPRs.
1064
+ * @see PRDetector#filterCandidatesWithOpenPRs
1065
+ */
1066
+ private filterCandidatesWithOpenPRs;
1067
+ /**
1068
+ * Scans candidate issues for stale claims from other orchestrators.
1069
+ * An issue is considered stale if:
1070
+ * - It is in an "in-progress" state
1071
+ * - It has an assignee that is NOT this orchestrator
1072
+ * - Its updatedAt timestamp exceeds the heartbeat TTL
1073
+ *
1074
+ * Stale claims are released so the issue becomes available on subsequent ticks.
1075
+ */
1076
+ private releaseStaleClaims;
1077
+ /**
1078
+ * Handles an escalation effect by writing to the interaction queue and logging.
1079
+ */
1080
+ private handleEscalation;
1081
+ /**
1082
+ * Handles a claim effect by calling claimAndVerify on the ClaimManager.
1083
+ * If claimed, proceeds to dispatch. If rejected, emits a claim_rejected
1084
+ * event to clean up the state machine.
1085
+ */
1086
+ private handleClaimEffect;
1087
+ /**
1088
+ * Posts a structured comment on the GitHub issue when the orchestrator claims it.
1089
+ * Fire-and-forget: failures are logged but never block dispatch.
1090
+ */
1091
+ private postClaimComment;
1092
+ /**
1093
+ * Posts a lifecycle event comment to the GitHub issue.
1094
+ * Supports: claimed, completed, released.
1095
+ * Fire-and-forget: failures are logged but never block the caller.
1096
+ */
1097
+ private postLifecycleComment;
400
1098
  /**
401
1099
  * Dispatches a new agent to work on an issue.
402
1100
  *
@@ -404,10 +1102,9 @@ declare class Orchestrator extends EventEmitter {
404
1102
  * @param attempt - The retry attempt number
405
1103
  */
406
1104
  private dispatchIssue;
407
- /**
408
- * Runs an agent session in a background task to avoid blocking the main loop.
409
- */
410
1105
  private runAgentInBackgroundTask;
1106
+ private recordOutcomeIfPipelineEnabled;
1107
+ private handleCompletionSideEffects;
411
1108
  /**
412
1109
  * Informs the state machine that an agent worker has exited.
413
1110
  */
@@ -418,26 +1115,39 @@ declare class Orchestrator extends EventEmitter {
418
1115
  * @param issueId - The ID of the issue to stop
419
1116
  */
420
1117
  private stopIssue;
1118
+ /**
1119
+ * Dispatch a work item immediately, bypassing the normal tick → roadmap cycle.
1120
+ * Used by the dashboard's "Dispatch Now" action.
1121
+ */
1122
+ dispatchAdHoc(issue: Issue): Promise<void>;
421
1123
  /**
422
1124
  * Starts the polling loop and the internal HTTP server.
1125
+ * Runs startup reconciliation to release orphaned claims before the first tick.
423
1126
  */
424
- start(): void;
1127
+ start(): Promise<void>;
425
1128
  /**
426
1129
  * Stops the orchestrator, clearing the polling interval and stopping the server.
427
1130
  */
428
1131
  stop(): Promise<void>;
1132
+ /** Update tick activity and broadcast the change to connected clients. */
1133
+ private setTickActivity;
429
1134
  /**
430
1135
  * Returns a point-in-time snapshot of the orchestrator's internal state.
431
1136
  */
432
1137
  getSnapshot(): Record<string, unknown>;
1138
+ /** Returns the maintenance scheduler status, or null if maintenance is not enabled. */
1139
+ getMaintenanceStatus(): MaintenanceStatus | null;
433
1140
  }
434
1141
 
435
1142
  /**
436
1143
  * Launches the Ink TUI for the given Orchestrator instance.
437
1144
  * Returns a function to wait for the TUI to exit.
1145
+ *
1146
+ * @deprecated The TUI is maintained as a fallback for headless/SSH environments.
1147
+ * The web dashboard (served at port 8080) is the primary monitoring interface.
438
1148
  */
439
1149
  declare function launchTUI(orchestrator: Orchestrator): {
440
1150
  waitUntilExit: () => Promise<void>;
441
1151
  };
442
1152
 
443
- export { type AgentUpdateEvent, type ApplyEventResult, ClaudeBackend, type CleanWorkspaceEffect, type DispatchEffect, type EmitLogEffect, type LinearGraphQLExtension, LinearGraphQLStub, type LiveSession, MockBackend, Orchestrator, type OrchestratorEvent, type OrchestratorState, PromptRenderer, type RateLimitSnapshot, type ReleaseClaimEffect, type RetryEntry, type RetryFiredEvent, RoadmapTrackerAdapter, type RunAttemptPhase, type RunningEntry, type ScheduleRetryEffect, type SideEffect, type StallDetectedEvent, type StopEffect, type TickEvent, type TokenTotals, type UpdateTokensEffect, type WorkerExitEvent, WorkflowLoader, WorkspaceHooks, WorkspaceManager, applyEvent, calculateRetryDelay, canDispatch, createEmptyState, getAvailableSlots, getDefaultConfig, getPerStateCount, isEligible, launchTUI, reconcile, selectCandidates, sortCandidates, validateWorkflowConfig };
1153
+ export { type AgentUpdateEvent, AnalysisArchive, type AnalysisRecord, type ApplyEventResult, type ArtifactPresence, ClaimManager, type ClaimManagerConfig, type CleanWorkspaceEffect, type DispatchEffect, type EmitLogEffect, type EscalateEffect, type ExecFileFn, InteractionQueue, type LinearGraphQLExtension, LinearGraphQLStub, type LiveSession, MockBackend, ORCHESTRATOR_IDENTITY_FILE, Orchestrator, type OrchestratorEvent, type OrchestratorState, PRDetector, type PRDetectorLogger, type PendingInteraction, PromptRenderer, type PublishedIndex, type RateLimitSnapshot as RateLimitComputeSnapshot, type RateLimitConfig, type RateLimitSnapshot$1 as RateLimitSnapshot, type ReleaseClaimEffect, type RetryEntry, type RetryFiredEvent, RoadmapTrackerAdapter, type RunAttemptPhase, type RunningEntry, type ScheduleRetryEffect, type SideEffect, type StallDetectedEvent, type StopEffect, type TickEvent, type TokenTotals, type TriageConfig, type TriageDecision, type TriageSignals, type TriageSkill, type UpdateTokensEffect, type WorkerExitEvent, WorkflowLoader, WorkspaceHooks, WorkspaceManager, applyEvent, artifactPresenceFromIssue, calculateRetryDelay, canDispatch, computeRateLimitDelay, createEmptyState, detectScopeTier, extractTitlePrefix, getAvailableSlots, getDefaultConfig, getPerStateCount, isEligible, launchTUI, loadPublishedIndex, reconcile, renderAnalysisComment, resolveEscalationConfig, resolveOrchestratorId, routeIssue, savePublishedIndex, selectCandidates, sortCandidates, triageIssue, validateWorkflowConfig };