@triflux/core 10.41.1 → 10.43.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.
package/hub/router.mjs CHANGED
@@ -1,6 +1,13 @@
1
1
  // hub/router.mjs — 실시간 라우팅/수신함 상태 관리자
2
2
  // SQLite는 감사 로그만 담당하고, 실제 배달 상태는 메모리에서 관리한다.
3
3
  import { EventEmitter, once } from "node:events";
4
+ import {
5
+ clampDurationMs,
6
+ DEFAULT_ASSIGN_TIMEOUT_MS,
7
+ DEFAULT_ASSIGN_TTL_MS,
8
+ DEFAULT_HANDOFF_TTL_MS,
9
+ resolveHardCeilingMs,
10
+ } from "./lib/timeout-defaults.mjs";
4
11
  import { uuidv7 } from "./lib/uuidv7.mjs";
5
12
 
6
13
  const ASSIGN_PENDING_STATUSES = new Set(["queued", "running"]);
@@ -49,17 +56,6 @@ function rolePriority(metadata = {}, roleName = "") {
49
56
  return 0;
50
57
  }
51
58
 
52
- function clampAssignDuration(
53
- value,
54
- fallback = 600000,
55
- min = 1000,
56
- max = 86400000,
57
- ) {
58
- const num = Number(value);
59
- if (!Number.isFinite(num)) return fallback;
60
- return Math.max(min, Math.min(Math.trunc(num), max));
61
- }
62
-
63
59
  function normalizeAssignTerminalStatus(input, metadata = {}) {
64
60
  const status = String(input || "")
65
61
  .trim()
@@ -260,6 +256,26 @@ export function createRouter(store) {
260
256
  );
261
257
  }
262
258
 
