@renseiai/agentfactory-server 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/dist/src/a2a-server.d.ts +88 -0
  4. package/dist/src/a2a-server.d.ts.map +1 -0
  5. package/dist/src/a2a-server.integration.test.d.ts +9 -0
  6. package/dist/src/a2a-server.integration.test.d.ts.map +1 -0
  7. package/dist/src/a2a-server.integration.test.js +397 -0
  8. package/dist/src/a2a-server.js +235 -0
  9. package/dist/src/a2a-server.test.d.ts +2 -0
  10. package/dist/src/a2a-server.test.d.ts.map +1 -0
  11. package/dist/src/a2a-server.test.js +311 -0
  12. package/dist/src/a2a-types.d.ts +125 -0
  13. package/dist/src/a2a-types.d.ts.map +1 -0
  14. package/dist/src/a2a-types.js +8 -0
  15. package/dist/src/agent-tracking.d.ts +201 -0
  16. package/dist/src/agent-tracking.d.ts.map +1 -0
  17. package/dist/src/agent-tracking.js +349 -0
  18. package/dist/src/env-validation.d.ts +65 -0
  19. package/dist/src/env-validation.d.ts.map +1 -0
  20. package/dist/src/env-validation.js +134 -0
  21. package/dist/src/governor-dedup.d.ts +15 -0
  22. package/dist/src/governor-dedup.d.ts.map +1 -0
  23. package/dist/src/governor-dedup.js +31 -0
  24. package/dist/src/governor-event-bus.d.ts +54 -0
  25. package/dist/src/governor-event-bus.d.ts.map +1 -0
  26. package/dist/src/governor-event-bus.js +152 -0
  27. package/dist/src/governor-storage.d.ts +28 -0
  28. package/dist/src/governor-storage.d.ts.map +1 -0
  29. package/dist/src/governor-storage.js +52 -0
  30. package/dist/src/index.d.ts +26 -0
  31. package/dist/src/index.d.ts.map +1 -0
  32. package/dist/src/index.js +50 -0
  33. package/dist/src/issue-lock.d.ts +129 -0
  34. package/dist/src/issue-lock.d.ts.map +1 -0
  35. package/dist/src/issue-lock.js +508 -0
  36. package/dist/src/logger.d.ts +76 -0
  37. package/dist/src/logger.d.ts.map +1 -0
  38. package/dist/src/logger.js +218 -0
  39. package/dist/src/orphan-cleanup.d.ts +64 -0
  40. package/dist/src/orphan-cleanup.d.ts.map +1 -0
  41. package/dist/src/orphan-cleanup.js +369 -0
  42. package/dist/src/pending-prompts.d.ts +67 -0
  43. package/dist/src/pending-prompts.d.ts.map +1 -0
  44. package/dist/src/pending-prompts.js +176 -0
  45. package/dist/src/processing-state-storage.d.ts +38 -0
  46. package/dist/src/processing-state-storage.d.ts.map +1 -0
  47. package/dist/src/processing-state-storage.js +61 -0
  48. package/dist/src/quota-tracker.d.ts +62 -0
  49. package/dist/src/quota-tracker.d.ts.map +1 -0
  50. package/dist/src/quota-tracker.js +155 -0
  51. package/dist/src/rate-limit.d.ts +111 -0
  52. package/dist/src/rate-limit.d.ts.map +1 -0
  53. package/dist/src/rate-limit.js +171 -0
  54. package/dist/src/redis-circuit-breaker.d.ts +67 -0
  55. package/dist/src/redis-circuit-breaker.d.ts.map +1 -0
  56. package/dist/src/redis-circuit-breaker.js +290 -0
  57. package/dist/src/redis-rate-limiter.d.ts +51 -0
  58. package/dist/src/redis-rate-limiter.d.ts.map +1 -0
  59. package/dist/src/redis-rate-limiter.js +168 -0
  60. package/dist/src/redis.d.ts +146 -0
  61. package/dist/src/redis.d.ts.map +1 -0
  62. package/dist/src/redis.js +343 -0
  63. package/dist/src/session-hash.d.ts +48 -0
  64. package/dist/src/session-hash.d.ts.map +1 -0
  65. package/dist/src/session-hash.js +80 -0
  66. package/dist/src/session-storage.d.ts +166 -0
  67. package/dist/src/session-storage.d.ts.map +1 -0
  68. package/dist/src/session-storage.js +397 -0
  69. package/dist/src/token-storage.d.ts +118 -0
  70. package/dist/src/token-storage.d.ts.map +1 -0
  71. package/dist/src/token-storage.js +263 -0
  72. package/dist/src/types.d.ts +11 -0
  73. package/dist/src/types.d.ts.map +1 -0
  74. package/dist/src/types.js +7 -0
  75. package/dist/src/webhook-idempotency.d.ts +44 -0
  76. package/dist/src/webhook-idempotency.d.ts.map +1 -0
  77. package/dist/src/webhook-idempotency.js +148 -0
  78. package/dist/src/work-queue.d.ts +120 -0
  79. package/dist/src/work-queue.d.ts.map +1 -0
  80. package/dist/src/work-queue.js +384 -0
  81. package/dist/src/worker-auth.d.ts +29 -0
  82. package/dist/src/worker-auth.d.ts.map +1 -0
  83. package/dist/src/worker-auth.js +49 -0
  84. package/dist/src/worker-storage.d.ts +108 -0
  85. package/dist/src/worker-storage.d.ts.map +1 -0
  86. package/dist/src/worker-storage.js +295 -0
  87. package/dist/src/workflow-state-integration.test.d.ts +2 -0
  88. package/dist/src/workflow-state-integration.test.d.ts.map +1 -0
  89. package/dist/src/workflow-state-integration.test.js +342 -0
  90. package/dist/src/workflow-state.test.d.ts +2 -0
  91. package/dist/src/workflow-state.test.d.ts.map +1 -0
  92. package/dist/src/workflow-state.test.js +113 -0
  93. package/package.json +72 -0
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Orphan Cleanup Module
3
+ *
4
+ * Detects and handles orphaned sessions - sessions marked as running/claimed
5
+ * but whose worker is no longer active (heartbeat timeout).
6
+ *
7
+ * When a worker disconnects, the work is re-queued for another worker to resume.
8
+ * The Linear issue status is NOT rolled back - the issue remains in its current
9
+ * workflow state and the next worker will resume from where the previous one left off.
10
+ */
11
+ import { createLogger } from './logger.js';
12
+ import { getAllSessions, resetSessionForRequeue, } from './session-storage.js';
13
+ import { listWorkers } from './worker-storage.js';
14
+ import { releaseClaim, isSessionInQueue, } from './work-queue.js';
15
+ import { dispatchWork, cleanupExpiredLocksWithPendingWork, cleanupStaleLocksWithIdleWorkers, isSessionParkedForIssue, getIssueLock, releaseIssueLock, } from './issue-lock.js';
16
+ const log = createLogger('orphan-cleanup');
17
+ // How long a session can be running without a valid worker before being considered orphaned
18
+ const ORPHAN_THRESHOLD_MS = 120_000; // 2 minutes (worker TTL + buffer)
19
+ /**
20
+ * Find sessions that are orphaned (running/claimed but worker is gone)
21
+ */
22
+ export async function findOrphanedSessions() {
23
+ const [sessions, workers] = await Promise.all([
24
+ getAllSessions(),
25
+ listWorkers(),
26
+ ]);
27
+ // Build set of active worker IDs
28
+ const activeWorkerIds = new Set(workers
29
+ .filter((w) => w.status === 'active')
30
+ .map((w) => w.id));
31
+ const orphaned = [];
32
+ for (const session of sessions) {
33
+ // Only check running or claimed sessions
34
+ if (session.status !== 'running' && session.status !== 'claimed') {
35
+ continue;
36
+ }
37
+ // Grace period: skip sessions updated recently — prevents race when a worker
38
+ // re-registers with a new ID but hasn't transferred session ownership yet
39
+ const sessionAge = Date.now() - session.updatedAt;
40
+ if (sessionAge < ORPHAN_THRESHOLD_MS) {
41
+ log.debug('Session recently updated, skipping orphan check', {
42
+ sessionId: session.linearSessionId,
43
+ ageMs: sessionAge,
44
+ });
45
+ continue;
46
+ }
47
+ // If session has no worker assigned, it's orphaned
48
+ if (!session.workerId) {
49
+ log.debug('Session has no worker assigned', {
50
+ sessionId: session.linearSessionId,
51
+ status: session.status,
52
+ });
53
+ orphaned.push(session);
54
+ continue;
55
+ }
56
+ // If the assigned worker is no longer active, session is orphaned
57
+ if (!activeWorkerIds.has(session.workerId)) {
58
+ log.debug('Session worker is no longer active', {
59
+ sessionId: session.linearSessionId,
60
+ workerId: session.workerId,
61
+ status: session.status,
62
+ });
63
+ orphaned.push(session);
64
+ continue;
65
+ }
66
+ }
67
+ return orphaned;
68
+ }
69
+ // How long a pending session can exist without a queue entry before being considered a zombie
70
+ const ZOMBIE_PENDING_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
71
+ /**
72
+ * Find zombie pending sessions — sessions stuck in `pending` status
73
+ * that have no corresponding entry in the work queue or any issue-pending queue.
74
+ *
75
+ * These arise when:
76
+ * - claimWork() removes from queue, but claimSession() fails and requeue also fails
77
+ * - Issue lock expires but promotion fails silently
78
+ */
79
+ export async function findZombiePendingSessions() {
80
+ const sessions = await getAllSessions();
81
+ const now = Date.now();
82
+ const zombies = [];
83
+ for (const session of sessions) {
84
+ if (session.status !== 'pending')
85
+ continue;
86
+ // Only consider sessions older than the threshold
87
+ const age = now - session.updatedAt;
88
+ if (age < ZOMBIE_PENDING_THRESHOLD_MS)
89
+ continue;
90
+ // Check if session is in the global work queue
91
+ const inQueue = await isSessionInQueue(session.linearSessionId);
92
+ if (inQueue)
93
+ continue;
94
+ // Check if session is parked in the issue-pending queue
95
+ const parked = await isSessionParkedForIssue(session.issueId, session.linearSessionId);
96
+ if (parked)
97
+ continue;
98
+ // Session is pending but not in any queue — it's a zombie
99
+ log.warn('Found zombie pending session', {
100
+ sessionId: session.linearSessionId,
101
+ issueIdentifier: session.issueIdentifier,
102
+ ageMinutes: Math.round(age / 60_000),
103
+ });
104
+ zombies.push(session);
105
+ }
106
+ return zombies;
107
+ }
108
+ /**
109
+ * Clean up orphaned sessions by re-queuing them
110
+ *
111
+ * @param callbacks - Optional callbacks for external integrations (e.g., posting Linear comments)
112
+ */
113
+ export async function cleanupOrphanedSessions(callbacks) {
114
+ const result = {
115
+ checked: 0,
116
+ orphaned: 0,
117
+ requeued: 0,
118
+ failed: 0,
119
+ details: [],
120
+ worktreePathsToCleanup: [],
121
+ };
122
+ try {
123
+ const sessions = await getAllSessions();
124
+ result.checked = sessions.length;
125
+ const orphaned = await findOrphanedSessions();
126
+ result.orphaned = orphaned.length;
127
+ if (orphaned.length > 0) {
128
+ log.info('Found orphaned sessions', { count: orphaned.length });
129
+ }
130
+ for (const session of orphaned) {
131
+ try {
132
+ const issueIdentifier = session.issueIdentifier || session.issueId.slice(0, 8);
133
+ log.info('Re-queuing orphaned session', {
134
+ sessionId: session.linearSessionId,
135
+ issueIdentifier,
136
+ previousWorker: session.workerId,
137
+ previousStatus: session.status,
138
+ });
139
+ // Release any existing claim
140
+ await releaseClaim(session.linearSessionId);
141
+ // Release the issue lock if held by this orphaned session.
142
+ // Without this, dispatchWork() below would fail to acquire the lock
143
+ // (SET NX) and park the work instead — leaving it stuck until the
144
+ // lock's 2-hour TTL expires, since the session is reset to 'pending'
145
+ // which the stale-lock cleanup doesn't consider terminal.
146
+ const existingLock = await getIssueLock(session.issueId);
147
+ if (existingLock && existingLock.sessionId === session.linearSessionId) {
148
+ log.info('Releasing issue lock held by orphaned session', {
149
+ sessionId: session.linearSessionId,
150
+ issueId: session.issueId,
151
+ });
152
+ await releaseIssueLock(session.issueId);
153
+ }
154
+ // Reset session for requeue (clears workerId so new worker can claim)
155
+ await resetSessionForRequeue(session.linearSessionId);
156
+ // Re-queue the work with higher priority
157
+ // IMPORTANT: Preserve workType to prevent incorrect status transitions
158
+ // NOTE: Do NOT preserve providerSessionId - the old session may be corrupted
159
+ // from the crash that caused the orphan. Starting fresh is safer.
160
+ const work = {
161
+ sessionId: session.linearSessionId,
162
+ issueId: session.issueId,
163
+ issueIdentifier,
164
+ priority: Math.max(1, (session.priority || 3) - 1), // Boost priority
165
+ queuedAt: Date.now(),
166
+ prompt: session.promptContext,
167
+ // providerSessionId intentionally omitted - don't resume crashed sessions
168
+ workType: session.workType,
169
+ projectName: session.projectName,
170
+ };
171
+ const dispatchResult = await dispatchWork(work);
172
+ if (dispatchResult.dispatched || dispatchResult.parked) {
173
+ result.requeued++;
174
+ result.details.push({
175
+ sessionId: session.linearSessionId,
176
+ issueIdentifier,
177
+ action: 'requeued',
178
+ worktreePath: session.worktreePath,
179
+ });
180
+ // Track worktree path for cleanup on worker machines
181
+ if (session.worktreePath) {
182
+ result.worktreePathsToCleanup.push(session.worktreePath);
183
+ }
184
+ // Call external callback (e.g., post Linear comment)
185
+ if (callbacks?.onOrphanRequeued) {
186
+ try {
187
+ await callbacks.onOrphanRequeued(session);
188
+ }
189
+ catch (err) {
190
+ log.warn('onOrphanRequeued callback failed', { error: err });
191
+ }
192
+ }
193
+ }
194
+ else {
195
+ result.failed++;
196
+ result.details.push({
197
+ sessionId: session.linearSessionId,
198
+ issueIdentifier,
199
+ action: 'failed',
200
+ reason: 'Failed to queue work',
201
+ });
202
+ }
203
+ }
204
+ catch (err) {
205
+ log.error('Failed to cleanup orphaned session', {
206
+ sessionId: session.linearSessionId,
207
+ error: err,
208
+ });
209
+ result.failed++;
210
+ result.details.push({
211
+ sessionId: session.linearSessionId,
212
+ issueIdentifier: session.issueIdentifier || 'unknown',
213
+ action: 'failed',
214
+ reason: err instanceof Error ? err.message : 'Unknown error',
215
+ });
216
+ }
217
+ }
218
+ // Check for zombie pending sessions (pending but not in any queue)
219
+ try {
220
+ const zombies = await findZombiePendingSessions();
221
+ if (zombies.length > 0) {
222
+ log.info('Found zombie pending sessions', { count: zombies.length });
223
+ }
224
+ for (const session of zombies) {
225
+ try {
226
+ const issueIdentifier = session.issueIdentifier || session.issueId.slice(0, 8);
227
+ log.info('Re-dispatching zombie pending session', {
228
+ sessionId: session.linearSessionId,
229
+ issueIdentifier,
230
+ });
231
+ // Release issue lock if held by this zombie session (same rationale as orphan cleanup)
232
+ const existingLock = await getIssueLock(session.issueId);
233
+ if (existingLock && existingLock.sessionId === session.linearSessionId) {
234
+ log.info('Releasing issue lock held by zombie session', {
235
+ sessionId: session.linearSessionId,
236
+ issueId: session.issueId,
237
+ });
238
+ await releaseIssueLock(session.issueId);
239
+ }
240
+ const work = {
241
+ sessionId: session.linearSessionId,
242
+ issueId: session.issueId,
243
+ issueIdentifier,
244
+ priority: Math.max(1, (session.priority || 3) - 1),
245
+ queuedAt: Date.now(),
246
+ prompt: session.promptContext,
247
+ workType: session.workType,
248
+ projectName: session.projectName,
249
+ };
250
+ const dispatchResult = await dispatchWork(work);
251
+ if (dispatchResult.dispatched || dispatchResult.parked) {
252
+ result.requeued++;
253
+ result.details.push({
254
+ sessionId: session.linearSessionId,
255
+ issueIdentifier,
256
+ action: 'requeued',
257
+ reason: 'Zombie pending session recovered',
258
+ });
259
+ // Call external callback
260
+ if (callbacks?.onZombieRecovered) {
261
+ try {
262
+ await callbacks.onZombieRecovered(session);
263
+ }
264
+ catch (err) {
265
+ log.warn('onZombieRecovered callback failed', { error: err });
266
+ }
267
+ }
268
+ }
269
+ else {
270
+ result.failed++;
271
+ result.details.push({
272
+ sessionId: session.linearSessionId,
273
+ issueIdentifier,
274
+ action: 'failed',
275
+ reason: 'Failed to re-dispatch zombie session',
276
+ });
277
+ }
278
+ }
279
+ catch (err) {
280
+ log.error('Failed to recover zombie session', {
281
+ sessionId: session.linearSessionId,
282
+ error: err,
283
+ });
284
+ result.failed++;
285
+ result.details.push({
286
+ sessionId: session.linearSessionId,
287
+ issueIdentifier: session.issueIdentifier || 'unknown',
288
+ action: 'failed',
289
+ reason: err instanceof Error ? err.message : 'Unknown error',
290
+ });
291
+ }
292
+ }
293
+ }
294
+ catch (err) {
295
+ log.error('Failed to find zombie pending sessions', { error: err });
296
+ }
297
+ // Also check for expired issue locks with pending work
298
+ try {
299
+ const promoted = await cleanupExpiredLocksWithPendingWork();
300
+ if (promoted > 0) {
301
+ log.info('Promoted pending work from expired issue locks', { promoted });
302
+ }
303
+ }
304
+ catch (err) {
305
+ log.error('Failed to cleanup expired issue locks', { error: err });
306
+ }
307
+ // Check for stale locks held by completed sessions when workers have idle capacity.
308
+ // Only runs when workers are online — no point promoting if nobody can pick it up.
309
+ try {
310
+ const workers = await listWorkers();
311
+ const activeWorkers = workers.filter((w) => w.status === 'active');
312
+ const hasIdleWorkers = activeWorkers.length > 0 &&
313
+ activeWorkers.some((w) => w.activeCount < w.capacity);
314
+ if (hasIdleWorkers) {
315
+ const promoted = await cleanupStaleLocksWithIdleWorkers(hasIdleWorkers);
316
+ if (promoted > 0) {
317
+ log.info('Promoted parked work from stale issue locks', { promoted });
318
+ }
319
+ }
320
+ }
321
+ catch (err) {
322
+ log.error('Failed to cleanup stale issue locks', { error: err });
323
+ }
324
+ log.info('Orphan cleanup completed', {
325
+ checked: result.checked,
326
+ orphaned: result.orphaned,
327
+ requeued: result.requeued,
328
+ failed: result.failed,
329
+ worktreePathsToCleanup: result.worktreePathsToCleanup.length,
330
+ });
331
+ // Log worktree cleanup info if any paths need attention
332
+ if (result.worktreePathsToCleanup.length > 0) {
333
+ log.info('Worktree cleanup needed on worker machines', {
334
+ paths: result.worktreePathsToCleanup,
335
+ note: 'Run cleanup-worktrees on each worker machine to remove orphaned worktrees',
336
+ });
337
+ }
338
+ }
339
+ catch (err) {
340
+ log.error('Orphan cleanup failed', { error: err });
341
+ }
342
+ return result;
343
+ }
344
+ /**
345
+ * Check if cleanup should run based on time since last cleanup
346
+ * Returns true if enough time has passed
347
+ */
348
+ let lastCleanupTime = 0;
349
+ const CLEANUP_INTERVAL_MS = 60_000; // Run at most once per minute
350
+ export function shouldRunCleanup() {
351
+ const now = Date.now();
352
+ if (now - lastCleanupTime >= CLEANUP_INTERVAL_MS) {
353
+ lastCleanupTime = now;
354
+ return true;
355
+ }
356
+ return false;
357
+ }
358
+ /**
359
+ * Run cleanup if enough time has passed (debounced)
360
+ * Safe to call frequently - will only actually run periodically
361
+ *
362
+ * @param callbacks - Optional callbacks for external integrations
363
+ */
364
+ export async function maybeCleanupOrphans(callbacks) {
365
+ if (!shouldRunCleanup()) {
366
+ return null;
367
+ }
368
+ return cleanupOrphanedSessions(callbacks);
369
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Pending Prompts Module
3
+ *
4
+ * Stores follow-up prompts for running agent sessions.
5
+ * Workers poll for pending prompts and forward them to their running Claude processes.
6
+ */
7
+ /**
8
+ * A pending prompt waiting to be delivered to a running agent
9
+ */
10
+ export interface PendingPrompt {
11
+ id: string;
12
+ sessionId: string;
13
+ issueId: string;
14
+ prompt: string;
15
+ userId?: string;
16
+ userName?: string;
17
+ createdAt: number;
18
+ }
19
+ /**
20
+ * Store a pending prompt for a session
21
+ *
22
+ * @param sessionId - The Linear session ID
23
+ * @param issueId - The Linear issue ID
24
+ * @param prompt - The prompt text from the user
25
+ * @param user - Optional user info
26
+ * @returns The created prompt or null if storage failed
27
+ */
28
+ export declare function storePendingPrompt(sessionId: string, issueId: string, prompt: string, user?: {
29
+ id?: string;
30
+ name?: string;
31
+ }): Promise<PendingPrompt | null>;
32
+ /**
33
+ * Get all pending prompts for a session
34
+ *
35
+ * @param sessionId - The Linear session ID
36
+ * @returns Array of pending prompts (oldest first)
37
+ */
38
+ export declare function getPendingPrompts(sessionId: string): Promise<PendingPrompt[]>;
39
+ /**
40
+ * Get the count of pending prompts for a session
41
+ */
42
+ export declare function getPendingPromptCount(sessionId: string): Promise<number>;
43
+ /**
44
+ * Claim and remove a pending prompt by ID
45
+ * Returns the prompt if found and removed, null otherwise
46
+ *
47
+ * @param sessionId - The Linear session ID
48
+ * @param promptId - The prompt ID to claim
49
+ * @returns The claimed prompt or null
50
+ */
51
+ export declare function claimPendingPrompt(sessionId: string, promptId: string): Promise<PendingPrompt | null>;
52
+ /**
53
+ * Pop the oldest pending prompt for a session (claim and remove atomically)
54
+ *
55
+ * @param sessionId - The Linear session ID
56
+ * @returns The oldest pending prompt or null if none
57
+ */
58
+ export declare function popPendingPrompt(sessionId: string): Promise<PendingPrompt | null>;
59
+ /**
60
+ * Clear all pending prompts for a session
61
+ * Called when session completes or is stopped
62
+ *
63
+ * @param sessionId - The Linear session ID
64
+ * @returns true if cleared successfully
65
+ */
66
+ export declare function clearPendingPrompts(sessionId: string): Promise<boolean>;
67
+ //# sourceMappingURL=pending-prompts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pending-prompts.d.ts","sourceRoot":"","sources":["../../src/pending-prompts.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAiBH;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;CAClB;AAgBD;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE;IAAE,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GACpC,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAiC/B;AAED;;;;;GAKG;AACH,wBAAsB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAanF;AAED;;GAEG;AACH,wBAAsB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAY9E;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAwB/B;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAkBvF;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAc7E"}
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Pending Prompts Module
3
+ *
4
+ * Stores follow-up prompts for running agent sessions.
5
+ * Workers poll for pending prompts and forward them to their running Claude processes.
6
+ */
7
+ import { redisRPush, redisLRange, redisLRem, redisLLen, redisDel, isRedisConfigured, } from './redis.js';
8
+ import { createLogger } from './logger.js';
9
+ const log = createLogger('pending-prompts');
10
+ // Redis key prefix for pending prompts per session
11
+ const PENDING_PROMPTS_PREFIX = 'session:prompts:';
12
+ /**
13
+ * Build the Redis key for a session's pending prompts
14
+ */
15
+ function buildPromptsKey(sessionId) {
16
+ return `${PENDING_PROMPTS_PREFIX}${sessionId}`;
17
+ }
18
+ /**
19
+ * Generate a unique prompt ID
20
+ */
21
+ function generatePromptId() {
22
+ return `prm_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
23
+ }
24
+ /**
25
+ * Store a pending prompt for a session
26
+ *
27
+ * @param sessionId - The Linear session ID
28
+ * @param issueId - The Linear issue ID
29
+ * @param prompt - The prompt text from the user
30
+ * @param user - Optional user info
31
+ * @returns The created prompt or null if storage failed
32
+ */
33
+ export async function storePendingPrompt(sessionId, issueId, prompt, user) {
34
+ if (!isRedisConfigured()) {
35
+ log.warn('Redis not configured, cannot store pending prompt');
36
+ return null;
37
+ }
38
+ try {
39
+ const pendingPrompt = {
40
+ id: generatePromptId(),
41
+ sessionId,
42
+ issueId,
43
+ prompt,
44
+ userId: user?.id,
45
+ userName: user?.name,
46
+ createdAt: Date.now(),
47
+ };
48
+ const key = buildPromptsKey(sessionId);
49
+ const serialized = JSON.stringify(pendingPrompt);
50
+ await redisRPush(key, serialized);
51
+ log.info('Pending prompt stored', {
52
+ promptId: pendingPrompt.id,
53
+ sessionId,
54
+ issueId,
55
+ promptLength: prompt.length,
56
+ });
57
+ return pendingPrompt;
58
+ }
59
+ catch (error) {
60
+ log.error('Failed to store pending prompt', { error, sessionId, issueId });
61
+ return null;
62
+ }
63
+ }
64
+ /**
65
+ * Get all pending prompts for a session
66
+ *
67
+ * @param sessionId - The Linear session ID
68
+ * @returns Array of pending prompts (oldest first)
69
+ */
70
+ export async function getPendingPrompts(sessionId) {
71
+ if (!isRedisConfigured()) {
72
+ return [];
73
+ }
74
+ try {
75
+ const key = buildPromptsKey(sessionId);
76
+ const items = await redisLRange(key, 0, -1);
77
+ return items.map((item) => JSON.parse(item));
78
+ }
79
+ catch (error) {
80
+ log.error('Failed to get pending prompts', { error, sessionId });
81
+ return [];
82
+ }
83
+ }
84
+ /**
85
+ * Get the count of pending prompts for a session
86
+ */
87
+ export async function getPendingPromptCount(sessionId) {
88
+ if (!isRedisConfigured()) {
89
+ return 0;
90
+ }
91
+ try {
92
+ const key = buildPromptsKey(sessionId);
93
+ return await redisLLen(key);
94
+ }
95
+ catch (error) {
96
+ log.error('Failed to get pending prompt count', { error, sessionId });
97
+ return 0;
98
+ }
99
+ }
100
+ /**
101
+ * Claim and remove a pending prompt by ID
102
+ * Returns the prompt if found and removed, null otherwise
103
+ *
104
+ * @param sessionId - The Linear session ID
105
+ * @param promptId - The prompt ID to claim
106
+ * @returns The claimed prompt or null
107
+ */
108
+ export async function claimPendingPrompt(sessionId, promptId) {
109
+ if (!isRedisConfigured()) {
110
+ return null;
111
+ }
112
+ try {
113
+ const key = buildPromptsKey(sessionId);
114
+ const items = await redisLRange(key, 0, -1);
115
+ for (const item of items) {
116
+ const prompt = JSON.parse(item);
117
+ if (prompt.id === promptId) {
118
+ // Remove this specific item from the list
119
+ await redisLRem(key, 1, item);
120
+ log.info('Pending prompt claimed', { promptId, sessionId });
121
+ return prompt;
122
+ }
123
+ }
124
+ return null;
125
+ }
126
+ catch (error) {
127
+ log.error('Failed to claim pending prompt', { error, sessionId, promptId });
128
+ return null;
129
+ }
130
+ }
131
+ /**
132
+ * Pop the oldest pending prompt for a session (claim and remove atomically)
133
+ *
134
+ * @param sessionId - The Linear session ID
135
+ * @returns The oldest pending prompt or null if none
136
+ */
137
+ export async function popPendingPrompt(sessionId) {
138
+ if (!isRedisConfigured()) {
139
+ return null;
140
+ }
141
+ try {
142
+ const prompts = await getPendingPrompts(sessionId);
143
+ if (prompts.length === 0) {
144
+ return null;
145
+ }
146
+ const oldest = prompts[0];
147
+ const claimed = await claimPendingPrompt(sessionId, oldest.id);
148
+ return claimed;
149
+ }
150
+ catch (error) {
151
+ log.error('Failed to pop pending prompt', { error, sessionId });
152
+ return null;
153
+ }
154
+ }
155
+ /**
156
+ * Clear all pending prompts for a session
157
+ * Called when session completes or is stopped
158
+ *
159
+ * @param sessionId - The Linear session ID
160
+ * @returns true if cleared successfully
161
+ */
162
+ export async function clearPendingPrompts(sessionId) {
163
+ if (!isRedisConfigured()) {
164
+ return false;
165
+ }
166
+ try {
167
+ const key = buildPromptsKey(sessionId);
168
+ await redisDel(key);
169
+ log.info('Pending prompts cleared', { sessionId });
170
+ return true;
171
+ }
172
+ catch (error) {
173
+ log.error('Failed to clear pending prompts', { error, sessionId });
174
+ return false;
175
+ }
176
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Redis-backed Processing State Storage
3
+ *
4
+ * Implements the `ProcessingStateStorage` interface from `@renseiai/agentfactory`
5
+ * using Redis for persistence. Used by the top-of-funnel governor to track
6
+ * which processing phases (research, backlog-creation) have been completed
7
+ * for each issue.
8
+ *
9
+ * Key format: `governor:processing:{issueId}:{phase}`
10
+ * TTL: 30 days (matches workflow state TTL)
11
+ */
12
+ import type { ProcessingStateStorage, ProcessingPhase, ProcessingRecord } from '@renseiai/agentfactory';
13
+ /**
14
+ * Redis-backed implementation of `ProcessingStateStorage`.
15
+ *
16
+ * Each phase completion is stored as an independent key so that phases
17
+ * can be checked and cleared independently without affecting each other.
18
+ */
19
+ export declare class RedisProcessingStateStorage implements ProcessingStateStorage {
20
+ /**
21
+ * Check whether a given phase has already been completed for an issue.
22
+ */
23
+ isPhaseCompleted(issueId: string, phase: ProcessingPhase): Promise<boolean>;
24
+ /**
25
+ * Mark a phase as completed for an issue.
26
+ * Stores a `ProcessingRecord` JSON object with a 30-day TTL.
27
+ */
28
+ markPhaseCompleted(issueId: string, phase: ProcessingPhase, sessionId?: string): Promise<void>;
29
+ /**
30
+ * Clear a phase completion record for an issue.
31
+ */
32
+ clearPhase(issueId: string, phase: ProcessingPhase): Promise<void>;
33
+ /**
34
+ * Retrieve the processing record for a phase, if it exists.
35
+ */
36
+ getPhaseRecord(issueId: string, phase: ProcessingPhase): Promise<ProcessingRecord | null>;
37
+ }
38
+ //# sourceMappingURL=processing-state-storage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"processing-state-storage.d.ts","sourceRoot":"","sources":["../../src/processing-state-storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EACV,sBAAsB,EACtB,eAAe,EACf,gBAAgB,EACjB,MAAM,wBAAwB,CAAA;AAgB/B;;;;;GAKG;AACH,qBAAa,2BAA4B,YAAW,sBAAsB;IACxE;;OAEG;IACG,gBAAgB,CACpB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,OAAO,CAAC;IAInB;;;OAGG;IACG,kBAAkB,CACtB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,eAAe,EACtB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC;IAUhB;;OAEG;IACG,UAAU,CACd,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,IAAI,CAAC;IAIhB;;OAEG;IACG,cAAc,CAClB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC;CAGpC"}