triflux 3.3.0-dev.1 → 3.3.0-dev.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hub/pipe.mjs CHANGED
@@ -5,6 +5,19 @@ import net from 'node:net';
5
5
  import { existsSync, unlinkSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { randomUUID } from 'node:crypto';
8
+ import {
9
+ teamInfo,
10
+ teamTaskList,
11
+ teamTaskUpdate,
12
+ teamSendMessage,
13
+ } from './team/nativeProxy.mjs';
14
+ import { createPipeline } from './pipeline/index.mjs';
15
+ import {
16
+ ensurePipelineTable,
17
+ initPipelineState,
18
+ listPipelineStates,
19
+ readPipelineState,
20
+ } from './pipeline/state.mjs';
8
21
 
9
22
  const DEFAULT_HEARTBEAT_TTL_MS = 60000;
10
23
 
@@ -173,6 +186,24 @@ export function createPipeServer({
173
186
  return result;
174
187
  }
175
188
 
189
+ case 'assign': {
190
+ const result = router.assignAsync(payload);
191
+ if (client) touchClient(client);
192
+ return result;
193
+ }
194
+
195
+ case 'assign_result': {
196
+ const result = router.reportAssignResult(payload);
197
+ if (client) touchClient(client);
198
+ return result;
199
+ }
200
+
201
+ case 'assign_retry': {
202
+ const result = router.retryAssign(payload.job_id, payload);
203
+ if (client) touchClient(client);
204
+ return result;
205
+ }
206
+
176
207
  case 'result': {
177
208
  const result = router.handlePublish({
178
209
  from: payload.agent_id,
@@ -218,6 +249,41 @@ export function createPipeServer({
218
249
  };
219
250
  }
220
251
 
252
+ case 'team_task_update': {
253
+ const result = await teamTaskUpdate(payload);
254
+ if (client) touchClient(client);
255
+ return result;
256
+ }
257
+
258
+ case 'team_send_message': {
259
+ const result = await teamSendMessage(payload);
260
+ if (client) touchClient(client);
261
+ return result;
262
+ }
263
+
264
+ case 'pipeline_advance': {
265
+ if (client) touchClient(client);
266
+ if (!store?.db) {
267
+ return { ok: false, error: 'hub_db_not_found' };
268
+ }
269
+ ensurePipelineTable(store.db);
270
+ const pipeline = createPipeline(store.db, payload.team_name);
271
+ return pipeline.advance(payload.phase);
272
+ }
273
+
274
+ case 'pipeline_init': {
275
+ if (client) touchClient(client);
276
+ if (!store?.db) {
277
+ return { ok: false, error: 'hub_db_not_found' };
278
+ }
279
+ ensurePipelineTable(store.db);
280
+ const state = initPipelineState(store.db, payload.team_name, {
281
+ fix_max: payload.fix_max,
282
+ ralph_max: payload.ralph_max,
283
+ });
284
+ return { ok: true, data: state };
285
+ }
286
+
221
287
  default:
222
288
  return {
223
289
  ok: false,
@@ -282,6 +348,44 @@ export function createPipeServer({
282
348
  return router.getStatus(scope, payload);
283
349
  }
284
350
 
351
+ case 'assign_status': {
352
+ if (client) touchClient(client);
353
+ return router.getAssignStatus(payload);
354
+ }
355
+
356
+ case 'team_info': {
357
+ const result = await teamInfo(payload);
358
+ if (client) touchClient(client);
359
+ return result;
360
+ }
361
+
362
+ case 'team_task_list': {
363
+ const result = await teamTaskList(payload);
364
+ if (client) touchClient(client);
365
+ return result;
366
+ }
367
+
368
+ case 'pipeline_state': {
369
+ if (client) touchClient(client);
370
+ if (!store?.db) {
371
+ return { ok: false, error: 'hub_db_not_found' };
372
+ }
373
+ ensurePipelineTable(store.db);
374
+ const state = readPipelineState(store.db, payload.team_name);
375
+ return state
376
+ ? { ok: true, data: state }
377
+ : { ok: false, error: 'pipeline_not_found' };
378
+ }
379
+
380
+ case 'pipeline_list': {
381
+ if (client) touchClient(client);
382
+ if (!store?.db) {
383
+ return { ok: false, error: 'hub_db_not_found' };
384
+ }
385
+ ensurePipelineTable(store.db);
386
+ return { ok: true, data: listPipelineStates(store.db) };
387
+ }
388
+
285
389
  default:
286
390
  return {
287
391
  ok: false,
package/hub/router.mjs CHANGED
@@ -3,10 +3,45 @@
3
3
  import { EventEmitter, once } from 'node:events';
4
4
  import { uuidv7 } from './store.mjs';
5
5
 
6
+ const ASSIGN_PENDING_STATUSES = new Set(['queued', 'running']);
7
+
6
8
  function uniqueStrings(values = []) {
7
9
  return Array.from(new Set((values || []).map((value) => String(value || '').trim()).filter(Boolean)));
8
10
  }
9
11
 
12
+ function clampAssignDuration(value, fallback = 600000, min = 1000, max = 86400000) {
13
+ const num = Number(value);
14
+ if (!Number.isFinite(num)) return fallback;
15
+ return Math.max(min, Math.min(Math.trunc(num), max));
16
+ }
17
+
18
+ function normalizeAssignTerminalStatus(input, metadata = {}) {
19
+ const status = String(input || '').trim().toLowerCase();
20
+ const resultTag = String(
21
+ metadata?.result
22
+ ?? metadata?.status
23
+ ?? metadata?.outcome
24
+ ?? '',
25
+ ).trim().toLowerCase();
26
+
27
+ if (status === 'queued') return 'queued';
28
+ if (status === 'running' || status === 'in_progress') return 'running';
29
+ if (status === 'timed_out' || status === 'timeout') return 'timed_out';
30
+ if (status === 'failed' || status === 'error') return 'failed';
31
+ if (status === 'succeeded' || status === 'success') return 'succeeded';
32
+
33
+ if (status === 'completed') {
34
+ if (resultTag === 'failed' || resultTag === 'error') return 'failed';
35
+ if (resultTag === 'timed_out' || resultTag === 'timeout') return 'timed_out';
36
+ return 'succeeded';
37
+ }
38
+
39
+ if (resultTag === 'failed' || resultTag === 'error') return 'failed';
40
+ if (resultTag === 'timed_out' || resultTag === 'timeout') return 'timed_out';
41
+ if (resultTag === 'succeeded' || resultTag === 'success') return 'succeeded';
42
+ return 'succeeded';
43
+ }
44
+
10
45
  function normalizeAgentTopics(store, agentId, runtimeTopics) {
11
46
  const topics = new Set(runtimeTopics || []);
12
47
  const persisted = store.getAgent(agentId)?.topics || [];
@@ -199,6 +234,133 @@ export function createRouter(store) {
199
234
  return { msg, recipients };
200
235
  }
201
236
 
237
+ function buildAssignSnapshot(job, extra = {}) {
238
+ if (!job) return null;
239
+ return {
240
+ job_id: job.job_id,
241
+ supervisor_agent: job.supervisor_agent,
242
+ worker_agent: job.worker_agent,
243
+ topic: job.topic,
244
+ task: job.task,
245
+ status: job.status,
246
+ attempt: job.attempt,
247
+ retry_count: job.retry_count,
248
+ max_retries: job.max_retries,
249
+ timeout_ms: job.timeout_ms,
250
+ deadline_ms: job.deadline_ms,
251
+ trace_id: job.trace_id,
252
+ correlation_id: job.correlation_id,
253
+ last_message_id: job.last_message_id,
254
+ result: job.result,
255
+ error: job.error,
256
+ updated_at_ms: job.updated_at_ms,
257
+ completed_at_ms: job.completed_at_ms,
258
+ ...extra,
259
+ };
260
+ }
261
+
262
+ function notifyAssignSupervisor(job, event, extra = {}) {
263
+ if (!job?.supervisor_agent) return null;
264
+ const { msg } = dispatchMessage({
265
+ type: 'event',
266
+ from: job.worker_agent || 'assign-router',
267
+ to: job.supervisor_agent,
268
+ topic: 'assign.result',
269
+ priority: Math.max(5, job.priority || 5),
270
+ ttl_ms: job.ttl_ms || job.timeout_ms || 600000,
271
+ payload: {
272
+ event,
273
+ ...buildAssignSnapshot(job),
274
+ ...extra,
275
+ },
276
+ trace_id: job.trace_id,
277
+ correlation_id: job.correlation_id,
278
+ });
279
+ return msg;
280
+ }
281
+
282
+ function dispatchAssignJob(job, reason = 'dispatch') {
283
+ const { msg, recipients } = dispatchMessage({
284
+ type: 'handoff',
285
+ from: job.supervisor_agent,
286
+ to: job.worker_agent,
287
+ topic: job.topic || 'assign.job',
288
+ priority: job.priority || 5,
289
+ ttl_ms: job.ttl_ms || job.timeout_ms || 600000,
290
+ payload: {
291
+ kind: 'assign.job',
292
+ reason,
293
+ assign_job_id: job.job_id,
294
+ attempt: job.attempt,
295
+ retry_count: job.retry_count,
296
+ max_retries: job.max_retries,
297
+ timeout_ms: job.timeout_ms,
298
+ supervisor_agent: job.supervisor_agent,
299
+ worker_agent: job.worker_agent,
300
+ task: job.task,
301
+ payload: job.payload || {},
302
+ },
303
+ trace_id: job.trace_id,
304
+ correlation_id: job.correlation_id,
305
+ });
306
+
307
+ const updated = store.updateAssignStatus(job.job_id, job.status, {
308
+ last_message_id: msg.id,
309
+ });
310
+ return { job: updated || job, recipients, message_id: msg.id };
311
+ }
312
+
313
+ function scheduleAssignRetry(job, reason, error = null, requested_by = 'system') {
314
+ if (!job) {
315
+ return { ok: false, error: { code: 'ASSIGN_NOT_FOUND', message: 'assign job not found' } };
316
+ }
317
+ if (job.retry_count >= job.max_retries) {
318
+ return {
319
+ ok: false,
320
+ error: {
321
+ code: 'ASSIGN_RETRY_EXHAUSTED',
322
+ message: `retry exhausted for ${job.job_id}`,
323
+ },
324
+ };
325
+ }
326
+
327
+ const queued = store.retryAssign(job.job_id, {
328
+ error,
329
+ timeout_ms: job.timeout_ms,
330
+ ttl_ms: job.ttl_ms,
331
+ });
332
+ const dispatched = dispatchAssignJob(queued, 'retry');
333
+ notifyAssignSupervisor(dispatched.job, 'retry_scheduled', {
334
+ retry_reason: reason,
335
+ requested_by,
336
+ });
337
+ return {
338
+ ok: true,
339
+ data: {
340
+ retried: true,
341
+ ...buildAssignSnapshot(dispatched.job, {
342
+ retry_reason: reason,
343
+ requested_by,
344
+ }),
345
+ },
346
+ };
347
+ }
348
+
349
+ function handleAssignTimeout(job) {
350
+ const timedOut = store.updateAssignStatus(job.job_id, 'timed_out', {
351
+ error: job.error ?? { message: 'assign job timed out' },
352
+ });
353
+
354
+ if (timedOut.retry_count < timedOut.max_retries) {
355
+ return scheduleAssignRetry(timedOut, 'timed_out', timedOut.error, 'sweeper');
356
+ }
357
+
358
+ notifyAssignSupervisor(timedOut, 'completed', {
359
+ completion_reason: 'timed_out',
360
+ });
361
+ return { ok: true, data: buildAssignSnapshot(timedOut, { completion_reason: 'timed_out' }) };
362
+ }
363
+
202
364
  const router = {
203
365
  responseEmitter,
204
366
  deliveryEmitter,
@@ -362,6 +524,137 @@ export function createRouter(store) {
362
524
  };
363
525
  },
364
526
 
527
+ assignAsync({
528
+ supervisor_agent,
529
+ worker_agent,
530
+ topic = 'assign.job',
531
+ task = '',
532
+ payload = {},
533
+ priority = 5,
534
+ ttl_ms = 600000,
535
+ timeout_ms = 600000,
536
+ max_retries = 0,
537
+ trace_id,
538
+ correlation_id,
539
+ }) {
540
+ const job = store.createAssign({
541
+ supervisor_agent,
542
+ worker_agent,
543
+ topic,
544
+ task,
545
+ payload,
546
+ priority,
547
+ ttl_ms,
548
+ timeout_ms,
549
+ max_retries,
550
+ trace_id,
551
+ correlation_id,
552
+ });
553
+ const dispatched = dispatchAssignJob(job, 'create');
554
+ return {
555
+ ok: true,
556
+ data: {
557
+ assigned_to: worker_agent,
558
+ ...buildAssignSnapshot(dispatched.job),
559
+ },
560
+ };
561
+ },
562
+
563
+ reportAssignResult({
564
+ job_id,
565
+ worker_agent,
566
+ status,
567
+ attempt,
568
+ result,
569
+ error,
570
+ payload = {},
571
+ metadata = {},
572
+ }) {
573
+ const job = store.getAssign(job_id);
574
+ if (!job) {
575
+ return {
576
+ ok: false,
577
+ error: { code: 'ASSIGN_NOT_FOUND', message: `assign job not found: ${job_id}` },
578
+ };
579
+ }
580
+ if (worker_agent && worker_agent !== job.worker_agent) {
581
+ return {
582
+ ok: false,
583
+ error: { code: 'ASSIGN_WORKER_MISMATCH', message: `worker mismatch: ${worker_agent}` },
584
+ };
585
+ }
586
+ if (Number.isFinite(Number(attempt)) && Number(attempt) !== job.attempt) {
587
+ return {
588
+ ok: false,
589
+ error: {
590
+ code: 'ASSIGN_ATTEMPT_MISMATCH',
591
+ message: `stale assign result for attempt ${attempt} (current ${job.attempt})`,
592
+ },
593
+ };
594
+ }
595
+
596
+ const mergedMetadata = {
597
+ ...(payload?.metadata || {}),
598
+ ...(metadata || {}),
599
+ };
600
+ const normalizedStatus = normalizeAssignTerminalStatus(
601
+ status || payload?.status,
602
+ mergedMetadata,
603
+ );
604
+ const nextResult = result ?? (Object.prototype.hasOwnProperty.call(payload || {}, 'result') ? payload.result : payload);
605
+ const nextError = error ?? payload?.error ?? null;
606
+
607
+ if (normalizedStatus === 'running') {
608
+ const running = store.updateAssignStatus(job.job_id, 'running', {
609
+ started_at_ms: job.started_at_ms || Date.now(),
610
+ deadline_ms: Date.now() + clampAssignDuration(job.timeout_ms, job.timeout_ms),
611
+ result: nextResult,
612
+ error: nextError,
613
+ });
614
+ notifyAssignSupervisor(running, 'progress');
615
+ return { ok: true, data: buildAssignSnapshot(running) };
616
+ }
617
+
618
+ const finalized = store.updateAssignStatus(job.job_id, normalizedStatus, {
619
+ result: nextResult,
620
+ error: nextError,
621
+ });
622
+
623
+ if ((normalizedStatus === 'failed' || normalizedStatus === 'timed_out')
624
+ && finalized.retry_count < finalized.max_retries) {
625
+ return scheduleAssignRetry(finalized, normalizedStatus, nextError, worker_agent || finalized.worker_agent);
626
+ }
627
+
628
+ notifyAssignSupervisor(finalized, 'completed');
629
+ return { ok: true, data: buildAssignSnapshot(finalized) };
630
+ },
631
+
632
+ getAssignStatus({ job_id, ...filters } = {}) {
633
+ if (job_id) {
634
+ const job = store.getAssign(job_id);
635
+ return job
636
+ ? { ok: true, data: buildAssignSnapshot(job) }
637
+ : { ok: false, error: { code: 'ASSIGN_NOT_FOUND', message: `assign job not found: ${job_id}` } };
638
+ }
639
+ return {
640
+ ok: true,
641
+ data: {
642
+ assigns: store.listAssigns(filters).map((job) => buildAssignSnapshot(job)),
643
+ },
644
+ };
645
+ },
646
+
647
+ retryAssign(job_id, { reason = 'manual', requested_by = 'manual' } = {}) {
648
+ const job = store.getAssign(job_id);
649
+ if (!job) {
650
+ return {
651
+ ok: false,
652
+ error: { code: 'ASSIGN_NOT_FOUND', message: `assign job not found: ${job_id}` },
653
+ };
654
+ }
655
+ return scheduleAssignRetry(job, reason, job.error, requested_by);
656
+ },
657
+
365
658
  sweepExpired() {
366
659
  const now = Date.now();
367
660
  let expired = 0;
@@ -374,10 +667,31 @@ export function createRouter(store) {
374
667
  return { messages: expired };
375
668
  },
376
669
 
670
+ sweepTimedOutAssigns() {
671
+ const expiredAssigns = store.listAssigns({
672
+ statuses: Array.from(ASSIGN_PENDING_STATUSES),
673
+ active_before_ms: Date.now(),
674
+ limit: 100,
675
+ });
676
+ let timed_out = 0;
677
+ let retried = 0;
678
+
679
+ for (const job of expiredAssigns) {
680
+ const result = handleAssignTimeout(job);
681
+ timed_out += 1;
682
+ if (result?.data?.retried) retried += 1;
683
+ }
684
+
685
+ return { timed_out, retried };
686
+ },
687
+
377
688
  startSweeper() {
378
689
  if (sweepTimer) return;
379
690
  sweepTimer = setInterval(() => {
380
- try { router.sweepExpired(); } catch {}
691
+ try {
692
+ router.sweepExpired();
693
+ router.sweepTimedOutAssigns();
694
+ } catch {}
381
695
  }, 10000);
382
696
  staleTimer = setInterval(() => {
383
697
  try { store.sweepStaleAgents(); } catch {}
@@ -427,12 +741,19 @@ export function createRouter(store) {
427
741
  if (include_metrics) {
428
742
  const depths = router.getQueueDepths();
429
743
  const stats = router.getDeliveryStats();
744
+ const auditStats = store.getAuditStats();
430
745
  data.queues = {
431
746
  urgent_depth: depths.urgent,
432
747
  normal_depth: depths.normal,
433
748
  dlq_depth: depths.dlq,
434
749
  avg_delivery_ms: stats.avg_delivery_ms,
435
750
  };
751
+ data.assigns = {
752
+ queued: auditStats.assign_queued,
753
+ running: auditStats.assign_running,
754
+ failed: auditStats.assign_failed,
755
+ timed_out: auditStats.assign_timed_out,
756
+ };
436
757
  }
437
758
  }
438
759
 
package/hub/schema.sql CHANGED
@@ -76,13 +76,46 @@ CREATE INDEX IF NOT EXISTS idx_messages_priority ON messages(priority DESC, crea
76
76
  CREATE INDEX IF NOT EXISTS idx_inbox_agent ON message_inbox(agent_id, delivered_at_ms);
77
77
  CREATE INDEX IF NOT EXISTS idx_inbox_message ON message_inbox(message_id);
78
78
  CREATE INDEX IF NOT EXISTS idx_human_requests_state ON human_requests(state);
79
- CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status);
80
- CREATE INDEX IF NOT EXISTS idx_agents_lease ON agents(lease_expires_ms);
81
-
82
- -- 파이프라인 상태 테이블 (Phase 2)
83
- CREATE TABLE IF NOT EXISTS pipeline_state (
84
- team_name TEXT PRIMARY KEY,
85
- phase TEXT NOT NULL DEFAULT 'plan',
79
+ CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status);
80
+ CREATE INDEX IF NOT EXISTS idx_agents_lease ON agents(lease_expires_ms);
81
+
82
+ -- Assign Job 테이블
83
+ CREATE TABLE IF NOT EXISTS assign_jobs (
84
+ job_id TEXT PRIMARY KEY,
85
+ supervisor_agent TEXT NOT NULL,
86
+ worker_agent TEXT NOT NULL,
87
+ topic TEXT NOT NULL DEFAULT 'assign.job',
88
+ task TEXT NOT NULL DEFAULT '',
89
+ payload_json TEXT NOT NULL DEFAULT '{}',
90
+ status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','timed_out')),
91
+ attempt INTEGER NOT NULL DEFAULT 1,
92
+ retry_count INTEGER NOT NULL DEFAULT 0,
93
+ max_retries INTEGER NOT NULL DEFAULT 0,
94
+ priority INTEGER NOT NULL DEFAULT 5 CHECK (priority BETWEEN 1 AND 9),
95
+ ttl_ms INTEGER NOT NULL DEFAULT 600000,
96
+ timeout_ms INTEGER NOT NULL DEFAULT 600000,
97
+ deadline_ms INTEGER,
98
+ trace_id TEXT NOT NULL,
99
+ correlation_id TEXT NOT NULL,
100
+ last_message_id TEXT,
101
+ result_json TEXT,
102
+ error_json TEXT,
103
+ created_at_ms INTEGER NOT NULL,
104
+ updated_at_ms INTEGER NOT NULL,
105
+ started_at_ms INTEGER,
106
+ completed_at_ms INTEGER,
107
+ last_retry_at_ms INTEGER
108
+ );
109
+
110
+ CREATE INDEX IF NOT EXISTS idx_assign_jobs_status ON assign_jobs(status, updated_at_ms DESC);
111
+ CREATE INDEX IF NOT EXISTS idx_assign_jobs_supervisor ON assign_jobs(supervisor_agent, updated_at_ms DESC);
112
+ CREATE INDEX IF NOT EXISTS idx_assign_jobs_worker ON assign_jobs(worker_agent, updated_at_ms DESC);
113
+ CREATE INDEX IF NOT EXISTS idx_assign_jobs_deadline ON assign_jobs(deadline_ms, status);
114
+
115
+ -- 파이프라인 상태 테이블 (Phase 2)
116
+ CREATE TABLE IF NOT EXISTS pipeline_state (
117
+ team_name TEXT PRIMARY KEY,
118
+ phase TEXT NOT NULL DEFAULT 'plan',
86
119
  fix_attempt INTEGER DEFAULT 0,
87
120
  fix_max INTEGER DEFAULT 3,
88
121
  ralph_iteration INTEGER DEFAULT 0,