259
+ function registerPresenceIsEffective(result) {
260
+ const effective = result?.effective;
261
+ return (
262
+ effective?.online === true &&
263
+ effective.status === "online" &&
264
+ Number(effective.lease_expires_ms) === Number(result?.lease_expires_ms)
265
+ );
266
+ }
267
+
268
+ function registerPresenceFailure(agentId, result) {
269
+ return {
270
+ ok: false,
271
+ error: {
272
+ code: "REGISTER_PRESENCE_NOT_UPDATED",
273
+ message: `${agentId} register did not update effective presence`,
274
+ },
275
+ data: { ...result, hub_pid: process.pid },
276
+ };
277
+ }
278
+
263
279
  function sortRoleCandidates(candidates = []) {
264
280
  return [...candidates].sort((left, right) => {
265
281
  const sourceDelta =
@@ -586,11 +602,31 @@ export function createRouter(store) {
586
602
  record.message.status = "delivered";
587
603
  store.updateMessageStatus(messageId, "delivered");
588
604
  recordLatency(delivery.delivered_at_ms - record.message.created_at_ms);
605
+ rearmAssignDeadlineOnDispatch(record.message, delivery.delivered_at_ms);
589
606
  return true;
590
607
  }
591
608
  return false;
592
609
  }
593
610
 
611
+ function rearmAssignDeadlineOnDispatch(message, deliveredAtMs) {
612
+ if (message?.payload?.kind !== "assign.job") return;
613
+ const jobId = message.payload.assign_job_id;
614
+ if (!jobId) return;
615
+ const job = store.getAssign(jobId);
616
+ if (
617
+ !job ||
618
+ !ASSIGN_PENDING_STATUSES.has(job.status) ||
619
+ job.dispatched_at_ms
620
+ )
621
+ return;
622
+ if (Number(message.payload.attempt) !== job.attempt) return;
623
+ store.updateAssignStatus(job.job_id, job.status, {
624
+ dispatched_at_ms: deliveredAtMs,
625
+ deadline_ms:
626
+ deliveredAtMs + clampDurationMs(job.timeout_ms, job.timeout_ms),
627
+ });
628
+ }
629
+
594
630
  function ackMessages(ids, agentId) {
595
631
  const now = Date.now();
596
632
  let count = 0;
@@ -686,7 +722,7 @@ export function createRouter(store) {
686
722
  to: job.supervisor_agent,
687
723
  topic: "assign.result",
688
724
  priority: Math.max(5, job.priority || 5),
689
- ttl_ms: job.ttl_ms || job.timeout_ms || 600000,
725
+ ttl_ms: job.ttl_ms || job.timeout_ms || DEFAULT_ASSIGN_TTL_MS,
690
726
  payload: {
691
727
  event,
692
728
  ...buildAssignSnapshot(job),
@@ -705,7 +741,7 @@ export function createRouter(store) {
705
741
  to: job.worker_agent,
706
742
  topic: job.topic || "assign.job",
707
743
  priority: job.priority || 5,
708
- ttl_ms: job.ttl_ms || job.timeout_ms || 600000,
744
+ ttl_ms: job.ttl_ms || job.timeout_ms || DEFAULT_ASSIGN_TTL_MS,
709
745
  payload: {
710
746
  kind: "assign.job",
711
747
  reason,
@@ -777,6 +813,24 @@ export function createRouter(store) {
777
813
  const timedOut = store.updateAssignStatus(job.job_id, "timed_out", {
778
814
  error: job.error ?? { message: "assign job timed out" },
779
815
  });
816
+ if (timedOut?.worker_agent) {
817
+ dispatchMessage({
818
+ type: "event",
819
+ from: "assign-router",
820
+ to: timedOut.worker_agent,
821
+ topic: "assign.cancel",
822
+ priority: 8,
823
+ ttl_ms: clampDurationMs(timedOut.ttl_ms, DEFAULT_ASSIGN_TTL_MS),
824
+ payload: {
825
+ kind: "assign.cancel",
826
+ assign_job_id: timedOut.job_id,
827
+ attempt: timedOut.attempt,
828
+ reason: "timed_out",
829
+ },
830
+ trace_id: timedOut.trace_id,
831
+ correlation_id: timedOut.correlation_id,
832
+ });
833
+ }
780
834
 
781
835
  if (timedOut.retry_count < timedOut.max_retries) {
782
836
  return scheduleAssignRetry(
@@ -802,6 +856,9 @@ export function createRouter(store) {
802
856
 
803
857
  registerAgent(args) {
804
858
  const result = store.registerAgent(args);
859
+ if (!registerPresenceIsEffective(result)) {
860
+ return registerPresenceFailure(args.agent_id, result);
861
+ }
805
862
  upsertRuntimeTopics(args.agent_id, args.topics || [], { replace: true });
806
863
  refreshRoleCandidateForAgent(args.agent_id);
807
864
  for (const roleName of ROLE_TOPICS) {
@@ -810,7 +867,7 @@ export function createRouter(store) {
810
867
  transferBacklog: true,
811
868
  });
812
869
  }
813
- return result;
870
+ return { ok: true, data: { ...result, hub_pid: process.pid } };
814
871
  },
815
872
 
816
873
  refreshAgentLease(agentId, ttlMs = 30000) {
@@ -1123,7 +1180,7 @@ export function createRouter(store) {
1123
1180
  acceptance_criteria,
1124
1181
  context_refs,
1125
1182
  priority = 5,
1126
- ttl_ms = 600000,
1183
+ ttl_ms = DEFAULT_HANDOFF_TTL_MS,
1127
1184
  trace_id,
1128
1185
  correlation_id,
1129
1186
  }) {
@@ -1151,8 +1208,8 @@ export function createRouter(store) {
1151
1208
  task = "",
1152
1209
  payload = {},
1153
1210
  priority = 5,
1154
- ttl_ms = 600000,
1155
- timeout_ms = 600000,
1211
+ ttl_ms = DEFAULT_ASSIGN_TTL_MS,
1212
+ timeout_ms = DEFAULT_ASSIGN_TIMEOUT_MS,
1156
1213
  max_retries = 0,
1157
1214
  trace_id,
1158
1215
  correlation_id,
@@ -1209,16 +1266,6 @@ export function createRouter(store) {
1209
1266
  },
1210
1267
  };
1211
1268
  }
1212
- if (Number.isFinite(Number(attempt)) && Number(attempt) !== job.attempt) {
1213
- return {
1214
- ok: false,
1215
- error: {
1216
- code: "ASSIGN_ATTEMPT_MISMATCH",
1217
- message: `stale assign result for attempt ${attempt} (current ${job.attempt})`,
1218
- },
1219
- };
1220
- }
1221
-
1222
1269
  const mergedMetadata = {
1223
1270
  ...(payload?.metadata || {}),
1224
1271
  ...(metadata || {}),
@@ -1232,11 +1279,37 @@ export function createRouter(store) {
1232
1279
  (Object.hasOwn(payload || {}, "result") ? payload.result : payload);
1233
1280
  const nextError = error ?? payload?.error ?? null;
1234
1281
 
1282
+ if (Number.isFinite(Number(attempt)) && Number(attempt) !== job.attempt) {
1283
+ const preserved = notifyAssignSupervisor(job, "late_result", {
1284
+ stale_attempt: Number(attempt),
1285
+ late_status: normalizedStatus,
1286
+ late_result: nextResult,
1287
+ late_error: nextError,
1288
+ reported_by: worker_agent || job.worker_agent,
1289
+ });
1290
+ return {
1291
+ ok: false,
1292
+ error: {
1293
+ code: "ASSIGN_ATTEMPT_MISMATCH",
1294
+ message: `stale assign result for attempt ${attempt} (current ${job.attempt})`,
1295
+ details: {
1296
+ preserved: Boolean(preserved),
1297
+ preserved_message_id: preserved?.id ?? null,
1298
+ },
1299
+ },
1300
+ };
1301
+ }
1302
+
1235
1303
  if (normalizedStatus === "running") {
1304
+ const nowMs = Date.now();
1305
+ const ceilingAtMs =
1306
+ (job.dispatched_at_ms || job.created_at_ms) + resolveHardCeilingMs();
1236
1307
  const running = store.updateAssignStatus(job.job_id, "running", {
1237
- started_at_ms: job.started_at_ms || Date.now(),
1238
- deadline_ms:
1239
- Date.now() + clampAssignDuration(job.timeout_ms, job.timeout_ms),
1308
+ started_at_ms: job.started_at_ms || nowMs,
1309
+ deadline_ms: Math.min(
1310
+ nowMs + clampDurationMs(job.timeout_ms, job.timeout_ms),
1311
+ ceilingAtMs,
1312
+ ),
1240
1313
  result: nextResult,
1241
1314
  error: nextError,
1242
1315
  });
@@ -1305,12 +1378,44 @@ export function createRouter(store) {
1305
1378
  sweepExpired() {
1306
1379
  const now = Date.now();
1307
1380
  let expired = 0;
1381
+ const deadLettered = [];
1308
1382
  for (const [messageId, record] of Array.from(liveMessages.entries())) {
1309
1383
  if (record.message.expires_at_ms > now) continue;
1310
1384
  store.moveToDeadLetter(messageId, "ttl_expired", null);
1385
+ deadLettered.push(record.message);
1311
1386
  removeMessage(messageId);
1312
1387
  expired += 1;
1313
1388
  }
1389
+ for (const message of deadLettered) {
1390
+ if (
1391
+ !["handoff", "request"].includes(message.type) ||
1392
+ message.payload?.kind === "assign.job"
1393
+ )
1394
+ continue;
1395
+ if (
1396
+ !message.from_agent ||
1397
+ String(message.from_agent).startsWith("topic:")
1398
+ )
1399
+ continue;
1400
+ dispatchMessage({
1401
+ type: "event",
1402
+ from: "hub-sweeper",
1403
+ to: message.from_agent,
1404
+ topic: "handoff.dead_letter",
1405
+ priority: 7,
1406
+ ttl_ms: 300000,
1407
+ payload: {
1408
+ kind: "handoff.dead_letter",
1409
+ message_id: message.id,
1410
+ original_topic: message.topic,
1411
+ original_to: message.to_agent,
1412
+ reason: "ttl_expired",
1413
+ task: message.payload?.task ?? null,
1414
+ },
1415
+ trace_id: message.trace_id,
1416
+ correlation_id: message.correlation_id,
1417
+ });
1418
+ }
1314
1419
  return { messages: expired };
1315
1420
  },
1316
1421
 
@@ -464,28 +464,41 @@ export function sendClaudeControlRequest(
464
464
 
465
465
  // claude daemon 은 control.sock 의 mutating op(dispatch 등)에 control-key
466
466
  // 인증을 강제한다 (미제시 시 EAUTH "didn't present the daemon control key").
467
- // key 는 <configDir>/daemon/control.key (configDir 스코프별). 파일이 없으면
468
- // 인증 미강제 daemon 으로 보고 auth 필드를 생략한다 (fail-open — 구버전 호환).
467
+ // OMC runtime config 는 .omc-launch-profile.json 의 sourceConfigDir 에서 key 를
468
+ // 발급한다. control.sock 은 runtime config hash 를 쓰더라도 auth 는 발급 config
469
+ // 에서 읽어야 한다. 일반 daemon 은 <configDir>/daemon/control.key 를 사용한다.
470
+ // 어느 경로에도 파일이 없으면 인증 미강제 daemon 으로 보고 auth 를 생략한다
471
+ // (fail-open — 구버전 호환).
469
472
  export async function readDaemonControlKey(
470
473
  configDir = resolveClaudeConfigDir(),
471
474
  { diagnostics } = {},
472
475
  ) {
473
476
  if (!configDir) return undefined;
474
- const keyPath = path.join(configDir, "daemon", "control.key");
475
- try {
476
- const key = await fs.readFile(keyPath, "utf8");
477
- return key.trim() || undefined;
478
- } catch (error) {
479
- if (error?.code === "ENOENT") return undefined;
480
- if (Array.isArray(diagnostics)) {
481
- diagnostics.push({
482
- code: error?.code || "UNKNOWN",
483
- path: keyPath,
484
- message: error?.message || String(error),
485
- });
477
+
478
+ const runtimeConfigDir = path.resolve(configDir);
479
+ const omcProfile = await readOmcLaunchProfile(runtimeConfigDir);
480
+ const keyConfigDirs = [omcProfile?.sourceConfigDir, runtimeConfigDir].filter(
481
+ (candidate, index, entries) =>
482
+ candidate && entries.indexOf(candidate) === index,
483
+ );
484
+
485
+ for (const keyConfigDir of keyConfigDirs) {
486
+ const keyPath = path.join(keyConfigDir, "daemon", "control.key");
487
+ try {
488
+ const key = await fs.readFile(keyPath, "utf8");
489
+ if (key.trim()) return key.trim();
490
+ } catch (error) {
491
+ if (error?.code === "ENOENT") continue;
492
+ if (Array.isArray(diagnostics)) {
493
+ diagnostics.push({
494
+ code: error?.code || "UNKNOWN",
495
+ path: keyPath,
496
+ message: error?.message || String(error),
497
+ });
498
+ }
486
499
  }
487
- return undefined;
488
500
  }
501
+ return undefined;
489
502
  }
490
503
 
491
504
  export async function buildDaemonControlAuth(configDir, opts = {}) {