Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/role-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,15 @@ Why: 감독 창이 같은 규칙 전문의 반복으로 차서 압축이 잦았
| 시험 프로필(conductor)이 아님 | `profile-not-in-trial` |
| 업무·카드 사실을 못 읽음, 판 판별 근거가 없거나 어긋남 | `facts-unavailable` |
| 레포 kadan 판이 아님(예: 다른 레포 감독) | `board-not-in-trial` |
| 이 세대 마지막 전문 시도 뒤에 이어 연 start가 있음(아래 목록) | `resumed-generation` |
| 현재 발령(`--task`) | `dispatch` |
| 이 세대에 전문이 없음(첫 안내·새 대화 첫 우편 포함) | `generation-first` |
| 이 세대 마지막 전문이 `keyDelivery !== 'sent'` | `delivery-unclear` |
| 프로필 또는 `templateDigest`가 마지막 전문과 다름 | `profile-changed` · `template-changed` |
| 마지막 전달 성공 전문 뒤 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·요약 시도의 실패는 남기지 않으며 기록 실패가 원래 오류를 가리지 않는다. 자동 재전송은 없다.
Expand Down
8 changes: 6 additions & 2 deletions src/mail-generation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)]);
Expand All @@ -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};
}

// 이전 세대 미확인: 읽음 미확인이면서 기록용 알림이 아니고 새 세대가 시작되기 전의 편지.
Expand Down
6 changes: 4 additions & 2 deletions src/role-instructions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
56 changes: 56 additions & 0 deletions tests/role-instructions-slim.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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}; }
Expand Down
Loading