claude-comms 1.0.46 → 2.0.1

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 (54) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +12 -13
  3. package/package.json +6 -10
  4. package/.agents/AGENTS.md +0 -61
  5. package/.agents/repo.md +0 -18
  6. package/.agents/tools/README.md +0 -45
  7. package/.agents/tools/agent-job/README.md +0 -208
  8. package/.agents/tools/agent-job/__pycache__/agent_monitor.cpython-312.pyc +0 -0
  9. package/.agents/tools/agent-job/agent_job.py +0 -936
  10. package/.agents/tools/agent-job/agent_monitor.py +0 -575
  11. package/.agents/tools/agent-job/consultant_template.txt +0 -73
  12. package/.agents/tools/chrome-devtools/README.md +0 -101
  13. package/.agents/tools/chrome-devtools/browsertools.py +0 -839
  14. package/.agents/tools/chrome-devtools/config.json +0 -24
  15. package/.agents/tools/workflow/CONSUMER_SETUP.md +0 -114
  16. package/.agents/tools/workflow/ONBOARD.md +0 -167
  17. package/.agents/tools/workflow/README.md +0 -169
  18. package/.agents/tools/workflow/cli.ts +0 -726
  19. package/.agents/tools/workflow/config.example.json +0 -16
  20. package/.agents/tools/workflow/init.ts +0 -76
  21. package/.agents/tools/workflow/lib/file-tracker.test.ts +0 -81
  22. package/.agents/tools/workflow/lib/file-tracker.ts +0 -387
  23. package/.agents/tools/workflow/lib/harness-executor.ts +0 -649
  24. package/.agents/tools/workflow/lib/prompts.scenario.test.ts +0 -262
  25. package/.agents/tools/workflow/lib/prompts.ts +0 -548
  26. package/.agents/tools/workflow/lib/streams.test.ts +0 -140
  27. package/.agents/tools/workflow/lib/streams.ts +0 -254
  28. package/.agents/tools/workflow/runner.ts +0 -994
  29. package/.agents/tools/workflow/templates/document.md +0 -42
  30. package/.agents/tools/workflow/templates/implement.md +0 -117
  31. package/.agents/tools/workflow/templates/plan.md +0 -54
  32. package/.agents/tools/workflow/templates/pm.md +0 -84
  33. package/.agents/tools/workflow/templates/product-owner.md +0 -211
  34. package/.agents/tools/workflow/templates/review.md +0 -56
  35. package/.agents/tools/workflow/templates/uat.md +0 -71
  36. package/.claude/agents/core/engineer.md +0 -146
  37. package/.claude/commands/action-feedback-item.md +0 -27
  38. package/.claude/commands/install-feedback-widget.md +0 -599
  39. package/.claude/commands/learn.md +0 -32
  40. package/.claude/hooks/comms/__pycache__/register_subagent.cpython-312.pyc +0 -0
  41. package/.claude/hooks/comms/__pycache__/update_subagent_completion.cpython-312.pyc +0 -0
  42. package/.claude/hooks/comms/get_unread_messages.py +0 -71
  43. package/.claude/hooks/comms/register_subagent.py +0 -74
  44. package/.claude/hooks/comms/send_message.py +0 -87
  45. package/.claude/hooks/comms/update_subagent_completion.py +0 -126
  46. package/.claude/hooks/context/session_id.py +0 -35
  47. package/.claude/hooks/observability/send_event.py +0 -110
  48. package/.claude/hooks/session-data/get_session_data.py +0 -261
  49. package/.claude/hooks/session-data/intercept_session_id.py +0 -69
  50. package/.claude/hooks/utils/__pycache__/server_config.cpython-312.pyc +0 -0
  51. package/.claude/hooks/utils/server_config.py +0 -17
  52. package/.claude/settings.json +0 -108
  53. package/.claude/settings.local.json +0 -46
  54. package/CLAUDE.md +0 -240
