/* * **규칙을 주기로 돈다** — 판정을 저장소에 잇는 자리. * * ── 왜 틱이 아닌가 (2026-09-01) ────────────────────────────────────────── * 커널 틱 안에 넣으려다 `tickGuarded` 를 읽고 접었다. 그 자리는 **예외가 나면 트윈을 멈추고**, 굶김 * 감시가 느린 틱 셋에 트윈을 내린다. 규칙 판정은 DB 를 두 번 건드리므로 그 감시에 정확히 걸린다. * * 그리고 필요하지도 않다. 「병목이 세 시간째」를 1초마다 다시 물을 이유가 없다. * * ── 규칙은 결재선에 올리지 않는다 ──────────────────────────────────────── * 이것이 이 파일의 가장 중요한 규율이다. * * 업무 목록의 결재선은 **올린 사람 기준**으로 풀린다 — `Myself` · `MyDepartment` · `MySupervisor` 가 * 전부 `context.state.user` 를 본다(§`getSystemUserFromOrgMemberItem`). 배경 루프에는 「나」가 없다. * 시스템 사용자를 지어내 넘기면 그 셋이 **시스템 사용자를 가리키게 되고**, 결재선이 뜻을 잃는다. * * 그래서 규칙은 **내는 것까지** 한다. 결재선에 올리는 것은 사람이 한다(§`raiseTwinCommand`) — 그 사람이 * 「이것을 승인자들 앞에 놓았다」의 주체가 되고, 그 사람 기준으로 결재선이 풀린다. * * 올리기 전까지 그 조치는 나가지 못한다. 게이트는 그대로 서 있다. */ import type { CommandTypeSpec } from '@operato/ops-contract' import { twinError, twinWarn } from '../../engine/log.js' import { propose, type CommandStore } from './command-dispatcher.js' import { evaluateRules, type ActuationRuleSpec, type AttentionLike, type RuleSkip } from './rule-evaluate.js' /** 이 프로세스가 든 트윈 하나. */ export interface OwnedTwin { domainId: string instanceId: string } /** 한 트윈을 돌고 나서 남는 사실. */ export interface TwinRunReport { domainId: string instanceId: string /** 실제로 적힌 커맨드 수. */ proposed: number /** 규칙마다 몇 개를 냈나 — 규칙 행에 누계를 적으려면 나누어 세어야 한다. */ proposedByRule: Record /** 왜 안 올렸나 — 규칙이 조용히 안 도는 것과 조건이 없어서 안 내는 것을 구별한다. */ skips: RuleSkip[] /** 상한에 닿아 두고 온 수. */ withheld?: number /** 이 트윈을 아예 못 돌았으면 그 이유. */ failed?: string } /** 밖에서 받는 것들 — 저장소도 엔진도 시계도 인자다. 그래야 시험이 재기동과 상한을 걸을 수 있다. */ export interface RuleRunnerDeps { /** 이 프로세스가 든 트윈들. 남의 프로세스가 든 트윈은 그쪽이 돈다. */ ownedTwins(): OwnedTwin[] /** 이 트윈에 걸린 규칙 — 없으면 스냅샷을 읽지 않는다. */ rulesOf(twin: OwnedTwin): Promise /** 지금 서 있는 주의들. */ attentionsOf(twin: OwnedTwin): AttentionLike[] | undefined /** * 이 트윈이 **무엇을 비추고 있나** — 관측(`live`)인가 가정(`sim`)인가. * * 이 값이 없거나 `live` 가 아니면 조치를 내지 않는다(§`runOneTwin`). 모름을 통과로 읽지 않는다. */ modeOf(twin: OwnedTwin): 'live' | 'sim' | undefined /** 커맨드가 사는 자리. */ store: CommandStore /** 선언된 조치 종류 — 없는 종류를 가리키는 규칙은 판정이 걸러 낸다. */ specs: readonly CommandTypeSpec[] nowMs(): number /** 돈 결과를 규칙 행에 남긴다 — 화면이 「이 규칙이 돌고 있나」를 말할 수 있게. */ noteRun?(twin: OwnedTwin, report: TwinRunReport): Promise maxPerRun?: number } /** * 한 바퀴 돈다 — **한 트윈이 실패해도 나머지는 돈다.** * * 트윈 하나의 규칙이 잘못됐다고 다른 공장의 규칙이 멈추면, 고치는 동안 전체가 멈춘 셈이 된다. */ export async function runRulesOnce(deps: RuleRunnerDeps): Promise { const out: TwinRunReport[] = [] for (const twin of deps.ownedTwins()) { const report: TwinRunReport = { ...twin, proposed: 0, proposedByRule: {}, skips: [] } try { Object.assign(report, await runOneTwin(twin, deps)) } catch (e: any) { report.failed = String(e?.message ?? e) twinError(`[twin-rule] "${twin.instanceId}" 규칙을 돌지 못했다 — ${report.failed}`) } out.push(report) if (deps.noteRun) { await deps.noteRun(twin, report).catch(err => twinWarn(`[twin-rule] "${twin.instanceId}" 규칙 기록을 남기지 못했다 — ${err?.message ?? err}`) ) } } return out } async function runOneTwin(twin: OwnedTwin, deps: RuleRunnerDeps): Promise> { const rules = await deps.rulesOf(twin) /* 규칙이 없으면 스냅샷도 읽지 않는다 — 아무것도 안 할 일에 값을 만들지 않는다. */ if (!rules.length) return {} /* * **이미 올라가 있는 근거를 저장소에서 읽는다.** * * 저장소가 그 길을 내주지 않으면 **판정하지 않는다.** 빈 집합을 만들어 주면 매 주기 같은 조건이 다시 * 올라가 대기 줄에 같은 지시가 쌓인다 — 그것을 조용히 하지 않는다. */ if (!deps.store.openCauses) { return { skips: [ { ruleId: '(전체)', reason: '기존 조치 요청의 근거를 조회할 수 없어 규칙을 평가하지 않았습니다. 중복 요청을 방지하려면 ' + 'CommandStore.openCauses를 구현해야 합니다.' } ] } } /* * ── **시늉된 조건에서 현장 지시를 내지 않는다** (2026-09-01) ──────────── * * 시뮬 트윈의 상태는 **가정**이다. 커널이 스스로 물품을 옮겨 만든 병목이고, 현장에서 관측된 것이 * 아니다. 그 위에서 생산 지시를 내면 **지어낸 조건이 실제 지시가 된다** — 오늘 자극과 조치를 갈라 * 놓은 것과 같은 부류의 잘못이고, 이쪽이 더 나쁘다(자극은 모델 안에서 끝나지만 이것은 현장으로 나간다). * * 실제로 그렇게 걸었다. 오늘 규칙을 확인한 트윈이 전부 `sim` 이었고, 시늉된 병목에서 생산 지시가 * 올라왔다. 배선은 옳게 돌았지만 근거는 가정이었다. * * 예측을 근거로 미리 지시를 내는 것 자체는 있을 수 있는 일이다. 다만 그것은 **선언해서** 할 일이고, * 승인하는 사람이 「이것은 예측이다」를 알아야 한다. 그 선언이 아직 없으므로 지금은 막고, 왜 막았는지 * 말한다 — 그 문장이 필요해진 사람이 무엇을 요청해야 하는지 알려 준다. */ const mode = deps.modeOf(twin) if (mode !== 'live') { return { skips: [ { ruleId: '(전체)', reason: mode === 'sim' ? '이 트윈은 시뮬레이션 모드이므로 현장 지시를 생성하지 않습니다. ' + '시뮬레이션·예측을 근거로 한 현장 지시는 현재 지원하지 않습니다.' : '트윈의 실행 모드를 확인할 수 없어 실제 관측에 근거한 현장 지시를 생성할 수 없습니다.' } ] } } const attentions = deps.attentionsOf(twin) if (attentions === undefined) { /* * 스냅샷이 없다 — 멈춘 트윈이거나 아직 안 선 트윈이다. **주의가 0건인 것과 다르다.** 0건으로 읽으면 * 「조건이 하나도 없다」가 되고, 규칙이 도는 줄로 보인다. */ return { skips: [{ ruleId: '(전체)', reason: '트윈의 상태를 조회할 수 없습니다. 실행이 중지되었거나 아직 시작되지 않았을 수 있습니다.' }] } } const openCauses = await deps.store.openCauses(twin.domainId, twin.instanceId) /* 없으면 넘기지 않는다 — 판정이 「받지 못했다」와 「낸 적이 없다」를 구별해야 한다. */ const lastProposedAt = await deps.store.lastProposedAt?.(twin.domainId, twin.instanceId) const verdict = evaluateRules({ rules, attentions, specs: deps.specs, openCauses, ...(lastProposedAt ? { lastProposedAt } : {}), nowMs: deps.nowMs(), ...(deps.maxPerRun ? { maxPerRun: deps.maxPerRun } : {}) }) const skips = [...verdict.skips] const proposedByRule: Record = {} let proposed = 0 for (const p of verdict.proposals) { try { await propose(deps.store, twin.domainId, deps.specs, { id: newCommandId(), instanceId: twin.instanceId, type: p.commandType, /* 사람이 아니다. 그래서 낸 사람이 없고, 아무 승인자든 두 번째 사람이 된다. */ origin: 'rule', proposedAt: new Date(deps.nowMs()).toISOString(), payload: p.payload, causeRef: p.attentionId, ruleRef: p.ruleId }) proposed++ proposedByRule[p.ruleId] = (proposedByRule[p.ruleId] ?? 0) + 1 } catch (e: any) { /* * 하나가 거절돼도 나머지는 낸다. 그리고 **거절을 삼키지 않는다** — 규칙이 무언가를 내려 했는데 * 못 냈다는 사실이 사라지면, 사람은 그 조건에 규칙이 안 걸린 줄로 읽는다. */ skips.push({ ruleId: p.ruleId, attentionId: p.attentionId, reason: String(e?.message ?? e) }) } } return { proposed, proposedByRule, skips, ...(verdict.withheld ? { withheld: verdict.withheld } : {}) } } /** 식별자 — 이 파일이 만든다. 저장소가 만들면 커맨드를 적기 전에 근거를 붙일 수 없다. */ function newCommandId(): string { return globalThis.crypto.randomUUID() }