/* * **승인 다음에 넘긴다** — 승인 도장과 실제 발신 사이의 한 걸음. * * ── 무엇이 없었나 (2026-09-04) ────────────────────────────────────────────── * 사람이 결재함에서 승인하면 커맨드가 `approved` 로 앉고 **거기서 멈췄다.** 그것을 집어 현장에 * 보내는 것이 아무것도 없었다 — 규칙 타이머는 조치를 *내는* 일만 하고, 화면에는 넘기기 단추가 * 없다. 폐루프를 라이브로 확인한 것도 사람이 뮤테이션을 손으로 불러서였다. * * 그래서 승인은 아무 데도 닿지 않았고, 현장은 오지 않는 지시를 기다렸다. 오류는 나지 않는다. * * ── 왜 승인 트랜잭션 안에서 보내지 않나 ───────────────────────────────────── * 승인은 업무 목록의 트랜잭션 안에서 일어난다. 그 안에서 현장에 요청을 쏘면 두 가지가 깨진다. * * ① 롤백이 되돌리지 못한다. 공장에는 작업지시가 생겼는데 우리 장부는 승인을 취소한 상태가 된다. * **네트워크로 나간 것은 트랜잭션이 못 되돌린다.** * ② 트랜잭션이 원격 응답을 기다리는 동안 열려 있다. 저쪽이 느리면 결재 화면이 함께 멈춘다. * * 그래서 **커밋 다음으로 미룬다.** 미루면 「미룬 사이에 롤백됐으면?」이 남는데, 그것은 * `dispatch` 가 스스로 막는다 — 새 연결로 커맨드를 **다시 읽고** 상태를 확인한다. 롤백됐으면 * 승인 기록이 없어 그 자리에서 던진다(§`asApproved`). 우리가 따로 검사할 것이 없다. * * ── 정직한 한계 ───────────────────────────────────────────────────────────── * 커밋과 발신 사이에 프로세스가 죽으면 그 커맨드는 `approved` 로 남는다. **잃지는 않지만 스스로 * 나가지도 않는다.** 그것을 사람이 볼 자리(「넘기지 못한 조치」 목록)와 다시 넘기는 길이 아직 * 없다 — 이 파일이 그 빈 자리를 메우지 않는다는 것을 여기 적어 둔다. * * 그리고 이 미룸은 **한 프로세스 안의 약속**이다. 여러 대로 늘리면 승인을 받은 프로세스가 * 보내는 것이 되고, 그 프로세스가 죽으면 위와 같다. 그때는 표를 보고 집어 가는 일꾼이 필요하다. */ import { twinWarn } from '../../engine/log.js' import { databaseCommandStore } from './command-store.js' import { dispatch } from './command-dispatcher.js' import { routeActuation } from '../reference/actuation-routing.js' /** * 승인이 확정된 조치를 **커밋 뒤에** 현장으로 넘긴다. * * 부르는 쪽은 기다리지 않는다(`void`). 결재의 성공은 넘김의 성공에 달려 있지 않다 — 어댑터가 * 죽어 있어도 승인은 남아 있어야 하고, 그것이 승인과 넘김을 가른 이유다. * * **던지지 않는다.** 이 함수는 요청 밖에서 돌기 때문에, 던지면 처리되지 않은 거절이 되어 * 프로세스를 내린다. 넘기다 생긴 실패는 `dispatch` 가 `failed` 로 앉히고, 그 위의 예외(상태가 * 맞지 않음·커맨드가 사라짐)만 여기서 받아 적는다. */ export function dispatchAfterApproval(domainId: string, commandId: string): void { /* * `setImmediate` 로 미룬다 — 지금 열린 트랜잭션이 커밋될 기회를 준다. * * 이것이 커밋을 **보장하지는 않는다.** 보장은 `dispatch` 의 다시 읽기가 한다(머리말). 여기서 * 미루는 것은 「대개 커밋된 뒤에 읽게 하려는」 것이고, 아니면 다시 읽기가 거절한다. */ setImmediate(() => { void (async () => { try { const settled = await dispatch(databaseCommandStore(), domainId, commandId, command => routeActuation(domainId, command.instanceId, command) ) if (settled.state === 'failed') { /* 실패는 결함이 아니다 — 저쪽이 안 받았거나 설정이 비었다는 사실이고, 행에 남는다. */ twinWarn(`[twin-actuation] 조치 ${commandId} 를 넘기지 못했다: ${settled.error ?? '(사유 없음)'}`) } } catch (e: any) { /* * 여기 오는 것은 「넘길 수 없는 상태」다. 가장 흔한 원인은 **승인 트랜잭션이 롤백된 것**이고, * 그때는 넘기지 않는 것이 맞다. 조용히 지나가지 않고 적는다 — 다른 원인이면 그것이 결함이다. */ twinWarn(`[twin-actuation] 조치 ${commandId} 를 넘기지 않았다: ${String(e?.message ?? e)}`) } })() }) }