@generacy-ai/orchestrator 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/config/loader.d.ts.map +1 -1
  2. package/dist/config/loader.js +15 -0
  3. package/dist/config/loader.js.map +1 -1
  4. package/dist/config/schema.d.ts +30 -0
  5. package/dist/config/schema.d.ts.map +1 -1
  6. package/dist/config/schema.js +13 -1
  7. package/dist/config/schema.js.map +1 -1
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +3 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/routes/health.d.ts +7 -0
  13. package/dist/routes/health.d.ts.map +1 -1
  14. package/dist/routes/health.js +5 -0
  15. package/dist/routes/health.js.map +1 -1
  16. package/dist/routes/internal-relay-events.d.ts +2 -2
  17. package/dist/routes/internal-relay-events.d.ts.map +1 -1
  18. package/dist/routes/internal-relay-events.js +12 -0
  19. package/dist/routes/internal-relay-events.js.map +1 -1
  20. package/dist/routes/retained-tunnel-event.d.ts +20 -0
  21. package/dist/routes/retained-tunnel-event.d.ts.map +1 -0
  22. package/dist/routes/retained-tunnel-event.js +67 -0
  23. package/dist/routes/retained-tunnel-event.js.map +1 -0
  24. package/dist/server.d.ts.map +1 -1
  25. package/dist/server.js +197 -34
  26. package/dist/server.js.map +1 -1
  27. package/dist/services/adaptive-poll-controller.d.ts +75 -0
  28. package/dist/services/adaptive-poll-controller.d.ts.map +1 -0
  29. package/dist/services/adaptive-poll-controller.js +96 -0
  30. package/dist/services/adaptive-poll-controller.js.map +1 -0
  31. package/dist/services/clarification-answer-monitor-service.d.ts +93 -0
  32. package/dist/services/clarification-answer-monitor-service.d.ts.map +1 -0
  33. package/dist/services/clarification-answer-monitor-service.js +452 -0
  34. package/dist/services/clarification-answer-monitor-service.js.map +1 -0
  35. package/dist/services/failure-fingerprint-tracker.d.ts +28 -0
  36. package/dist/services/failure-fingerprint-tracker.d.ts.map +1 -0
  37. package/dist/services/failure-fingerprint-tracker.js +38 -0
  38. package/dist/services/failure-fingerprint-tracker.js.map +1 -0
  39. package/dist/services/index.d.ts +1 -0
  40. package/dist/services/index.d.ts.map +1 -1
  41. package/dist/services/index.js +3 -0
  42. package/dist/services/index.js.map +1 -1
  43. package/dist/services/label-monitor-service.d.ts +21 -2
  44. package/dist/services/label-monitor-service.d.ts.map +1 -1
  45. package/dist/services/label-monitor-service.js +47 -16
  46. package/dist/services/label-monitor-service.js.map +1 -1
  47. package/dist/services/merge-conflict-monitor-service.d.ts +14 -1
  48. package/dist/services/merge-conflict-monitor-service.d.ts.map +1 -1
  49. package/dist/services/merge-conflict-monitor-service.js +46 -13
  50. package/dist/services/merge-conflict-monitor-service.js.map +1 -1
  51. package/dist/services/pr-feedback-monitor-service.d.ts +14 -1
  52. package/dist/services/pr-feedback-monitor-service.d.ts.map +1 -1
  53. package/dist/services/pr-feedback-monitor-service.js +46 -16
  54. package/dist/services/pr-feedback-monitor-service.js.map +1 -1
  55. package/dist/services/relay-bridge.d.ts +1 -0
  56. package/dist/services/relay-bridge.d.ts.map +1 -1
  57. package/dist/services/relay-bridge.js +21 -0
  58. package/dist/services/relay-bridge.js.map +1 -1
  59. package/dist/services/smee-channel-resolver.d.ts +60 -0
  60. package/dist/services/smee-channel-resolver.d.ts.map +1 -0
  61. package/dist/services/smee-channel-resolver.js +177 -0
  62. package/dist/services/smee-channel-resolver.js.map +1 -0
  63. package/dist/services/smee-receiver.d.ts +39 -21
  64. package/dist/services/smee-receiver.d.ts.map +1 -1
  65. package/dist/services/smee-receiver.js +134 -28
  66. package/dist/services/smee-receiver.js.map +1 -1
  67. package/dist/services/webhook-setup-service.d.ts +115 -111
  68. package/dist/services/webhook-setup-service.d.ts.map +1 -1
  69. package/dist/services/webhook-setup-service.js +377 -209
  70. package/dist/services/webhook-setup-service.js.map +1 -1
  71. package/dist/types/api.d.ts +3 -0
  72. package/dist/types/api.d.ts.map +1 -1
  73. package/dist/types/api.js +1 -0
  74. package/dist/types/api.js.map +1 -1
  75. package/dist/types/monitor.d.ts +18 -2
  76. package/dist/types/monitor.d.ts.map +1 -1
  77. package/dist/worker/clarification-markers.d.ts +72 -0
  78. package/dist/worker/clarification-markers.d.ts.map +1 -1
  79. package/dist/worker/clarification-markers.js +111 -0
  80. package/dist/worker/clarification-markers.js.map +1 -1
  81. package/dist/worker/clarification-poster.d.ts +102 -6
  82. package/dist/worker/clarification-poster.d.ts.map +1 -1
  83. package/dist/worker/clarification-poster.js +299 -89
  84. package/dist/worker/clarification-poster.js.map +1 -1
  85. package/dist/worker/claude-cli-worker.d.ts +7 -0
  86. package/dist/worker/claude-cli-worker.d.ts.map +1 -1
  87. package/dist/worker/claude-cli-worker.js +3 -0
  88. package/dist/worker/claude-cli-worker.js.map +1 -1
  89. package/dist/worker/failure-fingerprint.d.ts +41 -0
  90. package/dist/worker/failure-fingerprint.d.ts.map +1 -0
  91. package/dist/worker/failure-fingerprint.js +75 -0
  92. package/dist/worker/failure-fingerprint.js.map +1 -0
  93. package/dist/worker/label-manager.d.ts +42 -0
  94. package/dist/worker/label-manager.d.ts.map +1 -1
  95. package/dist/worker/label-manager.js +108 -4
  96. package/dist/worker/label-manager.js.map +1 -1
  97. package/dist/worker/phase-loop.d.ts +30 -0
  98. package/dist/worker/phase-loop.d.ts.map +1 -1
  99. package/dist/worker/phase-loop.js +124 -26
  100. package/dist/worker/phase-loop.js.map +1 -1
  101. package/dist/worker/pr-feedback-handler.d.ts +13 -0
  102. package/dist/worker/pr-feedback-handler.d.ts.map +1 -1
  103. package/dist/worker/pr-feedback-handler.js +48 -0
  104. package/dist/worker/pr-feedback-handler.js.map +1 -1
  105. package/dist/worker/stage-comment-manager.d.ts +5 -0
  106. package/dist/worker/stage-comment-manager.d.ts.map +1 -1
  107. package/dist/worker/stage-comment-manager.js +8 -1
  108. package/dist/worker/stage-comment-manager.js.map +1 -1
  109. package/dist/worker/terminal-label-op-error.d.ts +1 -1
  110. package/dist/worker/terminal-label-op-error.d.ts.map +1 -1
  111. package/dist/worker/terminal-label-op-error.js.map +1 -1
  112. package/dist/worker/types.d.ts +31 -0
  113. package/dist/worker/types.d.ts.map +1 -1
  114. package/dist/worker/types.js +11 -0
  115. package/dist/worker/types.js.map +1 -1
  116. package/package.json +7 -6