@@ -1,994 +0,0 @@
1
- #!/usr/bin/env npx tsx
2
- /**
3
- * Workflow Runner Daemon
4
- *
5
- * Subscribes to Convex and executes jobs as they become ready.
6
- * Runs forever, reacting to database changes.
7
- *
8
- * Supports parallel job execution within groups:
9
- * - Scheduler returns ALL pending jobs in a ready group
10
- * - Runner executes them in parallel
11
- * - When group completes, aggregated results go to PM
12
- *
13
- * Usage:
14
- * nohup npx tsx runner.ts > /tmp/runner.log 2>&1 &
15
- *
16
- * Config via config.json in same directory.
17
- */
18
-
19
- import { ConvexClient } from "convex/browser";
20
- import { anyApi } from "convex/server";
21
- import { readFileSync } from "fs";
22
- import { dirname, join } from "path";
23
- import { fileURLToPath } from "url";
24
-
25
- // Prompt building (extracted module)
26
- import {
27
- Assignment,
28
- Job,
29
- JobGroup,
30
- ChatJobContext,
31
- AccumulatedJobResult,
32
- buildPrompt,
33
- buildChatPrompt,
34
- parseChatContext,
35
- isChatJob,
36
- determinePromptType,
37
- } from "./lib/prompts.js";
38
-
39
- // Harness executor (file-based event streaming for crash resilience)
40
- import { HarnessExecutor, Harness } from "./lib/harness-executor.js";
41
-
42
- // Use anyApi for dynamic function references (works with ConvexClient)
43
- const api = anyApi;
44
-
45
- // Chat job from chatJobs table (separate from assignment jobs)
46
- interface ChatJob {
47
- _id: string;
48
- _creationTime: number;
49
- threadId: string;
50
- namespaceId: string;
51
- harness: "claude" | "codex" | "gemini";
52
- context: string;
53
- status: "pending" | "running" | "complete" | "failed";
54
- result?: string;
55
- startedAt?: number;
56
- completedAt?: number;
57
- createdAt: number;
58
- }
59
-
60
- // Config
61
- interface Config {
62
- convexUrl: string;
63
- namespace: string;
64
- timeoutMs: number;
65
- harnessDefaults: {
66
- default: Harness;
67
- [jobType: string]: Harness;
68
- };
69
- }
70
-
71
- /**
72
- * Get the harness for a job type from config
73
- */
74
- function getHarnessForJobType(jobType: string): Harness {
75
- return config.harnessDefaults[jobType] || config.harnessDefaults.default;
76
- }
77
-
78
- const __dirname = dirname(fileURLToPath(import.meta.url));
79
- const configPath = join(__dirname, "config.json");
80
- const config: Config = JSON.parse(readFileSync(configPath, "utf-8"));
81
-
82
- // Project root is 3 levels up from .agents/tools/workflow
83
- const projectRoot = join(__dirname, "..", "..", "..");
84
-
85
- // State
86
- let client: ConvexClient | null = null;
87
- let unsubscribeJobs: (() => void) | null = null;
88
- let unsubscribeChatJobs: (() => void) | null = null;
89
-
90
- // Harness executor (handles process spawning, file-based event streaming, orphan recovery)
91
- const executor = new HarnessExecutor({
92
- timeoutMs: config.timeoutMs,
93
- cwd: projectRoot,
94
- });
95
-
96
- const METRICS_HEARTBEAT_MS = 10_000;
97
- const CODEX_TOOL_ITEM_TYPES = new Set([
98
- "command_execution",
99
- "tool_call",
100
- "tool_use",
101
- "function_call",
102
- ]);
103
-
104
- interface MetricsState {
105
- toolCallCount: number;
106
- subagentCount: number;
107
- totalTokens: number | null;
108
- lastEventAt: number | null;
109
- lastFlushedToolCallCount: number;
110
- lastFlushedSubagentCount: number;
111
- lastFlushedEventAt: number | null;
112
- heartbeat: NodeJS.Timeout | null;
113
- seenToolIds: Set<string>;
114
- seenSubagentIds: Set<string>;
115
- }
116
-
117
- function createMetricsState(): MetricsState {
118
- return {
119
- toolCallCount: 0,
120
- subagentCount: 0,
121
- totalTokens: null,
122
- lastEventAt: null,
123
- lastFlushedToolCallCount: 0,
124
- lastFlushedSubagentCount: 0,
125
- lastFlushedEventAt: null,
126
- heartbeat: null,
127
- seenToolIds: new Set(),
128
- seenSubagentIds: new Set(),
129
- };
130
- }
131
-
132
- function extractToolCallInfo(
133
- harness: Harness,
134
- event: Record<string, unknown>
135
- ): { ids: string[]; hadToolUse: boolean } {
136
- const type = event.type as string | undefined;
137
- switch (harness) {
138
- case "claude": {
139
- if (type !== "assistant") return { ids: [], hadToolUse: false };
140
- const message = event.message as { content?: Array<{ type?: string; id?: string }> } | undefined;
141
- if (!message?.content) return { ids: [], hadToolUse: false };
142
- const ids: string[] = [];
143
- let hadToolUse = false;
144
- for (const block of message.content) {
145
- if (block?.type === "tool_use") {
146
- hadToolUse = true;
147
- if (block.id) ids.push(String(block.id));
148
- }
149
- }
150
- return { ids, hadToolUse };
151
- }
152
- case "codex": {
153
- if (type !== "item.started" && type !== "item.completed") {
154
- return { ids: [], hadToolUse: false };
155
- }
156
- const item = event.item as { type?: string; id?: string } | undefined;
157
- if (!item?.type || !CODEX_TOOL_ITEM_TYPES.has(item.type)) {
158
- return { ids: [], hadToolUse: false };
159
- }
160
- const ids = item.id ? [String(item.id)] : [];
161
- return { ids, hadToolUse: true };
162
- }
163
- case "gemini": {
164
- if (type !== "tool_use") return { ids: [], hadToolUse: false };
165
- const toolId =
166
- (event.tool_id as string | undefined) ??
167
- (event.toolId as string | undefined) ??
168
- (event.toolID as string | undefined);
169
- return { ids: toolId ? [String(toolId)] : [], hadToolUse: true };
170
- }
171
- default:
172
- return { ids: [], hadToolUse: false };
173
- }
174
- }
175
-
176
- function extractSubagentInfo(
177
- harness: Harness,
178
- event: Record<string, unknown>
179
- ): { ids: string[]; hadSubagent: boolean } {
180
- const type = event.type as string | undefined;
181
- switch (harness) {
182
- case "claude": {
183
- if (type !== "assistant") return { ids: [], hadSubagent: false };
184
- const message = event.message as { content?: Array<{ type?: string; id?: string; name?: string }> } | undefined;
185
- if (!message?.content) return { ids: [], hadSubagent: false };
186
- const ids: string[] = [];
187
- let hadSubagent = false;
188
- for (const block of message.content) {
189
- if (block?.type === "tool_use" && block.name === "Task") {
190
- hadSubagent = true;
191
- if (block.id) ids.push(String(block.id));
192
- }
193
- }
194
- return { ids, hadSubagent };
195
- }
196
- case "gemini": {
197
- if (type !== "tool_use") return { ids: [], hadSubagent: false };
198
- const toolName = event.tool_name as string | undefined;
199
- if (toolName !== "delegate_to_agent") return { ids: [], hadSubagent: false };
200
- const toolId =
201
- (event.tool_id as string | undefined) ??
202
- (event.toolId as string | undefined) ??
203
- (event.toolID as string | undefined);
204
- return { ids: toolId ? [String(toolId)] : [], hadSubagent: true };
205
- }
206
- case "codex":
207
- default:
208
- return { ids: [], hadSubagent: false };
209
- }
210
- }
211
-
212
- function extractTotalTokens(
213
- harness: Harness,
214
- event: Record<string, unknown>
215
- ): number | null {
216
- const type = event.type as string | undefined;
217
- if (harness === "claude" && type === "result") {
218
- const usage = event.usage as { input_tokens?: number; output_tokens?: number } | undefined;
219
- if (typeof usage?.input_tokens === "number" && typeof usage?.output_tokens === "number") {
220
- return usage.input_tokens + usage.output_tokens;
221
- }
222
- }
223
-
224
- if (harness === "codex" && type === "turn.completed") {
225
- const usage = event.usage as { input_tokens?: number; output_tokens?: number } | undefined;
226
- if (typeof usage?.input_tokens === "number" && typeof usage?.output_tokens === "number") {
227
- return usage.input_tokens + usage.output_tokens;
228
- }
229
- }
230
-
231
- if (harness === "gemini" && type === "result") {
232
- const stats = event.stats as {
233
- total_tokens?: number;
234
- input_tokens?: number;
235
- output_tokens?: number;
236
- } | undefined;
237
- if (typeof stats?.total_tokens === "number") return stats.total_tokens;
238
- if (typeof stats?.input_tokens === "number" && typeof stats?.output_tokens === "number") {
239
- return stats.input_tokens + stats.output_tokens;
240
- }
241
- }
242
-
243
- return null;
244
- }
245
-
246
- function recordMetricsEvent(
247
- harness: Harness,
248
- event: Record<string, unknown>,
249
- metrics: MetricsState
250
- ): void {
251
- metrics.lastEventAt = Date.now();
252
-
253
- const toolInfo = extractToolCallInfo(harness, event);
254
- if (toolInfo.hadToolUse) {
255
- if (toolInfo.ids.length === 0) {
256
- metrics.toolCallCount += 1;
257
- } else {
258
- for (const id of toolInfo.ids) {
259
- if (metrics.seenToolIds.has(id)) continue;
260
- metrics.seenToolIds.add(id);
261
- metrics.toolCallCount += 1;
262
- }
263
- }
264
- }
265
-
266
- const subagentInfo = extractSubagentInfo(harness, event);
267
- if (subagentInfo.hadSubagent) {
268
- if (subagentInfo.ids.length === 0) {
269
- metrics.subagentCount += 1;
270
- } else {
271
- for (const id of subagentInfo.ids) {
272
- if (metrics.seenSubagentIds.has(id)) continue;
273
- metrics.seenSubagentIds.add(id);
274
- metrics.subagentCount += 1;
275
- }
276
- }
277
- }
278
-
279
- const totalTokens = extractTotalTokens(harness, event);
280
- if (totalTokens !== null) {
281
- metrics.totalTokens = totalTokens;
282
- }
283
- }
284
-
285
- // Save assistant response to chat thread
286
- async function saveChatResponse(threadId: string, content: string): Promise<void> {
287
- try {
288
- await client!.mutation(api.chatMessages.add, {
289
- threadId: threadId as any,
290
- role: "assistant",
291
- content,
292
- });
293
- console.log(`[Chat] Saved assistant response to thread ${threadId}`);
294
- } catch (e) {
295
- console.error(`[Chat] Failed to save response:`, e);
296
- }
297
- }
298
-
299
- // Save Claude session_id to thread for session resume
300
- async function saveSessionId(threadId: string, sessionId: string): Promise<void> {
301
- try {
302
- await client!.mutation(api.chatThreads.updateSessionId, {
303
- id: threadId as any,
304
- sessionId,
305
- });
306
- console.log(`[Chat] Saved session_id ${sessionId} to thread ${threadId}`);
307
- } catch (e) {
308
- console.error(`[Chat] Failed to save session_id:`, e);
309
- }
310
- }
311
-
312
- // Save lastPromptMode to thread for differential prompting
313
- async function saveLastPromptMode(
314
- threadId: string,
315
- mode: "jam" | "cook"
316
- ): Promise<void> {
317
- try {
318
- await client!.mutation(api.chatThreads.updateLastPromptMode, {
319
- id: threadId as any,
320
- lastPromptMode: mode,
321
- });
322
- console.log(`[Chat] Saved lastPromptMode ${mode} to thread ${threadId}`);
323
- } catch (e) {
324
- console.error(`[Chat] Failed to save lastPromptMode:`, e);
325
- }
326
- }
327
-
328
- // Guardian Mode: Trigger PO evaluation when PM completes
329
- interface ChatThread {
330
- _id: string;
331
- mode: "jam" | "cook" | "guardian";
332
- assignmentId?: string;
333
- claudeSessionId?: string;
334
- namespaceId: string;
335
- }
336
-
337
- async function triggerGuardianEvaluation(
338
- assignment: Assignment,
339
- pmResult: string
340
- ): Promise<void> {
341
- try {
342
- const guardianThread: ChatThread | null = await client!.query(
343
- api.chatThreads.getGuardianThread,
344
- { assignmentId: assignment._id as any }
345
- );
346
-
347
- if (!guardianThread) {
348
- console.log(`[Guardian] No guardian thread for assignment ${assignment._id}`);
349
- return;
350
- }
351
-
352
- console.log(`[Guardian] Found guardian thread ${guardianThread._id}, triggering evaluation`);
353
-
354
- try {
355
- await client!.mutation(api.chatMessages.add, {
356
- threadId: guardianThread._id as any,
357
- role: "pm",
358
- content: pmResult,
359
- });
360
- console.log(`[Guardian] Inserted PM response as message in thread`);
361
- } catch (msgError) {
362
- console.error(`[Guardian] Failed to insert PM message:`, msgError);
363
- throw new Error(`Guardian PM message insert failed: ${msgError}`);
364
- }
365
-
366
- try {
367
- await client!.mutation(api.chatJobs.trigger, {
368
- threadId: guardianThread._id as any,
369
- harness: getHarnessForJobType("chat"),
370
- isGuardianEvaluation: true,
371
- });
372
- console.log(`[Guardian] Triggered PO evaluation chatJob`);
373
- } catch (triggerError) {
374
- console.error(`[Guardian] Failed to trigger evaluation job:`, triggerError);
375
- throw new Error(`Guardian evaluation trigger failed: ${triggerError}`);
376
- }
377
- } catch (e) {
378
- console.error(`[Guardian] Evaluation failed for assignment ${assignment._id}:`, e);
379
- }
380
- }
381
-
382
- // Job execution (uses HarnessExecutor for crash-resilient file-based streaming)
383
- async function executeJob(
384
- job: Job,
385
- group: JobGroup,
386
- assignment: Assignment,
387
- accumulatedResults: AccumulatedJobResult[],
388
- previousNonPmGroupResults: AccumulatedJobResult[],
389
- r1GroupResults: AccumulatedJobResult[]
390
- ): Promise<void> {
391
- const jobId = job._id;
392
- const isChat = isChatJob(job);
393
- let chatContext: ChatJobContext | null = null;
394
-
395
- console.log(`[${jobId}] Starting ${job.jobType} job (${job.harness})${isChat ? ' [CHAT]' : ''}`);
396
-
397
- // Build prompt FIRST (before marking as running, so we can save it)
398
- let prompt: string;
399
-
400
- if (isChat) {
401
- chatContext = parseChatContext(job.context || "{}");
402
- if (!chatContext) {
403
- console.error(`[${jobId}] Invalid chat context, failing job`);
404
- await client!.mutation(api.jobs.fail, {
405
- id: jobId,
406
- result: "Invalid chat context provided",
407
- });
408
- return;
409
- }
410
- prompt = buildChatPrompt(chatContext, config.namespace);
411
- const resumeInfo = chatContext.claudeSessionId
412
- ? ` (resuming session ${chatContext.claudeSessionId.slice(0, 8)}...)`
413
- : ' (new session)';
414
- console.log(`[${jobId}] Chat mode: ${chatContext.mode}, thread: ${chatContext.threadId}${resumeInfo}`);
415
- } else {
416
- prompt = buildPrompt(
417
- group,
418
- assignment,
419
- job,
420
- accumulatedResults,
421
- previousNonPmGroupResults,
422
- r1GroupResults
423
- );
424
- }
425
-
426
- // Mark job as running with prompt for visibility
427
- await client!.mutation(api.jobs.start, { id: jobId, prompt });
428
-
429
- const metrics = createMetricsState();
430
-
431
- const flushMetrics = async (): Promise<void> => {
432
- if (!client) return;
433
- const update: {
434
- id: string;
435
- toolCallCount?: number;
436
- subagentCount?: number;
437
- lastEventAt?: number;
438
- } = { id: jobId };
439
- let changed = false;
440
-
441
- if (metrics.toolCallCount !== metrics.lastFlushedToolCallCount) {
442
- update.toolCallCount = metrics.toolCallCount;
443
- metrics.lastFlushedToolCallCount = metrics.toolCallCount;
444
- changed = true;
445
- }
446
-
447
- if (metrics.subagentCount !== metrics.lastFlushedSubagentCount) {
448
- update.subagentCount = metrics.subagentCount;
449
- metrics.lastFlushedSubagentCount = metrics.subagentCount;
450
- changed = true;
451
- }
452
-
453
- if (metrics.lastEventAt !== metrics.lastFlushedEventAt) {
454
- update.lastEventAt = metrics.lastEventAt ?? undefined;
455
- metrics.lastFlushedEventAt = metrics.lastEventAt;
456
- changed = true;
457
- }
458
-
459
- if (!changed) return;
460
-
461
- try {
462
- await client!.mutation(api.jobs.updateMetrics, update);
463
- } catch (e) {
464
- console.error(`[${jobId}] Failed to update job metrics:`, e);
465
- }
466
- };
467
-
468
- const ensureHeartbeat = (): void => {
469
- if (metrics.heartbeat) return;
470
- metrics.heartbeat = setInterval(() => {
471
- void flushMetrics();
472
- }, METRICS_HEARTBEAT_MS);
473
- };
474
-
475
- const stopHeartbeat = (): void => {
476
- if (!metrics.heartbeat) return;
477
- clearInterval(metrics.heartbeat);
478
- metrics.heartbeat = null;
479
- };
480
-
481
- // Execute via HarnessExecutor (file-based streaming, crash-resilient)
482
- executor.execute(
483
- {
484
- jobId,
485
- harness: job.harness as Harness,
486
- prompt,
487
- sessionId: isChat && chatContext?.claudeSessionId ? chatContext.claudeSessionId : undefined,
488
- env: {
489
- WORKFLOW_ASSIGNMENT_ID: assignment._id,
490
- WORKFLOW_GROUP_ID: group._id,
491
- WORKFLOW_JOB_ID: job._id,
492
- },
493
- },
494
- {
495
- onEvent: (event) => {
496
- recordMetricsEvent(job.harness as Harness, event, metrics);
497
- ensureHeartbeat();
498
- },
499
- onComplete: async (result, sessionId) => {
500
- stopHeartbeat();
501
- try {
502
- await client!.mutation(api.jobs.complete, {
503
- id: jobId,
504
- result,
505
- toolCallCount: metrics.toolCallCount,
506
- subagentCount: metrics.subagentCount,
507
- totalTokens: metrics.totalTokens ?? undefined,
508
- lastEventAt: metrics.lastEventAt ?? undefined,
509
- });
510
- if (isChat && chatContext) {
511
- await saveChatResponse(chatContext.threadId, result);
512
- if (sessionId) {
513
- await saveSessionId(chatContext.threadId, sessionId);
514
- }
515
- } else {
516
- await handleGroupCompletion(group, assignment, false);
517
- }
518
- } catch (e) {
519
- console.error(`[${jobId}] Error in onComplete:`, e);
520
- }
521
- },
522
- onFail: async (reason, partialResult) => {
523
- stopHeartbeat();
524
- try {
525
- await client!.mutation(api.jobs.fail, {
526
- id: jobId,
527
- result: partialResult || reason,
528
- toolCallCount: metrics.toolCallCount,
529
- subagentCount: metrics.subagentCount,
530
- totalTokens: metrics.totalTokens ?? undefined,
531
- lastEventAt: metrics.lastEventAt ?? undefined,
532
- });
533
- if (isChat && chatContext) {
534
- await saveChatResponse(chatContext.threadId,
535
- `I encountered an issue while processing your request. Please try again or rephrase your question.\n\nError details: ${partialResult || reason}`
536
- );
537
- } else {
538
- await handleGroupCompletion(group, assignment, true);
539
- }
540
- } catch (e) {
541
- console.error(`[${jobId}] Error in onFail:`, e);
542
- }
543
- },
544
- onTimeout: async (partialResult) => {
545
- stopHeartbeat();
546
- try {
547
- await client!.mutation(api.jobs.fail, {
548
- id: jobId,
549
- result: `Timeout after ${config.timeoutMs}ms. Partial result:\n${partialResult}`,
550
- toolCallCount: metrics.toolCallCount,
551
- subagentCount: metrics.subagentCount,
552
- totalTokens: metrics.totalTokens ?? undefined,
553
- lastEventAt: metrics.lastEventAt ?? undefined,
554
- });
555
- if (isChat && chatContext) {
556
- await saveChatResponse(chatContext.threadId,
557
- `I apologize, but I ran into a timeout. Here's what I was able to process:\n\n${partialResult || "(no output)"}`
558
- );
559
- } else {
560
- await handleGroupCompletion(group, assignment, true);
561
- }
562
- } catch (e) {
563
- console.error(`[${jobId}] Error in onTimeout:`, e);
564
- }
565
- },
566
- }
567
- );
568
- }
569
-
570
- // Handle group completion - check if all jobs done, trigger PM if needed
571
- async function handleGroupCompletion(
572
- group: JobGroup,
573
- assignment: Assignment,
574
- anyFailed: boolean
575
- ): Promise<void> {
576
- // Re-fetch group with jobs to get current status
577
- const currentGroup = await client!.query(api.jobs.getGroupWithJobs, { id: group._id });
578
- if (!currentGroup) {
579
- console.error(`[${group._id}] Group not found`);
580
- return;
581
- }
582
-
583
- // If group not yet complete, another job is still running
584
- if (currentGroup.status !== "complete" && currentGroup.status !== "failed") {
585
- console.log(`[${group._id}] Group still in progress (status: ${currentGroup.status})`);
586
- return;
587
- }
588
-
589
- console.log(`[${group._id}] Group completed (status: ${currentGroup.status})`);
590
-
591
- // Check job types in this group to determine behavior
592
- const jobs = currentGroup.jobs || [];
593
- const hasPMJob = jobs.some((j: Job) => j.jobType === "pm");
594
- const hasDocumentJob = jobs.some((j: Job) => j.jobType === "document");
595
-
596
- // PM groups: always trigger guardian evaluation (even if PM inserted next job)
597
- if (hasPMJob) {
598
- // PM completed - trigger guardian evaluation if applicable
599
- // Find the PM job's result (not aggregatedResult with headers)
600
- const pmJob = jobs.find((j: Job) => j.jobType === "pm" && j.result);
601
- const pmResult = pmJob?.result || "";
602
- await triggerGuardianEvaluation(assignment, pmResult);
603
- // If PM didn't insert a next job, check if assignment is done
604
- if (!currentGroup.nextGroupId) {
605
- await checkAndCompleteAssignment(assignment._id, group._id);
606
- }
607
- return;
608
- }
609
-
610
- if (hasDocumentJob) {
611
- // Document job auto-completes the assignment
612
- if (!currentGroup.nextGroupId) {
613
- await checkAndCompleteAssignment(assignment._id, group._id);
614
- }
615
- return;
616
- }
617
-
618
- // Regular group: if next group exists, let scheduler handle it
619
- if (currentGroup.nextGroupId) {
620
- console.log(`[${group._id}] Next group exists, scheduler will pick it up`);
621
- return;
622
- }
623
-
624
- // No next group and not PM/document - trigger PM review
625
- const failed = currentGroup.status === "failed";
626
- await triggerPMGroup(group, assignment, failed);
627
- }
628
-
629
- // Check if all groups in assignment are done, if so mark complete
630
- async function checkAndCompleteAssignment(assignmentId: string, completedGroupId: string): Promise<void> {
631
- const groups = await client!.query(api.jobs.listGroups, { assignmentId: assignmentId as any });
632
-
633
- const hasIncompleteGroups = groups.some(
634
- (g: JobGroup) => g.status === "pending" || g.status === "running"
635
- );
636
-
637
- if (hasIncompleteGroups) {
638
- console.log(`[${completedGroupId}] Assignment ${assignmentId} still has incomplete groups`);
639
- return;
640
- }
641
-
642
- console.log(`[${completedGroupId}] All groups done, marking assignment ${assignmentId} as complete`);
643
- await client!.mutation(api.assignments.complete, {
644
- id: assignmentId,
645
- });
646
- }
647
-
648
- // PM group triggering
649
- async function triggerPMGroup(
650
- completedGroup: JobGroup,
651
- assignment: Assignment,
652
- failed: boolean
653
- ): Promise<void> {
654
- const jobType = "pm";
655
- const context = failed
656
- ? `Previous group failed. Diagnose issues, decide recovery, and choose next job(s).`
657
- : undefined;
658
-
659
- console.log(`[${completedGroup._id}] Triggering ${jobType} group`);
660
-
661
- await client!.mutation(api.jobs.insertGroupAfter, {
662
- afterGroupId: completedGroup._id,
663
- jobs: [{
664
- jobType,
665
- harness: getHarnessForJobType(jobType),
666
- context,
667
- }],
668
- });
669
- }
670
-
671
- // Scheduler interfaces
672
- interface ReadyJob {
673
- job: Job;
674
- group: JobGroup;
675
- assignment: Assignment;
676
- accumulatedResults: AccumulatedJobResult[];
677
- previousNonPmGroupResults: AccumulatedJobResult[];
678
- r1GroupResults: AccumulatedJobResult[];
679
- }
680
-
681
- interface ReadyChatJob {
682
- chatJob: ChatJob;
683
- }
684
-
685
- async function processQueue(readyJobs: ReadyJob[]): Promise<void> {
686
- if (readyJobs.length === 0) return;
687
-
688
- // Filter out jobs we're already running (tracked by executor)
689
- const newJobs = readyJobs.filter((r) => !executor.isTracking(r.job._id));
690
- if (newJobs.length === 0) return;
691
-
692
- console.log(`[Queue] ${newJobs.length} new jobs to execute (parallel if same group)`);
693
-
694
- // Execute all ready jobs - scheduler already handles group logic
695
- for (const {
696
- job,
697
- group,
698
- assignment,
699
- accumulatedResults,
700
- previousNonPmGroupResults,
701
- r1GroupResults,
702
- } of newJobs) {
703
- // Double-check assignment status before executing
704
- const currentAssignment = await client!.query(api.assignments.get, {
705
- id: assignment._id,
706
- });
707
- if (currentAssignment?.status === "blocked") {
708
- console.log(`[${job._id}] Assignment ${assignment._id} is blocked, skipping`);
709
- continue;
710
- }
711
-
712
- executeJob(
713
- job,
714
- group,
715
- assignment,
716
- accumulatedResults,
717
- previousNonPmGroupResults,
718
- r1GroupResults
719
- ).catch((e) => {
720
- console.error(`Error executing job ${job._id}:`, e);
721
- });
722
- }
723
- }
724
-
725
- // Chat job execution (separate from assignment jobs, uses HarnessExecutor)
726
- async function executeChatJob(chatJob: ChatJob): Promise<void> {
727
- const jobId = chatJob._id;
728
- console.log(`[${jobId}] Starting chat job (${chatJob.harness})`);
729
-
730
- const chatContext = parseChatContext(chatJob.context);
731
- if (!chatContext) {
732
- console.error(`[${jobId}] Invalid chat context, failing job`);
733
- await client!.mutation(api.chatJobs.fail, {
734
- id: jobId,
735
- result: "Invalid chat context provided",
736
- });
737
- return;
738
- }
739
-
740
- const promptType = determinePromptType(chatContext);
741
- const prompt = buildChatPrompt(chatContext, config.namespace);
742
- const resumeInfo = chatContext.claudeSessionId
743
- ? ` (resuming session ${chatContext.claudeSessionId.slice(0, 8)}...)`
744
- : " (new session)";
745
- console.log(`[${jobId}] Chat mode: ${chatContext.mode}, prompt: ${promptType}, thread: ${chatContext.threadId}${resumeInfo}`);
746
-
747
- await client!.mutation(api.chatJobs.start, { id: jobId, prompt });
748
-
749
- const metrics = createMetricsState();
750
-
751
- const flushMetrics = async (): Promise<void> => {
752
- if (!client) return;
753
- const update: {
754
- id: string;
755
- toolCallCount?: number;
756
- subagentCount?: number;
757
- lastEventAt?: number;
758
- } = { id: jobId };
759
- let changed = false;
760
-
761
- if (metrics.toolCallCount !== metrics.lastFlushedToolCallCount) {
762
- update.toolCallCount = metrics.toolCallCount;
763
- metrics.lastFlushedToolCallCount = metrics.toolCallCount;
764
- changed = true;
765
- }
766
-
767
- if (metrics.subagentCount !== metrics.lastFlushedSubagentCount) {
768
- update.subagentCount = metrics.subagentCount;
769
- metrics.lastFlushedSubagentCount = metrics.subagentCount;
770
- changed = true;
771
- }
772
-
773
- if (metrics.lastEventAt !== metrics.lastFlushedEventAt) {
774
- update.lastEventAt = metrics.lastEventAt ?? undefined;
775
- metrics.lastFlushedEventAt = metrics.lastEventAt;
776
- changed = true;
777
- }
778
-
779
- if (!changed) return;
780
-
781
- try {
782
- await client!.mutation(api.chatJobs.updateMetrics, update);
783
- } catch (e) {
784
- console.error(`[${jobId}] Failed to update chat job metrics:`, e);
785
- }
786
- };
787
-
788
- const ensureHeartbeat = (): void => {
789
- if (metrics.heartbeat) return;
790
- metrics.heartbeat = setInterval(() => {
791
- void flushMetrics();
792
- }, METRICS_HEARTBEAT_MS);
793
- };
794
-
795
- const stopHeartbeat = (): void => {
796
- if (!metrics.heartbeat) return;
797
- clearInterval(metrics.heartbeat);
798
- metrics.heartbeat = null;
799
- };
800
-
801
- // Execute via HarnessExecutor (file-based streaming, crash-resilient)
802
- executor.execute(
803
- {
804
- jobId,
805
- harness: chatJob.harness as Harness,
806
- prompt,
807
- sessionId: chatContext.claudeSessionId || undefined,
808
- env: {
809
- WORKFLOW_THREAD_ID: chatContext.threadId,
810
- WORKFLOW_NAMESPACE_ID: chatContext.namespaceId,
811
- },
812
- },
813
- {
814
- onEvent: (event) => {
815
- recordMetricsEvent(chatJob.harness as Harness, event, metrics);
816
- ensureHeartbeat();
817
- },
818
- onComplete: async (result, sessionId) => {
819
- stopHeartbeat();
820
- try {
821
- await client!.mutation(api.chatJobs.complete, {
822
- id: jobId,
823
- result,
824
- toolCallCount: metrics.toolCallCount,
825
- subagentCount: metrics.subagentCount,
826
- totalTokens: metrics.totalTokens ?? undefined,
827
- lastEventAt: metrics.lastEventAt ?? undefined,
828
- });
829
- await saveChatResponse(chatContext.threadId, result);
830
- if (sessionId) {
831
- await saveSessionId(chatContext.threadId, sessionId);
832
- }
833
- if (!chatContext.isGuardianEvaluation) {
834
- await saveLastPromptMode(chatContext.threadId, chatContext.effectivePromptMode);
835
- }
836
- } catch (e) {
837
- console.error(`[${jobId}] Error in onComplete:`, e);
838
- try {
839
- await saveChatResponse(chatContext.threadId,
840
- `I completed processing but encountered a system error saving the result. Please check the logs.`
841
- );
842
- } catch {
843
- // Ignore - best effort
844
- }
845
- }
846
- },
847
- onFail: async (reason, partialResult) => {
848
- stopHeartbeat();
849
- try {
850
- await client!.mutation(api.chatJobs.fail, {
851
- id: jobId,
852
- result: partialResult || reason,
853
- toolCallCount: metrics.toolCallCount,
854
- subagentCount: metrics.subagentCount,
855
- totalTokens: metrics.totalTokens ?? undefined,
856
- lastEventAt: metrics.lastEventAt ?? undefined,
857
- });
858
- await saveChatResponse(chatContext.threadId,
859
- `I encountered an issue while processing your request. Please try again or rephrase your question.\n\nError details: ${partialResult || reason}`
860
- );
861
- } catch (e) {
862
- console.error(`[${jobId}] Error in onFail:`, e);
863
- }
864
- },
865
- onTimeout: async (partialResult) => {
866
- stopHeartbeat();
867
- try {
868
- await client!.mutation(api.chatJobs.fail, {
869
- id: jobId,
870
- result: `Timeout after ${config.timeoutMs}ms. Partial result:\n${partialResult}`,
871
- toolCallCount: metrics.toolCallCount,
872
- subagentCount: metrics.subagentCount,
873
- totalTokens: metrics.totalTokens ?? undefined,
874
- lastEventAt: metrics.lastEventAt ?? undefined,
875
- });
876
- await saveChatResponse(chatContext.threadId,
877
- `I apologize, but I ran into a timeout. Here's what I was able to process:\n\n${partialResult || "(no output)"}`
878
- );
879
- } catch (e) {
880
- console.error(`[${jobId}] Error in onTimeout:`, e);
881
- }
882
- },
883
- }
884
- );
885
- }
886
-
887
- async function processChatQueue(readyChatJobs: ReadyChatJob[]): Promise<void> {
888
- if (readyChatJobs.length === 0) return;
889
-
890
- // Filter out jobs we're already running (tracked by executor)
891
- const newJobs = readyChatJobs.filter((r) => !executor.isTracking(r.chatJob._id));
892
- if (newJobs.length === 0) return;
893
-
894
- for (const { chatJob } of newJobs) {
895
- executeChatJob(chatJob).catch((e) => {
896
- console.error(`Error executing chat job ${chatJob._id}:`, e);
897
- });
898
- }
899
- }
900
-
901
- // Main
902
- async function startRunner() {
903
- console.log(`Workflow runner starting for namespace: ${config.namespace}`);
904
- console.log(`Convex URL: ${config.convexUrl}`);
905
-
906
- client = new ConvexClient(config.convexUrl);
907
-
908
- const namespace = await client.query(api.namespaces.getByName, {
909
- name: config.namespace,
910
- });
911
-
912
- if (!namespace) {
913
- console.error(`Error: Namespace "${config.namespace}" not found in database.`);
914
- console.error("Run 'npx tsx init.ts' to initialize the namespace first.");
915
- process.exit(1);
916
- }
917
-
918
- const namespaceId = namespace._id;
919
- console.log(`Found namespace ID: ${namespaceId}`);
920
-
921
- // Reconcile any orphaned jobs from previous runner crash
922
- await executor.reconcileOrphans(async (result) => {
923
- console.log(`[Reconcile] ${result.jobId}: ${result.finalStatus}`);
924
- // Note: We don't update Convex here because orphaned jobs may have already
925
- // been marked as failed/timed out by the scheduler. The file-based status
926
- // is the authoritative record for the TUI monitor.
927
- });
928
-
929
- // Subscribe to assignment-based jobs
930
- unsubscribeJobs = client.onUpdate(
931
- api.scheduler.getReadyJobs,
932
- { namespaceId },
933
- (readyJobs: ReadyJob[]) => {
934
- console.log(`Queue update: ${readyJobs.length} ready jobs`);
935
- processQueue(readyJobs).catch((e) => {
936
- console.error("Error processing queue:", e);
937
- });
938
- },
939
- (error: Error) => {
940
- console.error("Jobs subscription error:", error);
941
- cleanup();
942
- setTimeout(startRunner, 5000);
943
- }
944
- );
945
-
946
- // Subscribe to chat jobs (separate from assignments)
947
- unsubscribeChatJobs = client.onUpdate(
948
- api.scheduler.getReadyChatJobs,
949
- { namespaceId },
950
- (readyChatJobs: ReadyChatJob[]) => {
951
- if (readyChatJobs.length > 0) {
952
- console.log(`Chat queue update: ${readyChatJobs.length} ready chat jobs`);
953
- }
954
- processChatQueue(readyChatJobs).catch((e) => {
955
- console.error("Error processing chat queue:", e);
956
- });
957
- },
958
- (error: Error) => {
959
- console.error("Chat jobs subscription error:", error);
960
- cleanup();
961
- setTimeout(startRunner, 5000);
962
- }
963
- );
964
- }
965
-
966
- function cleanup() {
967
- unsubscribeJobs?.();
968
- unsubscribeJobs = null;
969
- unsubscribeChatJobs?.();
970
- unsubscribeChatJobs = null;
971
-
972
- // Kill all active jobs via executor
973
- executor.killAll();
974
-
975
- client?.close();
976
- client = null;
977
- }
978
-
979
- startRunner();
980
-
981
- process.on("SIGINT", () => {
982
- console.log("\nShutting down...");
983
- cleanup();
984
- process.exit(0);
985
- });
986
-
987
- process.on("SIGTERM", () => {
988
- console.log("\nShutting down...");
989
- cleanup();
990
- process.exit(0);
991
- });
992
-
993
- // Keep alive
994
- setInterval(() => {}, 1000);