diff --git a/docs/role-instructions.md b/docs/role-instructions.md index 6397d011..e8f7d845 100644 --- a/docs/role-instructions.md +++ b/docs/role-instructions.md @@ -141,6 +141,7 @@ Why: 감독 창이 같은 규칙 전문의 반복으로 차서 압축이 잦았 | 시험 프로필(conductor)이 아님 | `profile-not-in-trial` | | 업무·카드 사실을 못 읽음, 판 판별 근거가 없거나 어긋남 | `facts-unavailable` | | 레포 kadan 판이 아님(예: 다른 레포 감독) | `board-not-in-trial` | +| 이 세대 마지막 전문 시도 뒤에 이어 연 start가 있음(아래 목록) | `resumed-generation` | | 현재 발령(`--task`) | `dispatch` | | 이 세대에 전문이 없음(첫 안내·새 대화 첫 우편 포함) | `generation-first` | | 이 세대 마지막 전문이 `keyDelivery !== 'sent'` | `delivery-unclear` | @@ -148,6 +149,7 @@ Why: 감독 창이 같은 규칙 전문의 반복으로 차서 압축이 잦았 | 마지막 전달 성공 전문 뒤 1시간 이상 경과(정확히 60분 포함) | `interval` | | 위에 해당 없음 | 요약(`within-interval`) | +- 이어 연 start는 세대 경계가 아니라서(우편 세대 그대로) 압축된 대화가 규칙을 잊었을 수 있는 자리만 따로 센다. 센다: `restartOf`, `conversationMode==='resume'`, `--inherit-mail`(`inheritMail`), `restoredFrom`, `secretaryResumeSource`. 안 센다: `sameProcess`(같은 pane PID로 다시 기록된 살아 있는 세션 재사용, 새 프로세스도 대화 재로드도 없음). new·fork는 이미 새 세대라 `generation-first`다. 전문이 한 번 시도되면(`sent`면 풀리고, 실패면 `delivery-unclear`) `resumed-generation`은 끝난다. 이유 우선순위는 resumed-generation → dispatch → generation-first → 나머지 순이며 어느 쪽이든 전문이다. - 판 판별은 이름(`boardFromRole`)과 레포(연결 업무·카드의 `repo`, 없으면 start 사건 cwd의 `~/Dev/<레포>`)가 둘 다 kadan일 때만 인정한다. 둘이 어긋나거나 근거가 없으면 `facts-unavailable`이다. - '전달 성공'은 `keyDelivery === 'sent'`만이다. 세대는 `mail-generation.mjs`의 start 경계를 그대로 쓴다. raw 전송은 `instructions`가 없어 건너뛴다. - 실제 바닥은 전송 실패를 영수증이 아니라 예외로 던진다. 전문 시도가 던져지면(`not-sent`·`unknown`) 원장에 별도 kind `send-failed`(시스템 영역, `mailId` 없음, `keyDelivery`·`instructions`만)를 남기고 원래 오류를 다시 던진다. `send`가 아니라서 발령·우편함·감시 재안내·디코 중복 방지(`findMailBySourceKeyAny`)가 전달로 보지 않는다. 그 세대의 마지막 전문 시도가 `sent`가 아니면 다음 우편은 `delivery-unclear`이고, 전문이 한 번 `sent`로 가면 풀린다. raw·요약 시도의 실패는 남기지 않으며 기록 실패가 원래 오류를 가리지 않는다. 자동 재전송은 없다. diff --git a/src/mail-generation.mjs b/src/mail-generation.mjs index b90845b0..dd47f588 100644 --- a/src/mail-generation.mjs +++ b/src/mail-generation.mjs @@ -9,7 +9,7 @@ const pidOf = e => e.panePid ?? e.rottiePid; export const conversationModeOf = e => e.conversationMode ?? conversationOf(e.cmd).conversationMode; export function createMailGenerations() { - const live = new Map(), seen = new Set(), bounds = new Map(), boundTimes = new Map(); + const live = new Map(), seen = new Set(), bounds = new Map(), boundTimes = new Map(), resumes = new Map(); const apply = (e, index) => { if (e?.kind === 'stop') { live.delete(e.session); return; } if (e?.kind !== 'start') return; @@ -22,6 +22,10 @@ export function createMailGenerations() { // 그 앞 세대의 경계는 그대로라 두 세대 이전 우편은 끊긴 채 남는다. new·fork는 새 대화라 경계다. const continues = sameProcess || e.inheritMail === true || e.restoredFrom || e.secretaryResumeSource || e.restartOf || conversationModeOf(e) === 'resume'; + // 이어 연 start(압축된 대화를 다시 연 경우): 세대 경계는 아니지만 지침 전문을 새로 보내야 할 자리다. + // sameProcess(살아 있는 세션 재사용)는 새 프로세스도 대화 재로드도 없으므로 뺀다. + if (!sameProcess && (e.inheritMail === true || e.restoredFrom || e.secretaryResumeSource || e.restartOf + || conversationModeOf(e) === 'resume')) resumes.set(role, [...(resumes.get(role) ?? []), index]); if (!continues && seen.has(role)) { bounds.set(role, [...(bounds.get(role) ?? []), index]); boundTimes.set(role, [...(boundTimes.get(role) ?? []), Date.parse(e.t)]); @@ -36,7 +40,7 @@ export function createMailGenerations() { const ms = typeof at === 'number' ? at : Date.parse(at); return Number.isFinite(ms) && (boundTimes.get(role) ?? []).some(bound => bound > ms); }; - return {apply, isPrevious, isPreviousAt, hasStarted: role => seen.has(role), generationOf: role => (bounds.get(role) ?? []).length}; + return {apply, isPrevious, isPreviousAt, hasStarted: role => seen.has(role), resumedAfter: (role, at) => (resumes.get(role) ?? []).some(index => index > at), generationOf: role => (bounds.get(role) ?? []).length}; } // 이전 세대 미확인: 읽음 미확인이면서 기록용 알림이 아니고 새 세대가 시작되기 전의 편지. diff --git a/src/role-instructions.mjs b/src/role-instructions.mjs index 6c4375bb..28e7dcb0 100644 --- a/src/role-instructions.mjs +++ b/src/role-instructions.mjs @@ -218,9 +218,11 @@ function decideInstructionsMode({role,profile,templateDigest,entries,current,una const board=trialBoard(role,repos); if (board==='unknown') return full('facts-unavailable'); if (board==='other') return full('board-not-in-trial'); + const fulls=entries.map((e,i)=>({e,i})).filter(({e,i})=>(e.kind==='send'||e.kind==='send-failed')&&e.role===role&&e.instructions?.mode==='full'&&!gens.isPrevious(role,i)); + const lastAt=fulls.at(-1),last=lastAt?.e; + // 이어 연 start(restart·resume·inherit 등)가 마지막 전문 시도보다 늦으면 압축 뒤 규칙 기억을 모르므로 전문을 다시 보낸다. + if (last&&gens.resumedAfter(role,lastAt.i)) return full('resumed-generation'); if (current) return full('dispatch'); - const fulls=entries.map((e,i)=>({e,i})).filter(({e,i})=>(e.kind==='send'||e.kind==='send-failed')&&e.role===role&&e.instructions?.mode==='full'&&!gens.isPrevious(role,i)).map(({e})=>e); - const last=fulls.at(-1); if (!last) return full('generation-first'); if (last.keyDelivery!=='sent') return full('delivery-unclear'); if (last.instructions.profile!==profile) return full('profile-changed'); diff --git a/tests/role-instructions-slim.test.mjs b/tests/role-instructions-slim.test.mjs index a4f5a1bd..d58a0595 100644 --- a/tests/role-instructions-slim.test.mjs +++ b/tests/role-instructions-slim.test.mjs @@ -8,6 +8,7 @@ import {composeRoleInstructions,FULL_INTERVAL_MS} from '../src/role-instructions import {guardedSend} from '../src/cli.mjs'; import {readLedger,readMailLedger,readTaskLedger,findMailBySourceKeyAny} from '../src/ledger.mjs'; import {Mailbox} from '../src/mailbox.mjs'; +import {createMailGenerations} from '../src/mail-generation.mjs'; const KADAN_ROLE_NAME = 'kadan-시험판-감독', OTHER_ROLE_NAME = '타레포-시험-감독'; const NOW = Date.parse('2026-10-16T12:00:00Z'); @@ -231,6 +232,61 @@ test('20 60분 경계: -1ms 요약 / 정확히 60분 전문 / +1ms 전문', () = } }); +// 이어 연 start 종류별 시험(전문 sent 뒤 이어 열기). sameProcess는 pid를 같게 둔 재기록이다. +const RESUME_KINDS = {restartOf: {restartOf: 'x', panePid: '200'}, resume: {conversationMode: 'resume', panePid: '200'}, + inheritMail: {inheritMail: true, panePid: '200'}, restoredFrom: {restoredFrom: 'x', panePid: '200'}, secretaryResumeSource: {secretaryResumeSource: 'x', panePid: '200'}}; +const generationOfRole = (entries, role) => { const g = createMailGenerations(); entries.forEach((e, i) => g.apply(e, i)); return g.generationOf(role); }; + +test('21 이어 연 start 다섯 종류 → 다음 일반 우편 full/resumed-generation, 세대 수 불변', () => { + for (const [name, extra] of Object.entries(RESUME_KINDS)) { + const s = setup(), entries = [...s.entries, start(KADAN_ROLE_NAME, NOW - 300_000, extra)]; + assert.deepEqual(meta(compose(KADAN_ROLE_NAME, {home: s.h, entries})), ['full', 'resumed-generation'], name); + assert.equal(generationOfRole(entries, KADAN_ROLE_NAME), generationOfRole(s.entries, KADAN_ROLE_NAME), name); + } +}); + +test('22 이어 열고 전문이 sent로 간 뒤 → summary/within-interval', () => { + for (const [name, extra] of Object.entries(RESUME_KINDS)) { + const s = setup(), entries = [...s.entries, start(KADAN_ROLE_NAME, NOW - 300_000, extra), fullSend(KADAN_ROLE_NAME, s.f, {t: iso(NOW - 60_000)})]; + assert.deepEqual(meta(compose(KADAN_ROLE_NAME, {home: s.h, entries})), ['summary', 'within-interval'], name); + } +}); + +test('23 sameProcess 재사용 start → 요약 그대로, 세대 불변', () => { + const s = setup(), entries = [...s.entries, start(KADAN_ROLE_NAME, NOW - 300_000)]; + assert.deepEqual(meta(compose(KADAN_ROLE_NAME, {home: s.h, entries})), ['summary', 'within-interval']); + assert.equal(generationOfRole(entries, KADAN_ROLE_NAME), 0); + // sameProcess이면 이어 연 필드가 함께 있어도 세지 않는다 + const both = [...s.entries, start(KADAN_ROLE_NAME, NOW - 300_000, {restartOf: 'x'})]; + assert.deepEqual(meta(compose(KADAN_ROLE_NAME, {home: s.h, entries: both})), ['summary', 'within-interval']); +}); + +test('24 new·fork start → generation-first(resumed-generation 아님)', () => { + for (const mode of ['new', 'fork']) { + const s = setup(), entries = [...s.entries, start(KADAN_ROLE_NAME, NOW - 300_000, {panePid: '200', conversationMode: mode})]; + const r = compose(KADAN_ROLE_NAME, {home: s.h, entries}); + assert.deepEqual(meta(r), ['full', 'generation-first'], mode); + assert.equal(r.metadata.generation, 1); + } +}); + +test('25 restart(첫 안내 없음) → 다음 일반 우편 full/resumed-generation, 전문 실패면 delivery-unclear', () => { + const t = throwingFixture(); + assert.equal(t.send().instructions.mode, 'full'); + assert.equal(t.send().instructions.mode, 'summary'); + appendLedger(start(t.role, Date.now(), {restartOf: 'x', panePid: '200', cwd: '/x/Dev/kadan'}), t.h); + const floorPid = {name: 'fake', alive: () => true, pid: () => '200', send: () => ({keyDelivery: 'sent', inputAcceptance: 'accepted'})}; + const sendAfter = (extra = {}) => guardedSend({floor: floorPid, session: `kadan-${t.role}`, role: t.role, message: '본문', roleProfile: 'conductor', recordedPid: '200', + env: {KADAN_HOME: t.h}, readEntries: () => readLedger(t.h), record: e => appendLedger(e, t.h), saveBody: () => {}, ...extra}); + const failing = {...floorPid, send: () => { const e = new Error('바닥 전달 실패'); e.delivery = 'unknown'; throw e; }}; + assert.throws(() => sendAfter({floor: failing}), e => e.delivery === 'unknown'); + const failed = readLedger(t.h).filter(e => e.kind === 'send-failed').at(-1); + assert.equal(failed.instructions.reason, 'resumed-generation'); + const next = sendAfter(); + assert.deepEqual([next.instructions.mode, next.instructions.reason], ['full', 'delivery-unclear']); + assert.equal(sendAfter().instructions.mode, 'summary'); +}); + import {CardStore} from '../src/card-store.mjs'; import {appendLedger} from '../src/ledger.mjs'; function awaitImport() { return {CardStore}; }