@@ -0,0 +1,96 @@
1
+ /**
2
+ * #953 — Shared adaptive-polling decision function used by
3
+ * `LabelMonitorService`, `PrFeedbackMonitorService`, and
4
+ * `MergeConflictMonitorService`. Extracted to eliminate the three-way
5
+ * copy-paste that produced the "smee-less cluster stuck at base cadence" bug.
6
+ *
7
+ * Pure: no I/O, no `this`, no `Date.now()`. Time flows in via `params.nowMs`.
8
+ */
9
+ /**
10
+ * Fast interval calculation shared between `decideAdaptivePoll` and callers
11
+ * that need the value at construction time.
12
+ */
13
+ export function computeFastInterval(basePollIntervalMs, adaptiveDivisor, minPollIntervalMs) {
14
+ return Math.max(minPollIntervalMs, Math.floor(basePollIntervalMs / adaptiveDivisor));
15
+ }
16
+ /**
17
+ * Decide the next poll cadence given current state and tuning.
18
+ *
19
+ * Branch summary:
20
+ * - webhooksConfigured=false, adaptivePolling=true:
21
+ * cycle 1 → { fast, healthy=false, to-fast, webhooks-not-configured }
22
+ * cycle 2+ → { fast, healthy=false, none, webhooks-not-configured }
23
+ * - webhooksConfigured=false, adaptivePolling=false:
24
+ * always → { base, healthy=true, none, operator-opt-out }
25
+ * - webhooksConfigured=true, lastWebhookEvent===null:
26
+ * always → { unchanged, unchanged, none, quiet }
27
+ * - webhooksConfigured=true, lastWebhookEvent!==null:
28
+ * staleness = nowMs - lastWebhookEvent; threshold = basePoll * 2
29
+ * if (staleness > threshold && webhookHealthy) → to-fast, webhook-stale
30
+ * else → { unchanged, unchanged, none, quiet }
31
+ *
32
+ * The `recordWebhookEvent` handler in each service also delegates here for
33
+ * the recovery path — it flips `webhookHealthy=true` and `lastWebhookEvent`
34
+ * before calling; the helper returns `to-base, webhook-recovered` when the
35
+ * caller was previously at the fast interval.
36
+ */
37
+ export function decideAdaptivePoll(params) {
38
+ const { webhooksConfigured, adaptivePolling, basePollIntervalMs, currentPollIntervalMs, lastWebhookEvent, webhookHealthy, adaptiveDivisor, minPollIntervalMs, nowMs, } = params;
39
+ const fastIntervalMs = computeFastInterval(basePollIntervalMs, adaptiveDivisor, minPollIntervalMs);
40
+ if (!webhooksConfigured) {
41
+ if (!adaptivePolling) {
42
+ return {
43
+ currentPollIntervalMs: basePollIntervalMs,
44
+ webhookHealthy: true,
45
+ transition: 'none',
46
+ reason: 'operator-opt-out',
47
+ };
48
+ }
49
+ // adaptivePolling === true, webhooksConfigured === false:
50
+ // fast interval, unhealthy. Emit `to-fast` on the transition edge only.
51
+ const transition = webhookHealthy ? 'to-fast' : 'none';
52
+ return {
53
+ currentPollIntervalMs: fastIntervalMs,
54
+ webhookHealthy: false,
55
+ transition,
56
+ reason: 'webhooks-not-configured',
57
+ };
58
+ }
59
+ // webhooksConfigured === true from here on.
60
+ if (lastWebhookEvent === null) {
61
+ // Grace: never received an event, don't ratchet down cadence.
62
+ return {
63
+ currentPollIntervalMs,
64
+ webhookHealthy,
65
+ transition: 'none',
66
+ reason: 'quiet',
67
+ };
68
+ }
69
+ const staleness = nowMs - lastWebhookEvent;
70
+ const staleThreshold = basePollIntervalMs * 2;
71
+ // Recovery path: caller set webhookHealthy=true before delegating.
72
+ // If we're on the fast interval, transition back to base.
73
+ if (webhookHealthy && currentPollIntervalMs !== basePollIntervalMs) {
74
+ return {
75
+ currentPollIntervalMs: basePollIntervalMs,
76
+ webhookHealthy: true,
77
+ transition: 'to-base',
78
+ reason: 'webhook-recovered',
79
+ };
80
+ }
81
+ if (staleness > staleThreshold && webhookHealthy) {
82
+ return {
83
+ currentPollIntervalMs: fastIntervalMs,
84
+ webhookHealthy: false,
85
+ transition: 'to-fast',
86
+ reason: 'webhook-stale',
87
+ };
88
+ }
89
+ return {
90
+ currentPollIntervalMs,
91
+ webhookHealthy,
92
+ transition: 'none',
93
+ reason: 'quiet',
94
+ };
95
+ }
96
+ //# sourceMappingURL=adaptive-poll-controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adaptive-poll-controller.js","sourceRoot":"","sources":["../../src/services/adaptive-poll-controller.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAiDH;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CACjC,kBAA0B,EAC1B,eAAuB,EACvB,iBAAyB;IAEzB,OAAO,IAAI,CAAC,GAAG,CAAC,iBAAiB,EAAE,IAAI,CAAC,KAAK,CAAC,kBAAkB,GAAG,eAAe,CAAC,CAAC,CAAC;AACvF,CAAC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,UAAU,kBAAkB,CAAC,MAA0B;IAC3D,MAAM,EACJ,kBAAkB,EAClB,eAAe,EACf,kBAAkB,EAClB,qBAAqB,EACrB,gBAAgB,EAChB,cAAc,EACd,eAAe,EACf,iBAAiB,EACjB,KAAK,GACN,GAAG,MAAM,CAAC;IAEX,MAAM,cAAc,GAAG,mBAAmB,CAAC,kBAAkB,EAAE,eAAe,EAAE,iBAAiB,CAAC,CAAC;IAEnG,IAAI,CAAC,kBAAkB,EAAE,CAAC;QACxB,IAAI,CAAC,eAAe,EAAE,CAAC;YACrB,OAAO;gBACL,qBAAqB,EAAE,kBAAkB;gBACzC,cAAc,EAAE,IAAI;gBACpB,UAAU,EAAE,MAAM;gBAClB,MAAM,EAAE,kBAAkB;aAC3B,CAAC;QACJ,CAAC;QACD,0DAA0D;QAC1D,wEAAwE;QACxE,MAAM,UAAU,GAAG,cAAc,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC;QACvD,OAAO;YACL,qBAAqB,EAAE,cAAc;YACrC,cAAc,EAAE,KAAK;YACrB,UAAU;YACV,MAAM,EAAE,yBAAyB;SAClC,CAAC;IACJ,CAAC;IAED,4CAA4C;IAE5C,IAAI,gBAAgB,KAAK,IAAI,EAAE,CAAC;QAC9B,8DAA8D;QAC9D,OAAO;YACL,qBAAqB;YACrB,cAAc;YACd,UAAU,EAAE,MAAM;YAClB,MAAM,EAAE,OAAO;SAChB,CAAC;IACJ,CAAC;IAED,MAAM,SAAS,GAAG,KAAK,GAAG,gBAAgB,CAAC;IAC3C,MAAM,cAAc,GAAG,kBAAkB,GAAG,CAAC,CAAC;IAE9C,mEAAmE;IACnE,0DAA0D;IAC1D,IAAI,cAAc,IAAI,qBAAqB,KAAK,kBAAkB,EAAE,CAAC;QACnE,OAAO;YACL,qBAAqB,EAAE,kBAAkB;YACzC,cAAc,EAAE,IAAI;YACpB,UAAU,EAAE,SAAS;YACrB,MAAM,EAAE,mBAAmB;SAC5B,CAAC;IACJ,CAAC;IAED,IAAI,SAAS,GAAG,cAAc,IAAI,cAAc,EAAE,CAAC;QACjD,OAAO;YACL,qBAAqB,EAAE,cAAc;YACrC,cAAc,EAAE,KAAK;YACrB,UAAU,EAAE,SAAS;YACrB,MAAM,EAAE,eAAe;SACxB,CAAC;IACJ,CAAC;IAED,OAAO;QACL,qBAAqB;QACrB,cAAc;QACd,UAAU,EAAE,MAAM;QAClB,MAAM,EAAE,OAAO;KAChB,CAAC;AACJ,CAAC"}
@@ -0,0 +1,93 @@
1
+ /**
2
+ * #958 T013 — Poll-based monitor that enqueues `continue` resume queue items
3
+ * for issues sitting at `waiting-for:clarification` + `agent:paused` when a
4
+ * new human-authored comment appears.
5
+ *
6
+ * Shape template: `merge-conflict-monitor-service.ts` (spec §Assumptions).
7
+ * Contract: `specs/958-found-during-local-snappoll/contracts/
8
+ * clarification-answer-monitor.md`.
9
+ *
10
+ * Key divergences from the merge-conflict monitor (per data-model.md):
11
+ * - Precondition label: `waiting-for:clarification` (was `waiting-for:merge-conflicts`).
12
+ * - Additional predicate: at least one comment with `viewerDidAuthor === false`
13
+ * that also passes `isTrustedCommentAuthor(_, 'answer-scanner', trustCtx)`.
14
+ * Comments carrying the engine-written answer marker are treated as
15
+ * cluster-self (not counted).
16
+ * - Queue command: `command: 'continue'` (was `'resolve-merge-conflicts'`).
17
+ *
18
+ * Non-behavior (contract):
19
+ * - MUST NOT apply `completed:clarification`. That label is reserved for
20
+ * the human's explicit force-advance override (FR-011).
21
+ * - MUST NOT modify `clarifications.md` — the monitor has no checkout; the
22
+ * phase loop performs integration on the resume path.
23
+ * - MUST NOT clear `waiting-for:clarification` or `agent:paused`.
24
+ */
25
+ import { type GitHubClientFactory } from '@generacy-ai/workflow-engine';
26
+ import type { MonitorState, QueueManager } from '../types/monitor.js';
27
+ import type { RepositoryConfig, PrMonitorConfig } from '../config/schema.js';
28
+ import type { Logger } from '../worker/types.js';
29
+ import type { AuthHealthSink } from './label-monitor-service.js';
30
+ export interface ClarificationAnswerEvent {
31
+ owner: string;
32
+ repo: string;
33
+ issueNumber: number;
34
+ issueLabels: string[];
35
+ source: 'poll';
36
+ }
37
+ export interface ClarificationAnswerMonitorOptions {
38
+ repositories: RepositoryConfig[];
39
+ pollIntervalMs: number;
40
+ adaptivePolling: boolean;
41
+ maxConcurrentPolls: number;
42
+ }
43
+ /**
44
+ * #987: options for the runtime `setWebhooksConfigured(true, opts?)` flip.
45
+ * See specs/987-summary-cluster-where-smee/contracts/setter-contract.md.
46
+ */
47
+ export interface SetWebhooksConfiguredOptions {
48
+ basePollIntervalMs?: number;
49
+ }
50
+ export declare class ClarificationAnswerMonitorService {
51
+ private readonly logger;
52
+ private readonly createClient;
53
+ private readonly tokenProvider?;
54
+ private readonly queueManager;
55
+ private readonly options;
56
+ private readonly clusterGithubUsername;
57
+ private readonly authHealth;
58
+ private readonly githubAppCredentialId;
59
+ private abortController;
60
+ private state;
61
+ constructor(logger: Logger, createClient: GitHubClientFactory, queueManager: QueueManager, config: PrMonitorConfig, repositories: RepositoryConfig[], clusterGithubUsername?: string, tokenProvider?: () => Promise<string | undefined>, authHealth?: AuthHealthSink, githubAppCredentialId?: string, webhooksConfigured?: boolean);
62
+ /**
63
+ * Process a clarification-answer event: verify preconditions, check for a
64
+ * new human-authored comment, enqueue a resume via `enqueueIfAbsent`.
65
+ *
66
+ * Returns true if enqueued, false if skipped or duplicate.
67
+ */
68
+ processClarificationAnswerEvent(event: ClarificationAnswerEvent): Promise<boolean>;
69
+ /**
70
+ * #993 — positive answer predicate. Returns the first comment that qualifies
71
+ * as a real clarification answer, or `undefined` if none exists on the issue.
72
+ *
73
+ * Short-circuits when the issue carries no question-marker comment (data-
74
+ * integrity signal — the clarify phase always posts one before applying
75
+ * `waiting-for:clarification`).
76
+ */
77
+ private findAnswerCandidate;
78
+ startPolling(): Promise<void>;
79
+ stopPolling(): void;
80
+ poll(): Promise<void>;
81
+ private pollRepo;
82
+ recordWebhookEvent(): void;
83
+ private updateAdaptivePolling;
84
+ getState(): Readonly<MonitorState>;
85
+ /**
86
+ * #987: flip `webhooksConfigured` to `true` at runtime. Setter is one-way
87
+ * (Q1); `adaptivePolling` stays untouched so the staleness safety net is
88
+ * reachable (Q2). See specs/987-summary-cluster-where-smee/clarifications.md.
89
+ */
90
+ setWebhooksConfigured(configured: true, opts?: SetWebhooksConfiguredOptions): void;
91
+ private sleep;
92
+ }
93
+ //# sourceMappingURL=clarification-answer-monitor-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clarification-answer-monitor-service.d.ts","sourceRoot":"","sources":["../../src/services/clarification-answer-monitor-service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,EAKL,KAAK,mBAAmB,EACzB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAEb,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC7E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAEjD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAcjE,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,iCAAiC;IAChD,YAAY,EAAE,gBAAgB,EAAE,CAAC;IACjC,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,OAAO,CAAC;IACzB,kBAAkB,EAAE,MAAM,CAAC;CAC5B;AAED;;;GAGG;AACH,MAAM,WAAW,4BAA4B;IAC3C,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AA4BD,qBAAa,iCAAiC;IAC5C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAsB;IACnD,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAoC;IACnE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAe;IAC5C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAoC;IAC5D,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAqB;IAC3D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAiB;IAC5C,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAqB;IAC3D,OAAO,CAAC,eAAe,CAAgC;IACvD,OAAO,CAAC,KAAK,CAAe;gBAG1B,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,mBAAmB,EACjC,YAAY,EAAE,YAAY,EAC1B,MAAM,EAAE,eAAe,EACvB,YAAY,EAAE,gBAAgB,EAAE,EAChC,qBAAqB,CAAC,EAAE,MAAM,EAC9B,aAAa,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,EACjD,UAAU,CAAC,EAAE,cAAc,EAC3B,qBAAqB,CAAC,EAAE,MAAM,EAC9B,kBAAkB,GAAE,OAAe;IA6BrC;;;;;OAKG;IACG,+BAA+B,CACnC,KAAK,EAAE,wBAAwB,GAC9B,OAAO,CAAC,OAAO,CAAC;IA+FnB;;;;;;;OAOG;IACH,OAAO,CAAC,mBAAmB;IAyCrB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IAmCnC,WAAW,IAAI,IAAI;IAQb,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAkBb,QAAQ;IAoFtB,kBAAkB,IAAI,IAAI;IAwB1B,OAAO,CAAC,qBAAqB;IA0B7B,QAAQ,IAAI,QAAQ,CAAC,YAAY,CAAC;IAIlC;;;;OAIG;IACH,qBAAqB,CAAC,UAAU,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,4BAA4B,GAAG,IAAI;IAalF,OAAO,CAAC,KAAK;CAcd"}
@@ -0,0 +1,452 @@
1
+ /**
2
+ * #958 T013 — Poll-based monitor that enqueues `continue` resume queue items
3
+ * for issues sitting at `waiting-for:clarification` + `agent:paused` when a
4
+ * new human-authored comment appears.
5
+ *
6
+ * Shape template: `merge-conflict-monitor-service.ts` (spec §Assumptions).
7
+ * Contract: `specs/958-found-during-local-snappoll/contracts/
8
+ * clarification-answer-monitor.md`.
9
+ *
10
+ * Key divergences from the merge-conflict monitor (per data-model.md):
11
+ * - Precondition label: `waiting-for:clarification` (was `waiting-for:merge-conflicts`).
12
+ * - Additional predicate: at least one comment with `viewerDidAuthor === false`
13
+ * that also passes `isTrustedCommentAuthor(_, 'answer-scanner', trustCtx)`.
14
+ * Comments carrying the engine-written answer marker are treated as
15
+ * cluster-self (not counted).
16
+ * - Queue command: `command: 'continue'` (was `'resolve-merge-conflicts'`).
17
+ *
18
+ * Non-behavior (contract):
19
+ * - MUST NOT apply `completed:clarification`. That label is reserved for
20
+ * the human's explicit force-advance override (FR-011).
21
+ * - MUST NOT modify `clarifications.md` — the monitor has no checkout; the
22
+ * phase loop performs integration on the resume path.
23
+ * - MUST NOT clear `waiting-for:clarification` or `agent:paused`.
24
+ */
25
+ import { GhAuthError, isTrustedCommentAuthor, tryLoadCommentTrustConfig, } from '@generacy-ai/workflow-engine';
26
+ import { JitTokenError } from '@generacy-ai/control-plane';
27
+ import { filterByAssignee } from './identity.js';
28
+ import { commentCarriesAnswerMarker, commentCarriesMachineMarker, matchClarificationQuestionMarker, } from '../worker/clarification-markers.js';
29
+ import { decideAdaptivePoll } from './adaptive-poll-controller.js';
30
+ const WAITING_FOR_CLARIFICATION_LABEL = 'waiting-for:clarification';
31
+ const AGENT_PAUSED_LABEL = 'agent:paused';
32
+ const MIN_POLL_INTERVAL_MS = 10_000;
33
+ const ADAPTIVE_DIVISOR = 2;
34
+ /**
35
+ * Adapter: pino-style logger (WorkerContext.Logger) → workflow-engine Logger.
36
+ * Same bridge used by `clarification-poster.ts` — kept private to this file
37
+ * so the merge-conflict monitor's DI surface stays untouched.
38
+ */
39
+ function toEngineLogger(logger) {
40
+ return {
41
+ info: (msg, meta) => {
42
+ if (meta && typeof meta === 'object')
43
+ logger.info(meta, msg);
44
+ else
45
+ logger.info(msg);
46
+ },
47
+ warn: (msg, meta) => {
48
+ if (meta && typeof meta === 'object')
49
+ logger.warn(meta, msg);
50
+ else
51
+ logger.warn(msg);
52
+ },
53
+ error: (msg, meta) => {
54
+ if (meta && typeof meta === 'object')
55
+ logger.error(meta, msg);
56
+ else
57
+ logger.error(msg);
58
+ },
59
+ debug: (msg, meta) => {
60
+ if (meta && typeof meta === 'object')
61
+ logger.debug(meta, msg);
62
+ else
63
+ logger.debug(msg);
64
+ },
65
+ };
66
+ }
67
+ export class ClarificationAnswerMonitorService {
68
+ logger;
69
+ createClient;
70
+ tokenProvider;
71
+ queueManager;
72
+ options;
73
+ clusterGithubUsername;
74
+ authHealth;
75
+ githubAppCredentialId;
76
+ abortController = null;
77
+ state;
78
+ constructor(logger, createClient, queueManager, config, repositories, clusterGithubUsername, tokenProvider, authHealth, githubAppCredentialId, webhooksConfigured = false) {
79
+ this.logger = logger;
80
+ this.createClient = createClient;
81
+ this.tokenProvider = tokenProvider;
82
+ this.queueManager = queueManager;
83
+ this.clusterGithubUsername = clusterGithubUsername;
84
+ this.authHealth = authHealth ?? { recordResult: () => undefined };
85
+ this.githubAppCredentialId = githubAppCredentialId;
86
+ this.options = {
87
+ repositories,
88
+ pollIntervalMs: config.pollIntervalMs,
89
+ adaptivePolling: config.adaptivePolling,
90
+ maxConcurrentPolls: config.maxConcurrentPolls,
91
+ };
92
+ this.state = {
93
+ isPolling: false,
94
+ webhookHealthy: true,
95
+ lastWebhookEvent: null,
96
+ currentPollIntervalMs: config.pollIntervalMs,
97
+ basePollIntervalMs: config.pollIntervalMs,
98
+ webhooksConfigured,
99
+ };
100
+ }
101
+ // ==========================================================================
102
+ // Event Processing
103
+ // ==========================================================================
104
+ /**
105
+ * Process a clarification-answer event: verify preconditions, check for a
106
+ * new human-authored comment, enqueue a resume via `enqueueIfAbsent`.
107
+ *
108
+ * Returns true if enqueued, false if skipped or duplicate.
109
+ */
110
+ async processClarificationAnswerEvent(event) {
111
+ const { owner, repo, issueNumber, issueLabels, source } = event;
112
+ const hasWaitingFor = issueLabels.includes(WAITING_FOR_CLARIFICATION_LABEL);
113
+ const hasPaused = issueLabels.includes(AGENT_PAUSED_LABEL);
114
+ if (!hasWaitingFor || !hasPaused) {
115
+ this.logger.debug({ owner, repo, issueNumber, source, hasWaitingFor, hasPaused }, 'Precondition failed for clarification-answer enqueue (need waiting-for:clarification + agent:paused)');
116
+ return false;
117
+ }
118
+ const blockedLabel = issueLabels.find((l) => l.startsWith('blocked:'));
119
+ if (blockedLabel) {
120
+ this.logger.info({ owner, repo, issueNumber, blockedLabel, reason: 'blocked-label-present' }, 'Skipping clarification-answer enqueue while blocked:* label is present');
121
+ return false;
122
+ }
123
+ // Fetch comments and require ≥1 human-authored (viewerDidAuthor === false)
124
+ // trusted comment. `viewerDidAuthor === true` comments (cluster-self) do
125
+ // not count as "new human answer" — even if they carry the answer marker,
126
+ // the phase loop is the source of truth for integrating cluster-relayed
127
+ // answers, not the monitor.
128
+ const client = this.createClient(undefined, this.tokenProvider);
129
+ let comments;
130
+ try {
131
+ comments = await client.getIssueCommentsWithViewerAuth(owner, repo, issueNumber);
132
+ }
133
+ catch (err) {
134
+ this.logger.warn({ err: String(err), owner, repo, issueNumber }, 'Failed to fetch comments during clarification-answer event — skipping');
135
+ return false;
136
+ }
137
+ const trustConfig = tryLoadCommentTrustConfig(process.cwd(), toEngineLogger(this.logger));
138
+ const trustCtx = {
139
+ logger: toEngineLogger(this.logger),
140
+ ...(this.clusterGithubUsername ? { botLogin: this.clusterGithubUsername } : {}),
141
+ ...(trustConfig ? { config: trustConfig } : {}),
142
+ };
143
+ const candidate = this.findAnswerCandidate(comments, trustCtx);
144
+ if (!candidate) {
145
+ this.logger.debug({ owner, repo, issueNumber }, 'No answer candidate found — nothing to resume on (no question-marker comment, or no comment newer than the latest question by a non-bot trusted author)');
146
+ return false;
147
+ }
148
+ const workflowLabel = issueLabels.find((l) => l.startsWith('workflow:'));
149
+ const workflowName = workflowLabel
150
+ ? workflowLabel.slice('workflow:'.length)
151
+ : 'speckit-feature';
152
+ const queueItem = {
153
+ owner,
154
+ repo,
155
+ issueNumber,
156
+ workflowName,
157
+ command: 'continue',
158
+ priority: Date.now(),
159
+ enqueuedAt: new Date().toISOString(),
160
+ metadata: {},
161
+ queueReason: 'resume',
162
+ };
163
+ const itemKey = `${owner}/${repo}#${issueNumber}`;
164
+ const enqueued = await this.queueManager.enqueueIfAbsent(queueItem);
165
+ if (!enqueued) {
166
+ this.logger.info({ itemKey, reason: 'in-flight', owner, repo, issueNumber }, 'Dropping clarification-answer enqueue (item already in flight)');
167
+ return false;
168
+ }
169
+ this.logger.info({
170
+ event: 'clarification-answer-resume-enqueued',
171
+ owner,
172
+ repo,
173
+ issueNumber,
174
+ source,
175
+ }, 'Clarification-answer resume enqueued');
176
+ return true;
177
+ }
178
+ /**
179
+ * #993 — positive answer predicate. Returns the first comment that qualifies
180
+ * as a real clarification answer, or `undefined` if none exists on the issue.
181
+ *
182
+ * Short-circuits when the issue carries no question-marker comment (data-
183
+ * integrity signal — the clarify phase always posts one before applying
184
+ * `waiting-for:clarification`).
185
+ */
186
+ findAnswerCandidate(comments, trustCtx) {
187
+ // `created_at`-only newness is intentional (replay-safe); blocks the
188
+ // `<!-- generacy-stage:specification -->` summary re-trigger vector where
189
+ // the bot updates its own summary and advances `updated_at` every poll.
190
+ const questionAnchor = latestQuestionCommentCreatedAt(comments);
191
+ if (questionAnchor === undefined)
192
+ return undefined;
193
+ for (const c of comments) {
194
+ // `[bot]`-suffix authors are dropped upstream of the trust helper.
195
+ // Cluster-relayed answers flow through the `completed:clarification`
196
+ // label / LabelMonitorService path, never through this monitor.
197
+ if (isBotAuthoredLogin(c.author))
198
+ continue;
199
+ if (c.created_at <= questionAnchor)
200
+ continue;
201
+ // Branch (a) runs before the machine-marker skip: `commentCarriesAnswerMarker`
202
+ // is a positive signal, and the answer-marker prefix is present in
203
+ // MACHINE_MARKERS as a #976 engine-noise entry. Without this order, a
204
+ // non-bot human posting `<!-- generacy-clarification-answers:1 -->` would
205
+ // be filtered out as machine noise (SC-003 regression).
206
+ if (commentCarriesAnswerMarker(c.body))
207
+ return c;
208
+ if (commentCarriesMachineMarker(c.body))
209
+ continue;
210
+ const decision = isTrustedCommentAuthor(c, 'answer-scanner', trustCtx);
211
+ if (decision.trusted &&
212
+ decision.reason !== 'bot' &&
213
+ decision.reason !== 'self-authored') {
214
+ return c;
215
+ }
216
+ }
217
+ return undefined;
218
+ }
219
+ // ==========================================================================
220
+ // Polling
221
+ // ==========================================================================
222
+ async startPolling() {
223
+ if (this.state.isPolling) {
224
+ this.logger.warn('Clarification-answer monitor polling already running');
225
+ return;
226
+ }
227
+ const ac = new AbortController();
228
+ this.abortController = ac;
229
+ this.state.isPolling = true;
230
+ this.logger.info({ intervalMs: this.state.currentPollIntervalMs, repos: this.options.repositories.length }, 'Starting clarification-answer monitor polling');
231
+ while (!ac.signal.aborted) {
232
+ try {
233
+ await this.poll();
234
+ }
235
+ catch (error) {
236
+ this.logger.error({ err: error }, 'Error during clarification-answer poll cycle');
237
+ }
238
+ if (this.options.adaptivePolling) {
239
+ this.updateAdaptivePolling();
240
+ }
241
+ await this.sleep(this.state.currentPollIntervalMs, ac.signal);
242
+ }
243
+ this.state.isPolling = false;
244
+ this.logger.info('Clarification-answer monitor polling loop stopped');
245
+ }
246
+ stopPolling() {
247
+ if (this.abortController) {
248
+ this.abortController.abort();
249
+ this.abortController = null;
250
+ this.logger.info('Clarification-answer monitor polling stop requested');
251
+ }
252
+ }
253
+ async poll() {
254
+ const repos = this.options.repositories;
255
+ if (repos.length === 0)
256
+ return;
257
+ const semaphore = new Semaphore(this.options.maxConcurrentPolls);
258
+ const pollTasks = repos.map(({ owner, repo }) => semaphore.acquire().then(async (release) => {
259
+ try {
260
+ await this.pollRepo(owner, repo);
261
+ }
262
+ finally {
263
+ release();
264
+ }
265
+ }));
266
+ await Promise.allSettled(pollTasks);
267
+ }
268
+ async pollRepo(owner, repo) {
269
+ const client = this.createClient(undefined, this.tokenProvider);
270
+ let allIssues;
271
+ try {
272
+ allIssues = await client.listIssuesWithLabel(owner, repo, WAITING_FOR_CLARIFICATION_LABEL);
273
+ if (this.githubAppCredentialId) {
274
+ this.authHealth.recordResult(this.githubAppCredentialId, { ok: true });
275
+ }
276
+ }
277
+ catch (error) {
278
+ if (error instanceof JitTokenError) {
279
+ this.logger.warn({ code: error.code, message: error.message, owner, repo }, 'JIT GitHub token refresh failed — skipping clarification-answer monitor cycle');
280
+ return;
281
+ }
282
+ if (error instanceof GhAuthError) {
283
+ if (this.githubAppCredentialId) {
284
+ this.authHealth.recordResult(this.githubAppCredentialId, { ok: false, statusCode: error.statusCode });
285
+ }
286
+ this.logger.warn({
287
+ credentialId: this.githubAppCredentialId,
288
+ statusCode: error.statusCode,
289
+ owner,
290
+ repo,
291
+ }, 'GitHub authentication failing on clarification-answer monitor — investigate credential refresh chain');
292
+ return;
293
+ }
294
+ this.logger.warn({ err: String(error), owner, repo }, 'Error polling repository for clarification-answer pauses');
295
+ return;
296
+ }
297
+ const issues = filterByAssignee(allIssues, this.clusterGithubUsername, this.logger);
298
+ if (issues.length === 0) {
299
+ this.logger.debug({ owner, repo }, 'No clarification-answer pauses found this cycle');
300
+ return;
301
+ }
302
+ this.logger.info({ owner, repo, count: issues.length }, 'Clarification-answer pauses found');
303
+ for (const issue of issues) {
304
+ const event = {
305
+ owner,
306
+ repo,
307
+ issueNumber: issue.number,
308
+ issueLabels: issue.labels.map((l) => l.name),
309
+ source: 'poll',
310
+ };
311
+ try {
312
+ await this.processClarificationAnswerEvent(event);
313
+ }
314
+ catch (err) {
315
+ this.logger.warn({ err: String(err), owner, repo, issueNumber: issue.number }, 'Error processing clarification-answer event — continuing to next issue');
316
+ }
317
+ }
318
+ }
319
+ // ==========================================================================
320
+ // Adaptive Polling
321
+ // ==========================================================================
322
+ recordWebhookEvent() {
323
+ this.state.lastWebhookEvent = Date.now();
324
+ this.state.webhookHealthy = true;
325
+ const decision = decideAdaptivePoll({
326
+ webhooksConfigured: this.state.webhooksConfigured,
327
+ adaptivePolling: this.options.adaptivePolling,
328
+ basePollIntervalMs: this.state.basePollIntervalMs,
329
+ currentPollIntervalMs: this.state.currentPollIntervalMs,
330
+ lastWebhookEvent: this.state.lastWebhookEvent,
331
+ webhookHealthy: this.state.webhookHealthy,
332
+ adaptiveDivisor: ADAPTIVE_DIVISOR,
333
+ minPollIntervalMs: MIN_POLL_INTERVAL_MS,
334
+ nowMs: Date.now(),
335
+ });
336
+ this.state.currentPollIntervalMs = decision.currentPollIntervalMs;
337
+ this.state.webhookHealthy = decision.webhookHealthy;
338
+ if (decision.transition !== 'none') {
339
+ this.logger.info({ intervalMs: this.state.currentPollIntervalMs, reason: decision.reason }, 'Webhook reconnected, restoring clarification-answer monitor poll interval');
340
+ }
341
+ }
342
+ updateAdaptivePolling() {
343
+ const decision = decideAdaptivePoll({
344
+ webhooksConfigured: this.state.webhooksConfigured,
345
+ adaptivePolling: this.options.adaptivePolling,
346
+ basePollIntervalMs: this.state.basePollIntervalMs,
347
+ currentPollIntervalMs: this.state.currentPollIntervalMs,
348
+ lastWebhookEvent: this.state.lastWebhookEvent,
349
+ webhookHealthy: this.state.webhookHealthy,
350
+ adaptiveDivisor: ADAPTIVE_DIVISOR,
351
+ minPollIntervalMs: MIN_POLL_INTERVAL_MS,
352
+ nowMs: Date.now(),
353
+ });
354
+ this.state.currentPollIntervalMs = decision.currentPollIntervalMs;
355
+ this.state.webhookHealthy = decision.webhookHealthy;
356
+ if (decision.transition !== 'none') {
357
+ this.logger.info({ intervalMs: this.state.currentPollIntervalMs, reason: decision.reason }, 'Webhooks appear unhealthy, increasing clarification-answer poll frequency');
358
+ }
359
+ }
360
+ // ==========================================================================
361
+ // State access
362
+ // ==========================================================================
363
+ getState() {
364
+ return { ...this.state };
365
+ }
366
+ /**
367
+ * #987: flip `webhooksConfigured` to `true` at runtime. Setter is one-way
368
+ * (Q1); `adaptivePolling` stays untouched so the staleness safety net is
369
+ * reachable (Q2). See specs/987-summary-cluster-where-smee/clarifications.md.
370
+ */
371
+ setWebhooksConfigured(configured, opts) {
372
+ void configured;
373
+ this.state.webhooksConfigured = true;
374
+ if (opts?.basePollIntervalMs !== undefined) {
375
+ this.state.basePollIntervalMs = opts.basePollIntervalMs;
376
+ this.state.currentPollIntervalMs = opts.basePollIntervalMs;
377
+ }
378
+ }
379
+ // ==========================================================================
380
+ // Utilities
381
+ // ==========================================================================
382
+ sleep(ms, signal) {
383
+ return new Promise((resolve) => {
384
+ if (signal.aborted) {
385
+ resolve();
386
+ return;
387
+ }
388
+ const timer = setTimeout(resolve, ms);
389
+ const onAbort = () => {
390
+ clearTimeout(timer);
391
+ resolve();
392
+ };
393
+ signal.addEventListener('abort', onAbort, { once: true });
394
+ });
395
+ }
396
+ }
397
+ /**
398
+ * #993 FR-001 — case-insensitive, whitespace-tolerant `[bot]`-suffix test.
399
+ * Mirrors `normalizeLogin`'s trim+lowercase shape without stripping the
400
+ * suffix (we're detecting it, not removing it). Any App bot identity
401
+ * (`generacy-ai[bot]`, `staging-generacy[bot]`, `dependabot[bot]`, …)
402
+ * is treated identically.
403
+ */
404
+ function isBotAuthoredLogin(author) {
405
+ return author.trim().toLowerCase().endsWith('[bot]');
406
+ }
407
+ /**
408
+ * #993 FR-004 — newness anchor. Returns the newest `created_at` (ISO-8601
409
+ * lexicographic compare) among comments carrying a question marker.
410
+ * `undefined` when the issue has no question-marker comment.
411
+ */
412
+ function latestQuestionCommentCreatedAt(comments) {
413
+ let latest;
414
+ for (const c of comments) {
415
+ if (matchClarificationQuestionMarker(c.body) === undefined)
416
+ continue;
417
+ if (latest === undefined || c.created_at > latest)
418
+ latest = c.created_at;
419
+ }
420
+ return latest;
421
+ }
422
+ /**
423
+ * Simple semaphore for bounded concurrency. Copy of the merge-conflict
424
+ * monitor's helper — deliberately duplicated to keep the two monitors
425
+ * shape-parallel and diffable.
426
+ */
427
+ class Semaphore {
428
+ count;
429
+ waiting = [];
430
+ constructor(max) {
431
+ this.count = max;
432
+ }
433
+ async acquire() {
434
+ if (this.count > 0) {
435
+ this.count--;
436
+ return () => this.release();
437
+ }
438
+ return new Promise((resolve) => {
439
+ this.waiting.push(() => {
440
+ this.count--;
441
+ resolve(() => this.release());
442
+ });
443
+ });
444
+ }
445
+ release() {
446
+ this.count++;
447
+ const next = this.waiting.shift();
448
+ if (next)
449
+ next();
450
+ }
451
+ }
452
+ //# sourceMappingURL=clarification-answer-monitor-service.js.map