@triflux/core 10.42.0 → 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()
@@ -606,11 +602,31 @@ export function createRouter(store) {
606
602
  record.message.status = "delivered";
607
603
  store.updateMessageStatus(messageId, "delivered");
608
604
  recordLatency(delivery.delivered_at_ms - record.message.created_at_ms);
605
+ rearmAssignDeadlineOnDispatch(record.message, delivery.delivered_at_ms);
609
606
  return true;
610
607
  }
611
608
  return false;
612
609
  }
613
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
+
614
630
  function ackMessages(ids, agentId) {
615
631
  const now = Date.now();
616
632
  let count = 0;
@@ -706,7 +722,7 @@ export function createRouter(store) {
706
722
  to: job.supervisor_agent,
707
723
  topic: "assign.result",
708
724
  priority: Math.max(5, job.priority || 5),
709
- ttl_ms: job.ttl_ms || job.timeout_ms || 600000,
725
+ ttl_ms: job.ttl_ms || job.timeout_ms || DEFAULT_ASSIGN_TTL_MS,
710
726
  payload: {
711
727
  event,
712
728
  ...buildAssignSnapshot(job),
@@ -725,7 +741,7 @@ export function createRouter(store) {
725
741
  to: job.worker_agent,
726
742
  topic: job.topic || "assign.job",
727
743
  priority: job.priority || 5,
728
- ttl_ms: job.ttl_ms || job.timeout_ms || 600000,
744
+ ttl_ms: job.ttl_ms || job.timeout_ms || DEFAULT_ASSIGN_TTL_MS,
729
745
  payload: {
730
746
  kind: "assign.job",
731
747
  reason,
@@ -797,6 +813,24 @@ export function createRouter(store) {
797
813
  const timedOut = store.updateAssignStatus(job.job_id, "timed_out", {
798
814
  error: job.error ?? { message: "assign job timed out" },
799
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
+ }
800
834
 
801
835
  if (timedOut.retry_count < timedOut.max_retries) {
802
836
  return scheduleAssignRetry(
@@ -1146,7 +1180,7 @@ export function createRouter(store) {
1146
1180
  acceptance_criteria,
1147
1181
  context_refs,
1148
1182
  priority = 5,
1149
- ttl_ms = 600000,
1183
+ ttl_ms = DEFAULT_HANDOFF_TTL_MS,
1150
1184
  trace_id,
1151
1185
  correlation_id,
1152
1186
  }) {
@@ -1174,8 +1208,8 @@ export function createRouter(store) {
1174
1208
  task = "",
1175
1209
  payload = {},
1176
1210
  priority = 5,
1177
- ttl_ms = 600000,
1178
- timeout_ms = 600000,
1211
+ ttl_ms = DEFAULT_ASSIGN_TTL_MS,
1212
+ timeout_ms = DEFAULT_ASSIGN_TIMEOUT_MS,
1179
1213
  max_retries = 0,
1180
1214
  trace_id,
1181
1215
  correlation_id,
@@ -1232,16 +1266,6 @@ export function createRouter(store) {
1232
1266
  },
1233
1267
  };
1234
1268
  }
1235
- if (Number.isFinite(Number(attempt)) && Number(attempt) !== job.attempt) {
1236
- return {
1237
- ok: false,
1238
- error: {
1239
- code: "ASSIGN_ATTEMPT_MISMATCH",
1240
- message: `stale assign result for attempt ${attempt} (current ${job.attempt})`,
1241
- },
1242
- };
1243
- }
1244
-
1245
1269
  const mergedMetadata = {
1246
1270
  ...(payload?.metadata || {}),
1247
1271
  ...(metadata || {}),
@@ -1255,11 +1279,37 @@ export function createRouter(store) {
1255
1279
  (Object.hasOwn(payload || {}, "result") ? payload.result : payload);
1256
1280
  const nextError = error ?? payload?.error ?? null;
1257
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
+
1258
1303
  if (normalizedStatus === "running") {
1304
+ const nowMs = Date.now();
1305
+ const ceilingAtMs =
1306
+ (job.dispatched_at_ms || job.created_at_ms) + resolveHardCeilingMs();
1259
1307
  const running = store.updateAssignStatus(job.job_id, "running", {
1260
- started_at_ms: job.started_at_ms || Date.now(),
1261
- deadline_ms:
1262
- 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
+ ),
1263
1313
  result: nextResult,
1264
1314
  error: nextError,
1265
1315
  });
@@ -1328,12 +1378,44 @@ export function createRouter(store) {
1328
1378
  sweepExpired() {
1329
1379
  const now = Date.now();
1330
1380
  let expired = 0;
1381
+ const deadLettered = [];
1331
1382
  for (const [messageId, record] of Array.from(liveMessages.entries())) {
1332
1383
  if (record.message.expires_at_ms > now) continue;
1333
1384
  store.moveToDeadLetter(messageId, "ttl_expired", null);
1385
+ deadLettered.push(record.message);
1334
1386
  removeMessage(messageId);
1335
1387
  expired += 1;
1336
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
+ }
1337
1419
  return { messages: expired };
1338
1420
  },
1339
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 = {}) {