/* * 규칙을 주기로 돌리는 타이머 하나. * * ── 왜 커널 틱이 아닌가 ────────────────────────────────────────────────── * `tickGuarded` 는 예외가 나면 **그 트윈을 멈춘다.** 규칙 하나가 잘못됐다고 공장이 멈추면 안 된다. * 그리고 굶김 감시가 느린 틱 셋에 트윈을 내리는데, 규칙은 DB 를 두 번 건드리므로 그 감시에 걸린다. * * ── 왜 이 간격인가 ─────────────────────────────────────────────────────── * 규칙이 보는 것은 **오래 이어진 조건**이다. 「병목이 세 시간째」를 1초마다 다시 물을 이유가 없다. * 짧게 잡으면 얻는 것 없이 DB 를 두 번씩 더 친다. * * ── 겹쳐 돌지 않는다 ───────────────────────────────────────────────────── * 한 바퀴가 간격보다 오래 걸리면 다음 발화가 겹친다. 겹치면 두 바퀴가 같은 근거 집합을 읽고 **같은 * 조치를 두 벌 낸다** — 중복을 막는 장치가 그 순간 통과된다. */ import { MES_COMMANDS } from '@operato/ops-contract' import { twinError, twinLog } from '../../engine/log.js' import { TwinEngine } from '../../engine/twin-engine.js' import { capabilitiesOf, getAdapter } from '../reference/reference-adapter.js' import { actuationReadinessOf, actuationTargetOf, routeActuation } from '../reference/actuation-routing.js' import { sweepPendingDispatch, type SweepReport } from './sweep-dispatch.js' import { catchUpEffects } from './catch-up-effects.js' import { databaseCommandStore } from './command-store.js' import { runRulesOnce } from './rule-runner.js' import { attentionsOf, modeOf, noteRun, rulesOf } from './rule-wiring.js' /** 몇 초마다 도나. 오래 이어진 조건을 보는 일이라 촘촘할 이유가 없다. */ export const RULE_PERIOD_MS = 30_000 let timer: any let running = false /** 주기를 세운다 — 두 번 불러도 하나만 선다. */ export function startActuationRuleLoop(periodMs = RULE_PERIOD_MS): void { if (timer) return timer = setInterval(() => { /* 앞 바퀴가 아직 돈다 — 겹치면 같은 조치를 두 벌 낸다. 건너뛰는 것이 맞다. */ if (running) return running = true tickRules() .catch(err => twinError(`[twin-rule] 규칙 주기가 실패했다 — ${err?.message ?? err}`)) .finally(() => { running = false }) }, periodMs) /* 이 타이머 때문에 프로세스가 안 죽지 않게. */ if (typeof timer?.unref === 'function') timer.unref() } export function stopActuationRuleLoop(): void { if (!timer) return clearInterval(timer) timer = undefined } /** * 일꾼이 한 바퀴에 무엇을 했나 — **바뀌었을 때만** 적는다. * * ── 왜 필요한가 (2026-09-04) ──────────────────────────────────────────────── * 처음에 보고를 받고 버렸다. 그러니 대기 줄에 조치가 앉아 있는데 아무 일도 안 일어날 때 * **이유를 알 길이 없었다** — 실제로 그 상태를 만나 30분을 뒤졌다. 집지 않는 것은 대개 옳은 * 판단이지만, 옳은 판단도 보이지 않으면 결함과 구별되지 않는다. * * ── 왜 바뀌었을 때만인가 ──────────────────────────────────────────────────── * 30초마다 같은 줄을 적으면 하루 2,880줄이고 그 사이의 진짜 변화가 묻힌다. 대기 줄의 모양이 * 그대로면 새로 말할 것이 없다 — `noteBlocked` 에서 지킨 것과 같은 규율이다. */ const lastSweep = new Map() function noteSweep(domainId: string, report: SweepReport): void { if (!report.seen) { lastSweep.delete(domainId) return } const skips = Object.entries(report.skipped) .sort(([a], [b]) => a.localeCompare(b)) .map(([why, n]) => `${why} ${n}`) .join(' · ') const line = `${report.seen}건 대기 · 넘김 ${report.dispatched}${skips ? ` · ${skips}` : ''}` if (lastSweep.get(domainId) === line) return lastSweep.set(domainId, line) twinLog(`[twin-sweep] ${line}`) } async function tickRules(): Promise { /* * ── 유입 창을 먼저 굴린다 (2026-09-05) ───────────────────────────────────── * 창이 닫히는 이유는 **시간이 지난 것**이다. 그런데 예전에는 「다음 사건이 왔을 때」와 「누가 * 물어볼 때」만 굴렀다 — 둘 다 안 올 수 있다. * * 그래서 사실이 드문 트윈은 표에 아무것도 안 남았다. 실측: 46건이 62분에 흩어져 온 트윈의 창이 * 1개, 사실 11건뿐인 트윈의 창이 48개 — **사실 수와 창 수가 거꾸로**였다. 창을 만든 것이 유입이 * 아니라 누가 물어본 것이었기 때문이다. * * **아무도 안 보는 트윈일수록 기록이 없었다.** 사실이 조용히 사라지는 것을 잡으려는 장부가 정확히 * 그 반대로 돌고 있었다(137봉투가 장부 없이 지나갔다 — 인티그레이션 레인 실측). * * 소유한 트윈이 없어도 굴린다 — 장부는 프로세스가 사는 동안 이어지고, 규칙을 돌 트윈이 없다는 * 것과 기록할 창이 없다는 것은 다른 사실이다. */ const rolled = TwinEngine.rollAllIngestWindows() if (rolled) twinLog(`[twin-ingest] 창 ${rolled}개를 닫아 적었다`) const owned = TwinEngine.ownedTwins() if (!owned.length) return /* * 트랜잭션을 넘기지 않는다 — 여러 트윈을 돌고, 한 트윈이 실패해도 앞의 트윈이 낸 것은 남아야 한다. * 한 덩어리로 묶으면 마지막 트윈의 실패가 앞의 결과를 전부 물린다. */ /* * **놓친 보고를 함께 backfill 한다** — 관측 → 조치의 반대 방향이다. * * 같은 주기를 쓰는 이유는 값이다: 둘 다 몇 초마다 볼 일이고, 타이머를 둘 두면 내리고 세울 자리가 * 둘이 된다. 다만 **서로 막지 않는다** — 규칙이 터져도 backfill 은 돌아야 한다. 조치를 못 내는 것과 * 나간 조치의 결과를 모르는 것은 다른 사고다. */ for (const domainId of new Set(owned.map(t => t.domainId))) { await catchUpEffects(domainId, databaseCommandStore()) /* * **넘길 차례를 기다리는 것들도 함께 줍는다** — 승인만 되고 안 나간 것, 실패해서 다시 해 볼 것. * * 정상 경로는 승인 직후에 이미 끝난다(§`dispatchAfterApproval`, 실측 282ms). 이 자리는 **그 * 사이에 프로세스가 죽은 경우**와 재시도만 본다. 그래서 30초로 충분하고 아무도 안 기다린다. */ const swept = await sweepPendingDispatch(domainId, { store: databaseCommandStore(), actuate: command => routeActuation(domainId, command.instanceId, command), idempotent: async instanceId => { const target = await actuationTargetOf(domainId, instanceId).catch(() => undefined) const type = (target as any)?.adapterType return type ? capabilitiesOf(getAdapter(type)).includes('idempotent-actuation') : false }, localReadiness: async instanceId => { const r = await actuationReadinessOf(domainId, instanceId).catch(() => undefined) /* 원격으로 물어 본 답은 쓰지 않는다 — 이 자리는 저쪽을 건드리지 않는 것이 규약이다. */ return r && (r as any).checked === 'local' ? (r as any) : undefined } }) noteSweep(domainId, swept) } await runRulesOnce({ ownedTwins: () => owned, rulesOf, attentionsOf, modeOf, store: databaseCommandStore(), specs: MES_COMMANDS, nowMs: () => Date.now(), noteRun }) }