From 5f683686655e60918f111eebd4b5102a3b3b08b3 Mon Sep 17 00:00:00 2001 From: fwkyle Date: Sat, 17 Oct 2026 17:50:12 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EB=B9=88=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EC=B0=BD=20=ED=99=94=EB=A9=B4=20=ED=8C=90=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EC=97=86=EC=95=A0=EA=B3=A0=20=EC=82=AC=EB=9E=8C=20=EB=B3=B4?= =?UTF-8?q?=ED=98=B8=EB=A5=BC=20=EC=B5=9C=EA=B7=BC=20=ED=82=A4=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5(30=EC=B4=88)=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실행기 업데이트로 화면 모양이 바뀌면 checkEmptyInput이 빈 입력창을 미제출 글로 오판해 발령과 알림이 통째로 막혔다(devin·claude 9-23, herdr 폭 10-11, codex 0.163 10-17). tmux는 카드·알림·일반 우편이 같은 client_activity 30초 검사 하나를 쓰고, herdr는 화면 판정과 inputFrame을 지웠다. 받아들인 손해: 30초 넘게 손을 뗀 쓰던 글 뒤에 우편이 붙을 수 있다. Co-Authored-By: Claude Sonnet 5.5 --- docs/architecture.md | 2 +- docs/automatic-review.md | 2 +- docs/design.md | 20 +- docs/experiments/herdr-roundtrip.md | 18 +- docs/flows.md | 2 +- docs/kadan-guide.html | 4 +- docs/peer-super-mail.md | 8 +- docs/watch-overview.md | 8 +- src/dashboard-band.mjs | 1 + src/floor-herdr.mjs | 54 +----- src/floor-tmux.mjs | 124 +++---------- src/herdr-notification.mjs | 8 +- src/notification-guard.mjs | 1 + tests/ai-identity-boundaries-cli.test.mjs | 73 ++------ tests/ai-identity-send-boundaries.test.mjs | 203 ++++++++++----------- tests/automatic-review-cli.test.mjs | 15 +- tests/herdr-busymail-send.test.mjs | 1 - tests/herdr-floor.test.mjs | 36 ++-- tests/herdr-input-scrollback.test.mjs | 30 --- tests/herdr-input-width.test.mjs | 61 ------- tests/send-buffer.test.mjs | 13 +- tests/watch-notify-hold.test.mjs | 10 +- 22 files changed, 227 insertions(+), 467 deletions(-) delete mode 100644 tests/herdr-input-scrollback.test.mjs delete mode 100644 tests/herdr-input-width.test.mjs diff --git a/docs/architecture.md b/docs/architecture.md index 8e4066ad..2c74a1dc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,7 +108,7 @@ flowchart LR - **대상과 유예:** 우편 ID가 있고 읽음 미확인인 편지를 발송 시각부터 5분 유예한 뒤 현재 수신 역할별로 묶는다. 카드 없는 질문도 우편 알림 대상이지만 작업 감시AI 대상이 되는 것은 아니다. 명시적으로 연결된 업무가 보류·완료·취소이면 제외한다. 별도의 판 활성 상태를 추측하지 않는다. - **전달 전 확인:** 기존 start 원장과 실제 세션 PID가 맞아야 한다. 예약 뒤 읽음·인계·업무 상태와 생존을 다시 확인한다. 사용자·종료 역할을 기동하지 않으며 원문을 재전송하지 않는다. - **재시작 중복 방지:** 시스템 원장의 `watch-mail-reminder`에 우편 ID와 현재 수신 역할별 예약(`reserved`)·결과(`result`)를 남긴다. 같은 우편·같은 수신 역할은 한 번만 알린다. 예약만 남았거나 실패·전달 불명확이어도 재시작으로 자동 재전송하지 않는다. 후임으로 책임이 이전되면 그 후임에 대한 알림은 별도 한 번 가능하다. -- **사람 입력 보호(2026-09-23):** 알림 예약 전에 tmux 바닥에서 창에 붙은 사람의 마지막 키 입력(`client_activity`)이 30초 안이거나 입력창에 글이 있으면 보류한다. 보류는 붙여넣기 전이므로 예약하지 않고 `watch-mail-held`를 사유별로 한 번만 남긴 뒤 다음 순회에 다시 본다. 확인과 붙여넣기 사이에 입력이 생겨 전송 검사에서 멈춘 경우에는 `result`에 `held:true`를 남기고 예약을 풀어 재시작 뒤에도 다시 시도한다. 입력창 판별은 카드 전송과 같은 기준이다. rottie 바닥은 이 보류를 지원하지 않는다. +- **사람 입력 보호(2026-09-23):** 알림 예약 전에 tmux 바닥에서 창에 붙은 사람의 마지막 키 입력(`client_activity`)이 30초 안이면 보류한다(2026-10-17부터 입력창 글 모양은 보지 않는다). 보류는 붙여넣기 전이므로 예약하지 않고 `watch-mail-held`를 사유별로 한 번만 남긴 뒤 다음 순회에 다시 본다. 확인과 붙여넣기 사이에 입력이 생겨 전송 검사에서 멈춘 경우에는 `result`에 `held:true`를 남기고 예약을 풀어 재시작 뒤에도 다시 시도한다. 카드 전송·일반 우편도 같은 기준을 쓴다. rottie 바닥은 이 보류를 지원하지 않는다. - **재귀 제외:** 확인 알림 자체와 기록 전용 자동 완료 우편은 `notificationOnly: true`로 추가 미확인 알림에서 제외한다. 이 필드가 없던 과거 `systemGenerated: task-completion`도 제외한다. 읽음 기록은 자동으로 만들지 않고 원장에 그대로 두되, `inbox --unread`와 화면의 미확인 건수·필터에서는 뺀다(2026-09-27 [kyle]). 전체 목록에서는 **기록용 · 추가 알림 없음**으로 함께 표시한다. - **질문 대기:** 읽음·중간 답장으로 답변 대기를 끝내지 않는다. 현재 수신 책임자의 최종 답변 또는 현재 발신 책임자의 취소로 종료한다. 새로운 답변 지연 자동 상신은 없다. diff --git a/docs/automatic-review.md b/docs/automatic-review.md index 190bbcbf..d1e15540 100644 --- a/docs/automatic-review.md +++ b/docs/automatic-review.md @@ -25,7 +25,7 @@ ready/assigned/done 상태만 가능하다. 원본 본문을 복제하지 않고 확인한다. 프로그램은 역할 생성, 모델 변경, 권한 확대, 시한 연장을 하지 않는다. 같은 업무의 다른 실행이나 다른 담당이 두 역할을 점유하고 있으면 설정·전달을 막는다. -자동 발령에도 일반 카드 발령과 같은 실제 AI 프로세스·모델·빈 입력창 검사가 적용된다. +자동 발령에도 일반 카드 발령과 같은 실제 AI 프로세스·모델·사람 최근 입력(30초) 검사가 적용된다. 등록된 실행기라도 화면의 안내 문구·테두리·기존 출력 때문에 빈 입력 영역을 확정할 수 없으면 전송 전에 `exception`으로 멈춘다. 실제 Devin 화면에서도 이 제한을 확인했으므로, 지원 명령 목록이나 격리 시험 통과만으로 실제 UI의 자동 왕복이 검증됐다고 보지 않는다. diff --git a/docs/design.md b/docs/design.md index 423fae4d..ed90a4d3 100644 --- a/docs/design.md +++ b/docs/design.md @@ -82,15 +82,17 @@ tmux는 (a)(b)(c)를 충족했고 (d)는 Mac에서 통과했다(서버 왕복은 tmux 카드 전송은 버퍼 준비 전과 붙여넣기 직전에 `pane_dead`, 같은 pane PID, 입력을 받는 전경 프로세스 그룹에 속한 pane 프로세스/자식의 실행 파일 또는 Node/Bun 스크립트 이름을 대조한다. 뒤에서 실행 중인 AI만으로는 허용하지 않는다. - 입력 줄은 커서가 있는 프롬프트 줄(`›`·`>`·`❯`·devin `❭`) 하나다. 화면 전체의 프롬프트 모양 줄을 - 후보로 세지 않는다 — codex 배너(`│ >_`)·과거 프롬프트 때문에 실제 대기 화면을 거부한 사고(2026-09-23). - 그 위쪽 가장 가까운 글 줄도 프롬프트 모양이면 여러 줄 입력과 구별할 수 없어 보류한다. - 입력 상자 윗줄은 0열에서 pane 폭을 채우는 가로줄(표지 허용)이며, 입력 상자(devin·claude)는 바로 아래 닫는 가로줄 밑을 상태줄로 보고, 상자가 없으면(codex) 빈 줄 뒤 상태줄 한 줄만 허용한다. 단 상태줄에 ` · `가 있고 바로 다음 줄이 단축키 안내(`? for shortcuts` 포함, codex 0.163.0 두 줄 바닥글)이면 그 한 줄까지 허용한다. - 프롬프트 뒤 글은 실제 화면에서 확인한 빈 입력 자리표시자만 빈 입력으로 본다(작업 중 자리표시자는 제외). - 입력칸 글이 모두 흐린 글씨(SGR 2, `capture-pane -e`)면 실행기가 띄운 예시·다음 입력 추천으로 보고 빈 입력으로 본다(claude-code의 `Try "…"`와 작업 뒤 추천, 2026-09-23 실측). 흐리지 않은 글이 한 글자라도 있으면 사람이 친 글로 보고 보류한다. - 커서가 프롬프트 줄 밖이거나 이 모양과 다르면 보류한다. 프롬프트가 없거나 미제출 텍스트가 있거나 빈 입력창을 확인하지 - 못하면 전송하지 않는다. 이는 화면에 보이는 빈 입력의 확인이며 AI 인증·모델 응답 - 성공을 증명하지 않는다. 복사모드 검사는 기존 공통 전송 경계를 재사용한다. + 사람 보호는 화면 모양이 아니라 **창에 붙은 사람의 최근 키 입력**(tmux `client_activity`) 하나다(2026-10-17 [kyle] 결정). + 카드 전송·감시 알림·일반 우편이 같은 기준을 쓴다. 붙은 사람의 마지막 키 입력이 30초(`HUMAN_IDLE_MS`) 안이면 + `KADAN_HUMAN_ACTIVE`로 보류하고, 값이 숫자가 아니면 `KADAN_HUMAN_ACTIVITY_UNKNOWN`으로 보류한다(실패 닫힘). + 붙은 client가 없으면(백그라운드 역할) 통과한다. 우회는 감시기의 5번째 강제 차례(`force`)뿐이다. + 일반 우편이 막히면 `not-sent`로 실패하며 새 대기열은 만들지 않는다(슈퍼 간 우편만 기존대로 보관). + 프롬프트 줄·자리표시자·입력 상자 테두리·바닥글 모양으로 빈 입력창을 판정하던 장치(`checkEmptyInput`)는 없앴다. + 실행기 업데이트로 화면 모양이 바뀌면 빈 입력창을 미제출 글로 오판해 발령과 알림이 통째로 막혔기 때문이다 + (2026-09-23 devin·claude, 10-11 herdr 폭, 10-17 codex 0.163). + **받아들인 손해:** 사람이 글을 치다 30초 넘게 손을 떼고 그대로 두면 그 글 뒤에 우편이 붙어 제출될 수 있다. + 또 사람이 창에 새로 붙은 순간도 활동으로 잡히므로 붙은 직후 30초는 보류된다. + 이는 화면에 보이는 확인이 아니며 AI 인증·모델 응답 성공을 증명하지 않는다. 복사모드 검사는 기존 공통 전송 경계를 재사용한다. 이 tmux 프로세스·입력 검증은 Rottie 바닥의 실측을 대신하지 않는다. 실제 전경 AI 프로세스의 명시 모델도 시작 기록과 대조한다. 불일치·미확인이면 거부한다. 빈 셸·비AI 명령·모델 미지정·이전 세대 신원만 남은 pane에는 카드를 붙여넣지 diff --git a/docs/experiments/herdr-roundtrip.md b/docs/experiments/herdr-roundtrip.md index 5f2de752..cf44a72d 100644 --- a/docs/experiments/herdr-roundtrip.md +++ b/docs/experiments/herdr-roundtrip.md @@ -12,11 +12,11 @@ - `KADAN_HOME`과 절대경로 `KADAN_HERDR_SOCKET`을 명시한다. 자동 서버 기동·설치·로그인·훅 설치·다른 바닥으로 폴백하지 않는다. - `cwd`를 생성 요청에 지정한다. 시작 원장에 서버 주소, workspace/pane/terminal ID와 실제 시작 PID를 기록한다. 한글 역할 이름은 그대로 유지한다. - 주소 조회는 원장의 terminal ID를 기준으로 한다. 반환된 pane/tab ID를 사용하며 번호를 추측하지 않는다. -- 전송 전 현재 PID·foreground 실행기·명시 모델·입력 대기·미제출 입력·스크롤 상태를 확인한다. 명령 인자가 생략된 경우 해당 로컬 foreground PID만 `ps`로 조회한다. +- 전송 전 현재 PID·foreground 실행기·명시 모델·입력 대기·스크롤 상태를 확인한다. 미제출 글 모양은 판정하지 않는다(2026-10-17, 아래 "입력창 판정 제거"). 명령 인자가 생략된 경우 해당 로컬 foreground PID만 `ps`로 조회한다. - `agent.prompt`로 괄호 붙여넣기와 Enter를 제출한다. 전달 성공은 `keyDelivery=sent`, `inputAcceptance=unconfirmed`이며 실행 완료를 뜻하지 않는다. -- 입력창 검사는 읽기 전용 terminal observer와 visible snapshot을 사용한다. `takeover`는 사용하지 않는다. +- 화면 확인은 `pane.read`의 visible text를 쓴다. `takeover`는 사용하지 않는다. - 화면 확인에 `terminal session control`·`terminal attach`를 쓰지 않는다. 실제 창 크기를 흉내 내려고 백그라운드로 띄우는 것도 금지다. 2026-10-12 시험(HW-6) 중 뒤에 남은 control 클라이언트 3개가 각각 68~130GB(압축 포함)까지 불어나 맥 전체가 메모리 부족에 걸렸다(01:36~07:53, 8회). 격리 재현 8가지 조건에서는 재현되지 않아 원인은 미확정이며 herdr에 보고했다(herdrdev/herdr#5175). 행 수가 필요하면 `observe --rows `를 쓴다. -- 응답 유실은 전달 불명확으로 끝낸다. 본문/Enter 자동 재전송은 없다. 자동 알림은 빈 입력창·에이전트 대기·동일 화면이 30초 이상 관측된 뒤 전송 직전 재검사한다. 작업 중·승인 대기·스크롤·입력 미확인은 보류한다. Enter 단독 재전송은 계속 거절한다. +- 응답 유실은 전달 불명확으로 끝낸다. 본문/Enter 자동 재전송은 없다. 자동 알림은 에이전트 대기·동일 화면이 30초 이상 관측된 뒤 전송 직전 재검사한다. 작업 중·승인 대기·스크롤·입력 미확인은 보류한다. Enter 단독 재전송은 계속 거절한다. - 출력은 ANSI recent snapshot을 읽는다. `truncated`이면 기존 wait가 gap으로 중단한다. 완전한 출력 로그나 스트림 재생 기능을 제공하지 않는다. ## 시험 환경에서 사용하는 법 @@ -54,7 +54,7 @@ kadan attach 판-작업자 | AI 도구의 300줄 명령 출력 뒤 DONE | 회수 성공. CLI가 중간 출력을 접어서 보여 줌 | | 새 DONE 없는 응답 | idle이어도 quiet 종료, done 기록 없음 | | PID 불일치 | 입력과 send 기록 없음 | -| 미제출 입력 | 원문 보존, 새 본문 거절 | +| 미제출 입력 | (2026-10-11 당시) 원문 보존, 새 본문 거절. 2026-10-17부터 화면 모양을 판정하지 않아 카드·일반 우편은 보낸다 | | 실제 신뢰 확인 화면의 blocked | 입력 거절 | | 종료한 시험 터미널 | 입력 거절 | | 연결 불가능한 시험 소켓 | 실패 보고, 자동 재시도 없음 | @@ -113,7 +113,7 @@ Rottie의 각 프로젝트에 `terminal create --workspace <그 프로젝트> -- ## 자동 알림 보호 보완 — 2026-10-13 일반 경보·감시 AI 보고·미확인 우편·유휴 알림이 같은 입력 보호를 쓴다. -herdr에는 최근 사람 입력 시각 API가 없어 화면 revision과 입력 줄·PID·terminal ID의 안정성을 +herdr에는 최근 사람 입력 시각 API가 없어 화면 revision과 화면 글·PID·terminal ID의 안정성을 함께 확인한다. 감시기 재시작이나 관측 실패 뒤에는 30초를 새로 센다. 보호로 확실히 미전송된 경우만 나중에 재확인하며, 이미 해소된 경보는 보내지 않는다. 응답 유실이나 발송 도중 중단은 자동 재전송하지 않는다. @@ -121,3 +121,11 @@ herdr에는 최근 사람 입력 시각 API가 없어 화면 revision과 입력 화면 관측과 `agent.prompt` 사이를 서버가 원자적으로 잠그지는 못한다. 마지막 검사 이후에 사람이 키를 누르는 짧은 경합 구간까지 완전히 없앤 보장은 아니다. 2026-10-13 실제 Codex 두 세션으로 입력 문장 보존, 30초 유예, 요청→자동 깨움→최종 답장→ 요청자의 읽음과 양쪽 한 번씩 알림을 확인했다. 운영 세션에 적용한 증거는 아니다. 레포 간 흐름은 [슈퍼 우편](../peer-super-mail.md)을 따른다. + +## 입력창 판정 제거 — 2026-10-17 + +- 결정: [kyle]이 화면 모양으로 빈 입력창을 판정하던 `checkEmptyInput`을 없앴다. 실행기 업데이트마다 빈 입력창을 미제출 글로 오판해 발령이 막히는 것(herdr 폭 10-11, devin·claude 9-23, codex 0.163 10-17)이 사람 글 뒤에 우편이 붙는 것보다 치명적이라고 보았다. +- 지운 것: herdr 쪽 `inputFrame`·`herdrInputFrame`(관찰 창 세로 줄 수로 커서 행을 구하던 장치)와 `checkEmptyInput` 호출. 위 "입력창 폭 판정"·"입력 커서 행" 항목은 그 당시 기록이며 이제 해당 코드가 없다. +- 방침 (b): 카드 전송과 일반 우편은 사람 검사 없이 보낸다. 이유는 (a) 화면 30초 안정 검사를 카드·우편에도 쓰려면 안정 기록을 메모리 맵(`herdrNotificationGuard`의 `quiet`)에 두는데, `kadan send`·`dispatch`는 호출마다 새 프로세스라 이전 관측을 기억하지 못해 늘 처음 관측으로 보류되기 때문이다(항상 막힘). 장수 프로세스인 감시기의 알림만 30초 안정 검사를 유지한다. +- 알림 지문: `frame.row`를 뺐다. 지문은 소켓·terminal·PID·pane·화면 revision·화면 글이다. 커서 행은 화면 글이 바뀌면 함께 바뀌므로 안정 판정에 더하는 정보가 없다. +- 받아들인 손해: herdr에서는 사람이 쓰던 글 뒤에 카드·우편이 붙어 제출될 수 있다. 승인 대기·작업 중(카드)·기록 보기 중 검사는 그대로다. diff --git a/docs/flows.md b/docs/flows.md index c41b5c35..0775008f 100644 --- a/docs/flows.md +++ b/docs/flows.md @@ -87,7 +87,7 @@ | 큐 대기 (`큐대기`) | 발령 없는 세션 등 | 그 밖의 큐 배너·큐 블록이 5분 넘게 남음 | 직속 상위 | | 상태 확인 필요 (`모름`) | 등록 역할·감시기 | 화면을 읽지 못함(역할), 원장을 읽지 못함(감시기) | 역할은 직속 상위, 원장 오류는 공통 수신자 | | 감시AI 오류 (`감시AI오류`) | 감시 AI 호출 | 판정 호출이 실패·시간 초과·보고 누락(대체 후보까지 실패). 인터넷 없음은 깨어 있는 동안 3번 연속일 때만, 맥북 잠자기로 끊긴 호출은 알리지 않음 | 사용자(디스코드, 안 되면 OS 알림·감시 출력). 판정 프로그램·모델 장애는 운영자 몫이라 감독에게 보내지 않는다 | -| 알림 밀림 (`알림밀림`) | 받는 창(역할) | 감시기가 보내려는 알림(미확인 우편 재알림·감시 경보·슈퍼 간 보관 우편)이 입력 보호(사람 입력·쓰다 만 글·화면 판정 불가)나 tmux 복사 모드로 3번 연속 밀림. 5번 연속이면 입력 보호를 건너뛰고(복사 모드는 해제·재확인 뒤) 넣고 원장에 `watch-notify-forced`를 남김. 전달에 성공하면 닫힘 → [입력 보호 상한](watch-overview.md#입력-보호-상한-2026-10-13) | 사용자(OS 알림). 밀리는 그 창이나 상위로는 보내지 않음 | +| 알림 밀림 (`알림밀림`) | 받는 창(역할) | 감시기가 보내려는 알림(미확인 우편 재알림·감시 경보·슈퍼 간 보관 우편)이 입력 보호(최근 30초 사람 입력)나 tmux 복사 모드로 3번 연속 밀림. 5번 연속이면 입력 보호를 건너뛰고(복사 모드는 해제·재확인 뒤) 넣고 원장에 `watch-notify-forced`를 남김. 전달에 성공하면 닫힘 → [입력 보호 상한](watch-overview.md#입력-보호-상한-2026-10-13) | 사용자(OS 알림). 밀리는 그 창이나 상위로는 보내지 않음 | | 전달 실패 (`전달실패`) | 경보 자체 | 경보 우편을 받는 쪽 창에 넣지 못함 | 받지 못한 쪽의 상위(관계표에 없으면 `--super`) | | 감시 제외 (`감시제외`) | 경보 자체 | 대상이 감시 범위를 벗어나 경보를 닫음(회복 편지 없음) | 기록만 | | 자원 (`자원`) | 이 컴퓨터 | 메모리·스왑·부하 | 우편 없음. 대시보드에서 본다 | diff --git a/docs/kadan-guide.html b/docs/kadan-guide.html index 496760d2..c24e269d 100644 --- a/docs/kadan-guide.html +++ b/docs/kadan-guide.html @@ -593,7 +593,7 @@

발령 한 번에 일어나는 일 — 결과 종류부터

발령 검문소 — 직접 눌러 보기

-

상황을 고르고 send를 눌러 보세요. 통과한 사건만 원장에 붙고, 거부는 도장으로 남아요. 이 로직은 guardedSend·checkSend의 축소 복사이고, 실제 검사는 더 많은 화면 규칙(빈 입력창 모양·복사모드 등)을 본다.

+

상황을 고르고 send를 눌러 보세요. 통과한 사건만 원장에 붙고, 거부는 도장으로 남아요. 이 로직은 guardedSend·checkSend의 축소 복사이고, 실제 검사는 더 많은 규칙(사람 최근 입력·복사모드 등)을 본다.

미리 만든 장면

@@ -669,7 +669,7 @@

규칙표 2 — 발령 검문소 순서

(3)PID 대조시작 기록의 PID와 지금 PID가 같나?KADAN_PID_MISMATCHcli.mjs:712 (4)AI 신원현재 세대가 등록 실행기·모델인가? 카드 단계 프로필과 맞나?KADAN_AI_IDENTITY_UNVERIFIED
KADAN_STALE_RUNNERcli.mjs:727·745
-

일반 우편·질문·답장은 taskId가 없으므로 (1)(4)를 거치지 않는다. tmux는 여기에 더해 빈 입력창 모양·전경 프로세스·복사모드까지 화면으로 확인한다. (cli.mjs:724, docs/design.md 규칙 2)

+

일반 우편·질문·답장은 taskId가 없으므로 (1)(4)를 거치지 않는다. tmux는 여기에 더해 사람 최근 입력(30초)·전경 프로세스·복사모드를 확인한다. (cli.mjs:724, docs/design.md 규칙 2)

diff --git a/docs/peer-super-mail.md b/docs/peer-super-mail.md index 1ec9bcc8..62e57943 100644 --- a/docs/peer-super-mail.md +++ b/docs/peer-super-mail.md @@ -46,7 +46,7 @@ ## 보관과 안전한 알림 관계표의 사용자 직속 역할(또는 관계표 밖의 명시된 super 프로필)끼리 보내는 일반 우편은 -보내는 순간 일반 `mail`과 같은 입력 보호(최근 30초 사람 입력·입력창의 미제출 글·화면 판정 불가)를 +보내는 순간 일반 `mail`과 같은 입력 보호(최근 30초 사람 입력)를 거쳐 바로 넣는다(2026-10-13). 통과하면 바로 들어가며 일반 우편처럼 5분 뒤에도 읽음이 없을 때만 감시기가 재알림한다. 걸리거나, 시작 기록·세션이 없으면 넣지 않고 보관한다. 보관한 우편의 `stored`, `wake: watch-required`는 저장 영수증이다. 별도 사전 검사는 없고 바닥의 붙여넣기 직전 검사가 @@ -60,9 +60,9 @@ 같은 창에 3번 연속 밀리면 사용자에게 `알림밀림`이 가고 5번 연속이면 입력 보호 없이 넣는다([입력 보호 상한](watch-overview.md#입력-보호-상한-2026-10-13)). 감시기가 꺼졌거나 수신 세션이 없으면 저장만 되므로 inbox에서 확인해야 한다. -tmux는 최근 사람 입력과 빈 입력창을 검사한다. herdr는 사람 입력 시각 API가 없으므로 -대기 상태·빈 입력창·동일 화면을 30초 이상 관측하고 전송 직전에 재검사한다. 작업 중·승인 -대기·스크롤 중·입력 영역 미확인은 보류한다. 화면 검사와 전송 사이의 짧은 경합은 서버에서 +tmux는 최근 사람 입력(`client_activity` 30초)만 검사한다. 입력창 글 모양은 보지 않는다(2026-10-17). +herdr는 사람 입력 시각 API가 없으므로 대기 상태·동일 화면을 30초 이상 관측하고 전송 직전에 재검사한다. +작업 중·승인 대기·스크롤 중은 보류한다. 화면 검사와 전송 사이의 짧은 경합은 서버에서 원자적으로 잠그지 못한다([herdr 경계](experiments/herdr-roundtrip.md#자동-알림-보호-보완--2026-10-13)). ## 읽고 멈춘 요청 감시 diff --git a/docs/watch-overview.md b/docs/watch-overview.md index f41a11bc..16c07396 100644 --- a/docs/watch-overview.md +++ b/docs/watch-overview.md @@ -225,7 +225,7 @@ AI가 정상 보고를 마쳤으면 마지막 대화 응답의 호칭·문구· - **답을 기다리는 일은 할 일에서 뺀다**(2026-10-05 [kyle]). 감독을 통째로 빼지 않고 그 일(카드와 그 카드가 속한 워크)만 뺀다 — 다른 일이 남아 있으면 그대로 판정한다. 사용자 결정(`decision request`)이 열린 카드는 모두에게서 빠진다. 결정에 `--work`로 연결한 업무도 같이 빠진다 — 결정 카드가 그 업무의 실행이 아니어도 된다(2026-10-07). 감독이 상위에게 답을 기다리는 질문 우편(`send --expect-reply`)을 보낸 카드는 묻는 쪽에게서만 빠지고, 답할 쪽(상위)에게는 할 일로 남는다. 결정·질문이 닫히면 다음 순회부터 다시 센다. 목록을 못 읽으면 아무것도 빼지 않는다. - **본인 차례만 센다**(2026-10-16 [kyle]). 사용자 결정을 기다리는 슈퍼감독이 하위 감독 차례 업무 때문에 재촉받던 오경보(업무 owner는 하위 감독, 차례는 슈퍼감독·하위 감독으로 섞여 있었다)를 고쳤다. 열린 워크의 할 일은 owner가 아니라 지금 차례(`turnOwner`)인 역할의 것이다(관계표에 없으면 owner). 감독의 1단은 본인 차례 일이 있을 때만 울린다. 하위 감독 차례 일이 멈추면 그 하위 감독이 1단을 받고, 2단에서 상위로 올라온다. 본인 차례가 0이고 사용자 답 대기뿐이면 아무것도 보내지 않는다. 재촉 문구는 `본인 차례 N건 / 하위 <역할> 차례 N건 / 사용자 답 대기 N건`으로 가른다(없는 항목은 생략). 경보 원장 사건에 센 항목(`idleItems.own`·`lower`·`userWait`, 각 20개까지)이 남는다 — 다음에는 원장만으로 무엇을 셌는지 답할 수 있다. - 할 일의 워크 담당은 확정된 역할 인계를 따라 지금 책임 감독으로 센다(`effectiveWorkOwner`). 워크 owner 원문이 옛 감독 이름이어도 이어 맡은 감독이 받는다(PR #99 검수 2). 2단 문구에 "N분 전에 본인에게 알렸으나 그대로"가 붙는다. -- 본인 알림은 사람이 그 창에서 최근 입력했거나 미제출 입력이 있으면 미루고, 경보가 열려 있는 동안 다음 주기에 다시 시도한다. 작업자에게는 본인 알림을 보내지 않는다(정체는 AI 판정 뒤 감독에게 간다). +- 본인 알림은 사람이 그 창에서 최근(30초) 입력했으면 미루고, 경보가 열려 있는 동안 다음 주기에 다시 시도한다. 작업자에게는 본인 알림을 보내지 않는다(정체는 AI 판정 뒤 감독에게 간다). - 본인 창에 넣은 알림 글은 그 감독의 화면 변화로 세지 않는다(배달 흡수). 안 그러면 알림이 경보를 닫고 15분 뒤 다시 울리는 타이머가 된다. 예전 `--wake`(30분 타이머마다 고정 점검 목록을 넣던 자가점검 깨우기)는 그래서 없앴다. - **해소 우편은 없다.** 조건이 사라지면 원장에 `resolved` 기록만 남기고 감시 출력에 한 줄 적는다(자원·큐대기는 전부터 그랬다). 경보 우편 끝에 "행동 전 지금 상태를 확인하라. 풀렸으면 따로 알리지 않는다"가 붙고, 감독은 `GET /api/dashboard/status?super=<자기 슈퍼감독>`으로 자기 판의 지금 상태를 작게 읽는다(감독 템플릿). 이 응답은 지금 손댈 실행(작업중·막힘)만 판단 칸 8개로 주고, 오래된 미정리·진행 전·일시정지는 `counts`의 수만 준다(2026-10-05 PR #99 검수 3: 거른 뒤에도 약 81KB였던 것을 약 2KB로). 사람은 현황의 "지금 막힌 것 · 경보"로 본다. - 기본 1시간에 한 번 확인한다. 최근 화면의 연결 재시도·SSE 시간 초과·문맥 압축 실패가 5분 유지되면 먼저 한 번 확인한다. 같은 오류가 유지되는 동안 조기 호출은 반복하지 않으며 이후 정기 점검에서 다시 판단한다. 한 주기 신규 감독 1명씩 처리하고 대기열의 실제 호출 시작 시각부터 다음 1시간을 센다. @@ -236,11 +236,11 @@ AI가 정상 보고를 마쳤으면 마지막 대화 응답의 호칭·문구· ## 입력 보호 상한 (2026-10-13) -감시기가 창에 넣는 알림은 입력 보호(최근 30초 사람 입력·입력창의 미제출 글·화면 판정 불가)에 걸리면 미룬다. 예전에는 상한이 없어 쓰다 만 입력이 남거나 화면 모양이 바뀌면 조용히 계속 밀릴 수 있었다. [kyle] 결정으로 상한을 둔다. 판정 기준과 일반 `kadan mail/send`는 그대로다. +감시기가 창에 넣는 알림은 입력 보호(최근 30초 사람 입력)에 걸리면 미룬다. 2026-10-17부터 입력창 글 모양은 판정하지 않는다. 옛 코드 `KADAN_PANE_INPUT_PENDING`·`KADAN_PANE_INPUT_UNKNOWN`은 옛 원장 기록을 표시하는 데만 남아 있다. 예전에는 상한이 없어 쓰다 만 입력이 남거나 화면 모양이 바뀌면 조용히 계속 밀릴 수 있었다. [kyle] 결정으로 상한을 둔다. 판정 기준과 일반 `kadan mail/send`는 그대로다. -- **센다:** 받는 창(역할) 단위로 감시기 메모리에서 센다. 한 주기에 그 창으로 갈 것(미확인 우편 재알림·감시 경보·놀고 있음 본인 알림·감시AI 보고의 재시도(첫 시도는 판정 AI 자식 프로세스가 보내 세지 못한다)·슈퍼 간 보관 우편 알림) 중 하나라도 입력 보호로 밀리면 1을 더한다. 전달에 성공하거나 보낼 것이 없어지면 0이다. 한 주기에 밀림과 성공이 같이 있으면 성공으로 보아 0이다. 관찰이 실패한 주기는 세지 않는다. 화면 판정 불가(`KADAN_PANE_INPUT_UNKNOWN` 등)도 미룸으로 센다. tmux 복사 모드(`KADAN_PANE_IN_MODE`)와 모드 조회 실패(`KADAN_PANE_MODE_UNKNOWN`)도 미룸이라 같은 카운터에 더한다(2026-10-14). 예전에는 이 둘이 일반 전달 실패로 끝나 알림이 다시 시도되지 않고 사라졌다. 일반 `kadan mail/send`와 큐 재개의 복사 모드 거절은 그대로다. 감시기를 다시 시작하면 0부터 센다. +- **센다:** 받는 창(역할) 단위로 감시기 메모리에서 센다. 한 주기에 그 창으로 갈 것(미확인 우편 재알림·감시 경보·놀고 있음 본인 알림·감시AI 보고의 재시도(첫 시도는 판정 AI 자식 프로세스가 보내 세지 못한다)·슈퍼 간 보관 우편 알림) 중 하나라도 입력 보호로 밀리면 1을 더한다. 전달에 성공하거나 보낼 것이 없어지면 0이다. 한 주기에 밀림과 성공이 같이 있으면 성공으로 보아 0이다. 관찰이 실패한 주기는 세지 않는다. 사람 입력 시각 미확인(`KADAN_HUMAN_ACTIVITY_UNKNOWN`)과 pane 상태 미확인도 미룸으로 센다. tmux 복사 모드(`KADAN_PANE_IN_MODE`)와 모드 조회 실패(`KADAN_PANE_MODE_UNKNOWN`)도 미룸이라 같은 카운터에 더한다(2026-10-14). 예전에는 이 둘이 일반 전달 실패로 끝나 알림이 다시 시도되지 않고 사라졌다. 일반 `kadan mail/send`와 큐 재개의 복사 모드 거절은 그대로다. 감시기를 다시 시작하면 0부터 센다. - **3번 연속:** `알림밀림` 경보 하나가 `@user`에게만 간다(디스코드, 안 되면 OS 알림 — `user-notify.mjs`가 `display notification`으로 내려가며 창을 앞으로 가져오지 않는다. [사용자 알림](user-notify.md)). 원장 `alert` 줄에 창·사유 코드(`holdCode`)·횟수(`holdCount`)·시작 시각(`heldSince`)이 남고 대시보드가 열린 경보로 보여 준다. 같은 연속 구간에서는 한 번만 울린다. 지난 주기까지의 횟수로 만들어 최대 한 주기 늦다. -- **5번 연속:** 그 창에는 입력 보호(사람 입력·입력창 글·안정 시간 확인)만 건너뛰고 넣는다. 생존 확인·PID 대조, herdr의 승인 대기·기록보기 확인은 그대로라 세션이 없거나 PID가 다르거나 herdr 기록 보기 중이면 강제로도 넣지 않는다. tmux 복사 모드는 복사 모드에서 보낸 키를 tmux가 복사 모드 명령으로 읽으므로 그대로 넣지 않고, 강제 차례에 한해 `send-keys -X cancel`로 빠져나온 뒤 `pane_in_mode`가 0인지 다시 확인하고 넣는다(사람의 스크롤 위치가 풀리며 쓰다 만 글 뒤에 붙는 위험과 같은 급으로 [kyle]이 수용, 2026-10-14). 다시 확인해도 0이 아니면 넣지 않고 미룸으로 센다. 해제하고 넣었으면 `watch-notify-forced`와 `send` 원장 줄에 `copyModeReleased: true`가 남는다. 그 밖의 이런 실패는 미룸이 아니라 일반 전달 실패라 횟수에 더하지도 0으로 되돌리지도 않는다. 넣는 데 성공하면 원장에 `watch-notify-forced` 줄(역할·횟수·사유·시작 시각)을 남기고 0으로 돌아가 경보가 풀린다. 쓰다 만 글 뒤에 붙을 위험은 [kyle]이 수용했다. +- **5번 연속:** 그 창에는 입력 보호(사람 입력·안정 시간 확인)만 건너뛰고 넣는다. 생존 확인·PID 대조, herdr의 승인 대기·기록보기 확인은 그대로라 세션이 없거나 PID가 다르거나 herdr 기록 보기 중이면 강제로도 넣지 않는다. tmux 복사 모드는 복사 모드에서 보낸 키를 tmux가 복사 모드 명령으로 읽으므로 그대로 넣지 않고, 강제 차례에 한해 `send-keys -X cancel`로 빠져나온 뒤 `pane_in_mode`가 0인지 다시 확인하고 넣는다(사람의 스크롤 위치가 풀리며 쓰다 만 글 뒤에 붙는 위험과 같은 급으로 [kyle]이 수용, 2026-10-14). 다시 확인해도 0이 아니면 넣지 않고 미룸으로 센다. 해제하고 넣었으면 `watch-notify-forced`와 `send` 원장 줄에 `copyModeReleased: true`가 남는다. 그 밖의 이런 실패는 미룸이 아니라 일반 전달 실패라 횟수에 더하지도 0으로 되돌리지도 않는다. 넣는 데 성공하면 원장에 `watch-notify-forced` 줄(역할·횟수·사유·시작 시각)을 남기고 0으로 돌아가 경보가 풀린다. 쓰다 만 글 뒤에 붙을 위험은 [kyle]이 수용했다. ## 자원 수치 확인 diff --git a/src/dashboard-band.mjs b/src/dashboard-band.mjs index fadc4279..cb2cbbdf 100644 --- a/src/dashboard-band.mjs +++ b/src/dashboard-band.mjs @@ -79,6 +79,7 @@ const SILENTLY_CLOSED = new Set(["자원", "전달실패"]); // 알림밀림 사유 코드를 쉬운 말로. 모르는 코드는 원문만 보인다. const HOLD_REASONS = { KADAN_HUMAN_ACTIVE: "사람이 방금 입력함", KADAN_HUMAN_ACTIVITY_UNKNOWN: "사람 입력 시각을 알 수 없음", + // 옛 기록 표시용: 화면 모양 판정을 없앤 2026-10-17 전 원장에 남은 코드다. KADAN_PANE_INPUT_PENDING: "입력창에 쓰다 만 글이 있음", KADAN_PANE_INPUT_UNKNOWN: "입력창 상태를 알 수 없음", KADAN_PANE_STATE_UNKNOWN: "창 상태를 알 수 없음", KADAN_HERDR_NOT_READY: "입력 대기 상태가 아님", KADAN_HERDR_INPUT_UNKNOWN: "입력창 상태를 알 수 없음", KADAN_HERDR_SCROLLED: "과거 기록을 보는 중", diff --git a/src/floor-herdr.mjs b/src/floor-herdr.mjs index f886b3fa..fc6350c4 100644 --- a/src/floor-herdr.mjs +++ b/src/floor-herdr.mjs @@ -5,7 +5,6 @@ import os from "node:os"; import { fileURLToPath } from "node:url"; import { commandWords, matchesAiProcess } from "./ai-identity.mjs"; import { readLedger } from "./ledger.mjs"; -import { checkEmptyInput } from "./floor-tmux.mjs"; import { herdrNotificationGuard } from "./herdr-notification.mjs"; function deny(code, message, delivery = "not-sent") { @@ -62,36 +61,6 @@ function localArgs(pid) { return result.status === 0 ? result.stdout.trim() : ""; } -const OBSERVE_FRAME = ` -import {spawn} from 'node:child_process'; -const [session,terminal,rows]=process.argv.slice(1); let data=''; -const child=spawn('herdr',['--session',session,'terminal','session','observe',terminal,'--rows',rows],{stdio:['ignore','pipe','ignore']}); -const timer=setTimeout(()=>{child.kill();process.exitCode=1;},3000); -child.stdout.on('data',chunk=>{data+=chunk;const end=data.indexOf('\\n'); - if(end>=0){process.stdout.write(data.slice(0,end));child.kill();clearTimeout(timer);}}); -child.on('error',()=>{clearTimeout(timer);process.exitCode=1;}); -`; - -// 관찰 창은 기본 120x40이라, 세로가 더 큰 터미널이면 실제 커서가 프레임 밖(숨김)으로 밀려 행 좌표가 어긋난다 -// (2026-10-12 LBR-1 결함 1: 58행 Codex 창에서 프레임 마지막 이동이 커서 아닌 그리기 위치 40행이었다). -// 터미널의 실제 세로 줄 수로 관찰해야 프레임 행 = 화면 행이 된다. -export function herdrInputFrame(socket, terminal, rows) { - if (!Number.isInteger(rows) || rows <= 0) deny("KADAN_HERDR_INPUT_UNKNOWN", "터미널 세로 줄 수 미확인"); - const result = spawnSync(process.execPath, ["--input-type=module", "-e", OBSERVE_FRAME, - serverName(socket), terminal, String(rows)], { encoding: "utf8", timeout: 4000, maxBuffer: 1024 * 1024 }); - let frame; - try { frame = JSON.parse(result.stdout); } catch { deny("KADAN_HERDR_INPUT_UNKNOWN", "입력 커서 조회 실패"); } - if (result.status !== 0 || frame.type !== "terminal.frame" || !frame.full || frame.encoding !== "ansi") { - deny("KADAN_HERDR_INPUT_UNKNOWN", "전체 터미널 화면이 아닌 응답"); - } - if (frame.height !== rows) deny("KADAN_HERDR_INPUT_UNKNOWN", `관찰 화면 세로(${frame.height})가 터미널(${rows})과 달라 커서 행을 믿을 수 없다`); - const ansi = Buffer.from(frame.bytes, "base64").toString("utf8"); - const cursor = [...ansi.matchAll(/\x1b\[(\d+);(\d+)H/g)].at(-1); - if (!cursor) deny("KADAN_HERDR_INPUT_UNKNOWN", "현재 입력 커서 미확인"); - // frame.width는 접속 창 크기와 어긋난 값이 올 수 있어(120 대 실제 212) 입력창 판정에 쓰지 않는다. - return { row: Number(cursor[1]) - 1, width: frame.width }; -} - // 새 폴더의 첫 실행은 AI 도구가 "이 폴더를 믿느냐"를 묻고 사람이 답할 때까지 멈춘다(2026-10-12 LBR-1 실측). // 자동으로 수락하지 않는다: 폴더를 믿는 일은 사람의 결정이다. 막힘을 알리고 정확한 통과 명령만 보여 준다. // Claude 기본 선택은 `No, exit`라 Enter만 보내면 종료된다 → 아래로 한 칸 내린 뒤 Enter. Codex 기본은 `Yes, continue`. @@ -110,7 +79,7 @@ export function trustPromptHelp(socket, pane, screenText) { } export function makeHerdrFloor({ env = process.env, rpc = herdrRpc, entries = readLedger, - argsForPid = localArgs, inputFrame = herdrInputFrame } = {}) { + argsForPid = localArgs } = {}) { const pending = new Map(); const socket = () => { if (!env.KADAN_HOME || !path.isAbsolute(env.KADAN_HERDR_SOCKET || "")) { @@ -159,7 +128,7 @@ export function makeHerdrFloor({ env = process.env, rpc = herdrRpc, entries = re // 응답 중인 실행기는 Claude·Codex 모두 들어온 입력을 자기 대기열에 쌓았다가 현재 작업 뒤에 처리한다(2026-10-12 실측). // 기본은 거절, 명시 허용: 호출부가 사람·역할 우편(send 명령)임을 busyMail로 표시할 때만 working을 허용한다. // 카드 발령·자동 경보·표시 없는 호출은 입력 대기(idle/done)만 허용한다. - const checkAgent = (session, identity, { allowWorking = false, skipInput = false } = {}) => { + const checkAgent = (session, identity, { allowWorking = false } = {}) => { const { record, pane } = requirePane(session); const info = processInfo(pane); if (String(info?.shell_pid) !== String(identity?.currentPid ?? record.panePid)) deny("KADAN_PID_MISMATCH", "현재 프로세스 세대가 시작 기록과 다르다"); @@ -180,19 +149,10 @@ export function makeHerdrFloor({ env = process.env, rpc = herdrRpc, entries = re deny("KADAN_HERDR_NOT_READY", `입력 대기 확인 실패: ${agent?.agent_status ?? "unknown"}`); } if (pane.scroll?.offset_from_bottom > 0) deny("KADAN_HERDR_SCROLLED", "과거 기록 조회 중이라 전송을 보류한다"); - if (skipInput) { - // 입력 보호를 건너뛰는 강제 알림. 입력창·안정 시간 확인만 빼고 프로세스·승인 대기·기록보기 확인은 위에서 끝냈다. - const view = call("pane.read", { pane_id: pane.pane_id, source: "visible", format: "text", strip_ansi: true }).read; - if (typeof view?.text !== "string") deny("KADAN_HERDR_INPUT_UNKNOWN", "현재 화면 미확인"); - return { record, pane, visible: view }; - } - const frame = inputFrame(socket(), pane.terminal_id, pane.scroll?.viewport_rows); - const visible = call("pane.read", { pane_id: pane.pane_id, source: "visible", format: "ansi", strip_ansi: false }).read; - if (typeof visible?.text !== "string" || visible.truncated) deny("KADAN_HERDR_INPUT_UNKNOWN", "현재 입력 화면 미확인"); - try { - checkEmptyInput(session, () => visible.text.replaceAll("\r\n", "\n"), frame.row, null, "herdr 전송 보류"); - } catch (error) { error.delivery = 'not-sent'; throw error; } - return { record, pane, visible, frame }; + // 화면 모양으로 미제출 글을 판정하지 않는다(2026-10-17 [kyle]). 읽은 화면은 알림의 30초 안정 검사 지문에만 쓴다. + const visible = call("pane.read", { pane_id: pane.pane_id, source: "visible", format: "text", strip_ansi: true }).read; + if (typeof visible?.text !== "string") deny("KADAN_HERDR_INPUT_UNKNOWN", "현재 화면 미확인"); + return { record, pane, visible }; }; const checkNotification = herdrNotificationGuard(session => checkAgent(session)); // 시작 직후 몇 초 동안만 본다. 신뢰 화면이면 사람에게 알리고, 입력 대기가 되거나 시간이 지나면 조용히 끝낸다. @@ -254,7 +214,7 @@ export function makeHerdrFloor({ env = process.env, rpc = herdrRpc, entries = re send(session, text, { cardIdentity, notificationGuard, busyMail } = {}) { const allowWorking = busyMail === true && !cardIdentity; const forced = notificationGuard?.force === true && !cardIdentity; - const check = () => forced ? checkAgent(session, undefined, { allowWorking: true, skipInput: true }) + const check = () => forced ? checkAgent(session, undefined, { allowWorking: true }) : notificationGuard ? checkNotification(session, notificationGuard) : checkAgent(session, cardIdentity, { allowWorking }); const { pane, visible } = check(); // 알림에는 완료 마커 기준점이 필요 없다. 긴 과거 출력 때문에 깨움까지 막지 않는다. diff --git a/src/floor-tmux.mjs b/src/floor-tmux.mjs index 7d0029ee..8a58522b 100644 --- a/src/floor-tmux.mjs +++ b/src/floor-tmux.mjs @@ -6,6 +6,7 @@ import { matchesAiProcess } from "./ai-identity.mjs"; import { ledgerHome } from "./ledger.mjs"; import { assertNotLiveFloor } from "./live-floor-guard.mjs"; import { assertRottieArgv } from "./floor-rottie.mjs"; +import { HUMAN_IDLE_MS } from "./notification-guard.mjs"; export const SOCKET = process.env.KADAN_SOCKET || process.env.KADAN_LITE_SOCKET || "kadan"; @@ -441,89 +442,25 @@ export function sendTmuxEnter(session, {run = tmuxOut} = {}) { return {keyDelivery:"sent", inputAcceptance:"unconfirmed"}; } -// 카드 입력창 판별. 추측으로 넣지 않고 실제 화면에서 확인한 형태만 둔다(2026-09-23 실측). -// - 빈 입력 자리표시자: devin v3000.11.1, codex v0.155.0·v0.163.0. 작업 중 자리표시자(Guide Devin…)는 빈 입력이 아니다. -// - 입력 상자 경계(devin·claude): 0열에서 pane 폭을 꽉 채우는 가로줄. 입력의 이어지는 줄은 들여쓰기되므로 -// 사용자 글이 이 모양이 될 수 없다. 윗줄에는 devin처럼 `(bypass permissions on)` 표지가 섞인다. -const PROMPT_LINE = /^\s*[│┃]?[ \t]*[›>❯❭](.*)$/u; -const EMPTY_INPUT_PLACEHOLDERS = new Set(["Ask Devin to build features, fix bugs, or work on your code", "Ask Codex to do anything"]); -// claude-code v2.1.280: 새 창 입력창에 흐린 글씨(SGR 2)로 `Try "…"` 예시가 뜬다. 문구는 매번 바뀌어 모양으로만 맞춘다(2026-09-23 실측). -const CLAUDE_PLACEHOLDER = /^Try "[^"\n]+"$/u; -const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/gu; -// 입력칸 글이 모두 흐린 글씨(SGR 2)면 실행기가 띄운 예시·추천 문구다. 사람이 친 글은 흐리지 않다. -// claude-code는 새 창의 `Try "…"` 예시와 작업 뒤 "다음 입력 추천"을 흐리게 띄운다(2026-09-23 capture -e 실측). -function allDimAfterPrompt(raw = "") { - let dim = false, prompt = false, seen = false; - for (let i = 0; i < raw.length;) { - const sgr = raw.slice(i).match(/^\x1b\[([0-9;?]*)([A-Za-z])/u); - if (sgr) { - if (sgr[2] === "m") for (const code of (sgr[1] || "0").split(";")) { - if (code === "2") dim = true; - else if (code === "" || code === "0" || code === "22") dim = false; - } - i += sgr[0].length; - continue; - } - const ch = String.fromCodePoint(raw.codePointAt(i)); - i += ch.length; - if (!prompt) { prompt = /[›>❯❭]/u.test(ch); continue; } - if (/\s/u.test(ch)) continue; - if (!dim) return false; - seen = true; - } - return seen; -} -// codex-cli 0.163.0부터 빈 입력창 아래 바닥글이 두 줄이다(2026-10-17 격리 소켓 실측, 160x40): -// ` GPT-6-Astra high · ~/Dev/kadan · main · Context 0% used · weekly 36% left` -// ` ← for agents · ? for shortcuts ⚠ 1 warning · f2 to view` -// 둘째 줄은 단축키 안내다. 안정된 부분은 `? for shortcuts`이고 경고 조각은 경고가 있을 때만 붙는다. -// 글 모양으로만 판단한다. 안내 줄이 흐린 글씨인지는 받은 사본에 없어 기대지 않는다. -const CODEX_HINT_PART = /^(?:← for agents|\? for shortcuts|⚠ \d+ warnings?|f2 to view)$/u; -const isCodexHintLine = line => { - const parts = line.trim().split(/\s{2,}| · /u).map(part => part.trim()).filter(Boolean); - return parts.includes("? for shortcuts") && parts.every(part => CODEX_HINT_PART.test(part)); -}; -const isInputRule = (line, width, labelled) => [...line].length === width && (labelled ? /^─.*─$/u : /^─+$/u).test(line); - -// 화면에 보이는 입력창이 비었는지 확인한다. 카드 전송과 감시기 알림이 같은 기준을 쓴다. -// width가 숫자면 바닥이 준 폭과 테두리 길이가 정확히 같아야 한다(tmux·Rottie). -// width가 null이면 바닥의 폭 숫자를 믿지 않고, 위·아래 테두리가 같은 길이이면서 -// 가져온 화면에서 가장 긴 줄과 같을 때만 입력창 테두리로 본다(herdr: 접속 창 크기와 폭 숫자가 어긋난다). -export function checkEmptyInput(session, run, row, width, label) { +// 사람 보호는 이것 하나다(2026-10-17 [kyle] 결정). 화면 모양(프롬프트·자리표시자·테두리·바닥글)을 읽어 +// 미제출 글을 판정하던 장치는 실행기 업데이트마다 빈 입력창을 오판해 발령과 알림을 막아 없앴다. +// 창에 붙은 사람이 humanIdleMs 안에 키를 눌렀으면 보류한다. 붙은 client가 없으면 통과한다. +// 받아들인 손해: 사람이 글을 치다 그 시간 넘게 손을 떼면 그 글 뒤에 우편이 붙어 제출될 수 있다. +// kadan의 붙여넣기(paste-buffer)는 client_activity를 바꾸지 않는다(키 입력만 센다). +function checkHumanIdle(session, run, {humanIdleMs = HUMAN_IDLE_MS, now} = {}) { const deny = (code, message) => { const error = new Error(message); error.code = code; throw error; }; - // 글자 모양(-e)까지 읽어 흐린 추천 문구와 사람이 친 글을 가른다. 판단은 모양을 뺀 글로 한다. - const raw = run(["capture-pane", "-e", "-p", "-t", `=${session}:`]).split("\n"); - const lines = raw.map(line => line.replace(ANSI, "")); - if (row >= lines.length) deny("KADAN_PANE_INPUT_UNKNOWN", `${label}: 입력 줄 미확인`); - // 입력 줄은 커서가 있는 프롬프트 줄 하나다. 화면 전체를 훑으면 배너(`│ >_ OpenAI Codex`)나 - // 본문 인용까지 후보가 되어 실제 대기 화면을 거부한다(2026-09-23 devin·claude·codex 실측). - const prompt = lines[row].match(PROMPT_LINE); - if (!prompt) { - if (lines.slice(0, row).some(line => PROMPT_LINE.test(line))) deny("KADAN_PANE_INPUT_PENDING", `${label}: 커서가 프롬프트 줄 밖이라 여러 줄 미제출 입력일 수 있음`); - deny("KADAN_PANE_INPUT_UNKNOWN", `${label}: 현재 입력 영역의 프롬프트 미확인`); - } - // 위쪽 가장 가까운 글 줄도 프롬프트 모양이면 여러 줄 입력의 이어지는 줄인지 구별할 수 없다. - if (PROMPT_LINE.test(lines.slice(0, row).findLast(line => line.trim()) ?? "")) deny("KADAN_PANE_INPUT_UNKNOWN", `${label}: 프롬프트 위 입력 경계 미확인`); - const ruleWidth = width ?? Math.max(...lines.map(line => [...line].length)); - const boxed = row > 0 && isInputRule(lines[row - 1], ruleWidth, true); - const rest = prompt[1].trim(); - if (rest && !EMPTY_INPUT_PLACEHOLDERS.has(rest) && !CLAUDE_PLACEHOLDER.test(rest) && !allDimAfterPrompt(raw[row])) deny("KADAN_PANE_INPUT_PENDING", `${label}: 미제출 입력이 있음`); - // 상자형(devin·claude)은 바로 아래 닫는 줄 밑을 상태줄로 본다. 상자 없음(codex)은 빈 줄 뒤 상태줄 한 줄만 허용한다. - // 단 상태줄에 ` · `가 있고 바로 다음 줄이 단축키 안내(0.163.0)이면 그 한 줄까지 허용한다. 그 뒤는 빈 줄뿐이어야 한다. - const below = lines.slice(row + 1); - const footer = below.findIndex(line => line.trim()); - const tail = footer + (below[footer + 1] !== undefined && below[footer]?.includes(" · ") && isCodexHintLine(below[footer + 1]) ? 2 : 1); - const closed = boxed ? isInputRule(below[0] ?? "", ruleWidth, false) - : footer < 0 || (footer > 0 && below.slice(tail).every(line => !line.trim())); - if (!closed) deny("KADAN_PANE_INPUT_PENDING", `${label}: 미제출 입력 또는 빈 입력창으로 확인할 수 없는 화면`); -} - -// 카드만 검사한다. 일반 우편/Enter 전용 경로와 기존 복사모드 검사는 그대로 둔다. + const clients = run(["list-clients", "-t", `=${session}`, "-F", "#{client_activity}"]).split("\n").filter(Boolean); + if (clients.some(value => !/^\d+$/.test(value))) deny("KADAN_HUMAN_ACTIVITY_UNKNOWN", "전송 보류: 사람 입력 시각 미확인 — 잠시 뒤 다시 보내라"); + const at = now ?? Date.now(); + if (clients.some(value => at - Number(value) * 1000 < humanIdleMs)) deny("KADAN_HUMAN_ACTIVE", "전송 보류: 사람이 이 창에서 최근 입력함 — 잠시 뒤 다시 보내라"); +} + +// 카드는 pane 종료·PID·AI 프로세스를 확인하고 사람 입력 시각을 본다. 기존 복사모드 검사는 그대로 둔다. function checkCardPane(session, identity, run, spawn) { const deny = (code, message) => { const error = new Error(message); error.code = code; throw error; }; - const state = run(["display-message", "-p", "-t", `=${session}:`, "#{pane_pid}|#{pane_dead}|#{cursor_y}|#{cursor_x}|#{pane_width}"]).trim(); - if (!/^\d+\|[01]\|\d+\|\d+\|\d+$/.test(state)) deny("KADAN_PANE_STATE_UNKNOWN", "카드 전송 불가: pane 상태 미확인"); - const [pid, dead, y, , w] = state.split("|"); + const state = run(["display-message", "-p", "-t", `=${session}:`, "#{pane_pid}|#{pane_dead}"]).trim(); + if (!/^\d+\|[01]$/.test(state)) deny("KADAN_PANE_STATE_UNKNOWN", "카드 전송 불가: pane 상태 미확인"); + const [pid, dead] = state.split("|"); if (dead !== "0") deny("KADAN_PANE_DEAD", "카드 전송 불가: 종료된 pane"); if (pid !== String(identity.currentPid)) deny("KADAN_PID_MISMATCH", "카드 전송 불가: 검사 중 pane 세대 변경"); const ps = spawn("ps", ["-ww", "-axo", "pid=,ppid=,pgid=,tpgid=,args="], {encoding:"utf8"}); @@ -538,23 +475,14 @@ function checkCardPane(session, identity, run, spawn) { if (!(Number(foreground) > 0) || !rows.some(row => descendants.has(row[1]) && row[3] === foreground && matchesAiProcess(row[5], identity.harness, identity.model))) { deny("KADAN_AI_PROCESS_UNVERIFIED", "카드 전송 불가: 현재 pane 프로세스의 AI 실행기·명시 모델이 시작 기록과 다르거나 미확인이다"); } - checkEmptyInput(session, run, Number(y), Number(w), "카드 전송 불가"); + checkHumanIdle(session, run); } -// 감시기 알림은 사람의 입력을 덮지 않는다(2026-09-23 미확인 우편 알림이 kyle의 입력 중 문장에 붙어 제출된 사고). -// 창에 붙은 사람이 최근 키를 눌렀거나 입력창에 글이 있으면 보류한다. kadan 붙여넣기는 client 활동에 잡히지 않는다. +// 감시기 알림도 사람의 입력을 덮지 않는다(2026-09-23 미확인 우편 알림이 kyle의 입력 중 문장에 붙어 제출된 사고). function checkNotificationPane(session, guard, run) { // 감시기가 5번 연속 밀린 알림을 입력 보호 없이 넣는 차례다(2026-10-13). 기록보기 확인은 sendTmux가 따로 한다. if (guard.force === true) return; - const deny = (code, message) => { const error = new Error(message); error.code = code; throw error; }; - const state = run(["display-message", "-p", "-t", `=${session}:`, "#{cursor_y}|#{pane_width}"]).trim(); - if (!/^\d+\|\d+$/.test(state)) deny("KADAN_PANE_STATE_UNKNOWN", "알림 미룸: pane 상태 미확인"); - const [y, w] = state.split("|"); - const clients = run(["list-clients", "-t", `=${session}`, "-F", "#{client_activity}"]).split("\n").filter(Boolean); - if (clients.some(value => !/^\d+$/.test(value))) deny("KADAN_HUMAN_ACTIVITY_UNKNOWN", "알림 미룸: 사람 입력 시각 미확인"); - const now = guard.now ?? Date.now(); - if (clients.some(value => now - Number(value) * 1000 < guard.humanIdleMs)) deny("KADAN_HUMAN_ACTIVE", "알림 미룸: 사람이 이 창에서 최근 입력함"); - checkEmptyInput(session, run, Number(y), Number(w), "알림 미룸"); + checkHumanIdle(session, run, guard); } // 붙여넣지 않고 알림 보류 사유만 돌려준다. 감시기가 예약 기록 전에 확인해 순회마다 기록이 쌓이지 않게 한다. @@ -568,18 +496,22 @@ export function sendTmux(session, text, {run = tmuxOut, spawn = spawnSync, pid = let stage = "not-started"; let loaded = false; const checkMode = () => checkPaneMode(session, run); + // 카드·알림·일반 우편이 같은 사람 입력 시각 기준을 쓴다. 일반 우편은 알림 보호 객체가 없으면 기본 30초다. + const checkSendPane = () => { + if (cardIdentity) checkCardPane(session, cardIdentity, run, spawn); + else if (notificationGuard) checkNotificationPane(session, notificationGuard, run); + else checkHumanIdle(session, run); + }; let released = false; try { // 감시기 알림이 5번 밀린 강제 차례에 한해 복사 모드를 해제하고 넣는다(2026-10-14). if (notificationGuard?.force === true && !cardIdentity) released = checkPaneMode(session, run, {release: true}); else checkMode(); - if (cardIdentity) checkCardPane(session, cardIdentity, run, spawn); - else if (notificationGuard) checkNotificationPane(session, notificationGuard, run); + checkSendPane(); run(["load-buffer", "-b", buffer, "-"], text); loaded = true; checkMode(); - if (cardIdentity) checkCardPane(session, cardIdentity, run, spawn); - else if (notificationGuard) checkNotificationPane(session, notificationGuard, run); + checkSendPane(); stage = "paste-attempted"; // -p: 괄호 붙여넣기. 없으면 Claude Code가 본문 줄바꿈과 뒤따르는 Enter를 구분하지 못해 // 짧은 본문에서 Enter가 먹힌다(2026-09-23 실측: 2초 뒤 Enter도 누락, -p면 0.4초도 제출). diff --git a/src/herdr-notification.mjs b/src/herdr-notification.mjs index 622a6ac6..f6d95f7b 100644 --- a/src/herdr-notification.mjs +++ b/src/herdr-notification.mjs @@ -1,8 +1,8 @@ import { createHash } from 'node:crypto'; import { HUMAN_IDLE_MS } from './notification-guard.mjs'; -// Herdr 0.9.3에는 사람 입력 시각 API가 없다. 빈 입력창·대기 상태의 화면이 -// 30초 이상 같을 때만 알린다. 관측 실패나 프로세스 교체 뒤에는 유예를 새로 시작한다. +// Herdr 0.9.3에는 사람 입력 시각 API가 없다. 대기 상태의 화면(글자·revision)이 +// 30초 이상 같을 때만 알린다. 입력창 모양·커서 행은 지문에 넣지 않는다(2026-10-17). 관측 실패나 프로세스 교체 뒤에는 유예를 새로 시작한다. // 서버의 원자적인 입력 잠금은 아니므로 실제 전송 직전에도 같은 검사를 반복한다. export function herdrNotificationGuard(observe) { const quiet = new Map(); @@ -20,7 +20,7 @@ export function herdrNotificationGuard(observe) { } const signature = createHash('sha256').update(JSON.stringify([ view.record.herdrSocket, view.pane.terminal_id, view.record.panePid, - view.pane.pane_id, view.visible.revision, view.visible.text, view.frame.row, + view.pane.pane_id, view.visible.revision, view.visible.text, ])).digest('hex'); let previous = quiet.get(session); if (!previous || previous.signature !== signature || now < previous.at) { @@ -28,7 +28,7 @@ export function herdrNotificationGuard(observe) { quiet.set(session, previous); } if (now - previous.at < duration) { - throw Object.assign(new Error('알림 미룸: 빈 입력창과 대기 화면의 30초 안정 확인 중'), { + throw Object.assign(new Error('알림 미룸: 대기 화면의 30초 안정 확인 중'), { code: 'KADAN_HUMAN_ACTIVE', delivery: 'not-sent', }); } diff --git a/src/notification-guard.mjs b/src/notification-guard.mjs index 2665e068..dad29981 100644 --- a/src/notification-guard.mjs +++ b/src/notification-guard.mjs @@ -2,6 +2,7 @@ // 서버 응답 유실·PID 변경·기록 실패는 재전송 근거가 아니다. export const HUMAN_IDLE_MS = 30_000; +// KADAN_PANE_INPUT_PENDING·KADAN_PANE_INPUT_UNKNOWN은 화면 모양 판정을 없애기(2026-10-17) 전 옛 원장 기록을 표시하는 용도로만 남긴다. const holdCodes = new Set([ 'KADAN_HUMAN_ACTIVE', 'KADAN_HUMAN_ACTIVITY_UNKNOWN', 'KADAN_PANE_INPUT_PENDING', 'KADAN_PANE_INPUT_UNKNOWN', 'KADAN_PANE_STATE_UNKNOWN', diff --git a/tests/ai-identity-boundaries-cli.test.mjs b/tests/ai-identity-boundaries-cli.test.mjs index dec2902c..249f9ef0 100644 --- a/tests/ai-identity-boundaries-cli.test.mjs +++ b/tests/ai-identity-boundaries-cli.test.mjs @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import os from 'node:os'; import fs from 'node:fs'; import path from 'node:path'; -import {spawnSync} from 'node:child_process'; +import {spawn,spawnSync} from 'node:child_process'; import {CardStore} from '../src/card-store.mjs'; import {readLedger,readTaskLedger} from '../src/ledger.mjs'; import {createDatabase,writeStorageMarker} from '../src/storage.mjs'; @@ -24,18 +24,19 @@ for(const backend of ['jsonl','sqlite']){ if(backend==='sqlite'){createDatabase(home);writeStorageMarker(home,'sqlite');} const bin=path.join(home,'bin');fs.mkdirSync(bin); const trace=path.join(home,'tmux.jsonl'); - fs.writeFileSync(path.join(bin,'tmux'),`#!${process.execPath}\nimport fs from 'node:fs';import {spawnSync} from 'node:child_process';const a=process.argv.slice(2);fs.appendFileSync(${JSON.stringify(trace)},JSON.stringify(a)+'\\n');const r=spawnSync(${JSON.stringify(realTmux)},a,{stdio:'inherit'}); + fs.writeFileSync(path.join(bin,'tmux'),`#!${process.execPath}\nimport fs from 'node:fs';import {spawn,spawnSync} from 'node:child_process';const a=process.argv.slice(2);fs.appendFileSync(${JSON.stringify(trace)},JSON.stringify(a)+'\\n');const r=spawnSync(${JSON.stringify(realTmux)},a,{stdio:'inherit'}); const raceFile=${JSON.stringify(path.join(home,'race.json'))}; if(a.includes('load-buffer')&&fs.existsSync(raceFile)) { const race=JSON.parse(fs.readFileSync(raceFile,'utf8')); if(race.active) { fs.writeFileSync(raceFile,JSON.stringify({...race,active:false})); - spawnSync(${JSON.stringify(realTmux)},['-L',process.env.KADAN_SOCKET,'send-keys','-t','kadan-'+race.role,...race.keys]); + if(race.attach)spawn('sh',['-c',"sleep 6 | '"+${JSON.stringify(realTmux)}+"' -L '"+process.env.KADAN_SOCKET+"' -C attach -t =kadan-"+race.role],{detached:true,stdio:'ignore'}).unref(); + else spawnSync(${JSON.stringify(realTmux)},['-L',process.env.KADAN_SOCKET,'send-keys','-t','kadan-'+race.role,...race.keys]); const end=Date.now()+3000;let observed=false; while(Date.now()tm('list-clients','-t','=kadan-resume','-F','#{client_activity}').stdout.trim(),v=>v!=='','사람 client'); + const humanProbe=probe('human-active','resume',['--task','resume']); + assert.match(humanProbe.error,/사람이 이 창에서 최근 입력/); + assert.doesNotMatch(tm('capture-pane','-p','-t','kadan-resume').stdout,/REVIEW_BODY_human-active/); start('dead',bin+'/codex --model model-a');tm('set-option','-t','kadan-dead','remain-on-exit','on');tm('send-keys','-t','kadan-dead','C-c');waitFor(()=>tm('display-message','-p','-t','kadan-dead','#{pane_dead}').stdout.trim(),dead=>dead==='1','dead pane');probe('dead-pane','dead',['--task','dead']); start('spoof',bin+'/codex --version; exec cat # --model model-a',false);probe('version-then-cat','spoof',['--execution','r/spoof']); const replaced=path.join(home,'replaced');fs.mkdirSync(replaced); fs.writeFileSync(path.join(replaced,'codex'),'#!/bin/sh\nexec cat\n',{mode:0o755}); start('replaced',replaced+'/codex --model model-a',false);probe('replaced-process','replaced',['--task','replaced']); - // 실제 입력 버퍼를 두 줄로 편집하고 커서를 위쪽 빈 줄로 옮긴다. - const editorDir=path.join(home,'editor');fs.mkdirSync(editorDir); - fs.writeFileSync(path.join(editorDir,'codex'),`#!${process.execPath} -import fs from 'node:fs'; -const file=process.env.KADAN_HOME+'/editor-state.json'; -let lines=[''],row=0; -const paint=()=>{process.stdout.write('\\x1b[2J\\x1b[H› '+lines.join('\\r\\n')+'\\x1b['+(row+1)+';'+(row===0?3:1)+'H');fs.writeFileSync(file,JSON.stringify({lines,row}));}; -process.stdin.setRawMode(true);process.stdin.resume(); -process.stdin.on('data',b=>{const data=b.toString();if(data==='\\x1b[A')row=Math.max(0,row-1);else for(const c of data){if(c==='\\n'){lines.splice(row+1,0,'');row++;}else if(c==='\\r'){fs.appendFileSync(file+'.submitted',JSON.stringify(lines)+'\\n');lines=[''];row=0;}else if(c==='\\u0003'){lines=[''];row=0;}else lines[row]+=c;}paint();});paint(); -`,{mode:0o755}); - const waitState=expected=>{const end=Date.now()+10000;while(Date.now()','❯'].map((prompt,i)=>['fake-prompt-'+i,['USER_UNSUBMITTED',prompt+' '],0]), - ['fake-border',['','──────','USER_BELOW_BORDER'],2], - ...['│','┃','│ │','┃ ┃','│┃',' │ ',' ┃ '].flatMap((text,i)=>[ - ['vertical-first-'+i,[text],0], - ['vertical-below-'+i,['',text],1], - ]), - ]) { - for(let i=0;i inspectCardSendIdentity({entries:[{kind:'start', session:'kadan-r', panePid:'11', cmd}], session:'kadan-r', currentPid:'11'}); @@ -40,114 +40,128 @@ test('프로세스 실행 파일/스크립트 자리만 실행기 이름으로 } }); -const rule = (label='') => label ? '─'.repeat(40-label.length-3)+' '+label+' ─' : '─'.repeat(40); -const realDevin = (input='❭ Ask Devin to build features, fix bugs, or work on your code') => ` Devin CLI\n\n${rule('(bypass permissions on)')}\n${input}\n${rule()}\nSWE-2 Max ctrl+v to paste image\n\n`; -const realClaude = (input='❯ ') => `⏺ 이전 답변\n${rule()}\n${input}\n${rule()}\n ~/Dev/x[main]\n Opus 5.5 | ctx:26%\n ⏵⏵ bypass permissions on\n`; - -// codex-cli 0.163.0 실측 화면(2026-10-17, 160x40): 프롬프트 36행, 37~39행이 빈 줄·상태줄·안내 줄. -const codex163Status = ' GPT-6-Astra high · ~/Dev/kadan · main · Context 0% used · weekly 36% left'; -const codex163Hint = (warn = true) => { - const left = ' ← for agents · ? for shortcuts'; - const right = '⚠ 1 warning · f2 to view'; - return warn ? left + ' '.repeat(160 - left.length - right.length) + right : left; -}; -const codex163 = (...below) => [...Array(36).fill('old output'), '› Ask Codex to do anything', ...below].join('\n'); - -function fixture({state='11|0|0|0', width=40, screen='› ', ps='11 1 11 11 /opt/bin/codex --model test-model', afterLoad, psStatus=0} = {}) { +// 화면 모양(프롬프트·자리표시자·테두리·바닥글)은 보지 않는다(2026-10-17 [kyle]). 사람 보호는 client_activity 하나다. +// 가짜 run은 kadan 자신의 paste-buffer가 client_activity를 바꾸지 않는다는 전제를 따른다(실제 tmux도 키 입력만 센다). +const nowSec = () => Math.floor(Date.now() / 1000); +function fixture({state='11|0', ps='11 1 11 11 /opt/bin/codex --model test-model', clients='', afterLoad, psStatus=0, screen='› 쓰던 글', card=true} = {}) { const calls=[]; let loaded=false; - return {calls, options:{cardIdentity:identity, pid:1, hrtime:()=>1n, + return {calls, options:{...(card ? {cardIdentity:identity} : {}), pid:1, hrtime:()=>1n, run(args) { calls.push(args); if(args.includes('#{pane_in_mode}')) return '0'; - if(args.includes('#{pane_pid}|#{pane_dead}|#{cursor_y}|#{cursor_x}|#{pane_width}')) return `${loaded && afterLoad?.state || state}|${width}`; - if(args[0]==='capture-pane') return loaded && afterLoad?.screen !== undefined ? afterLoad.screen : screen; + if(args.includes('#{pane_pid}|#{pane_dead}')) return loaded && afterLoad?.state || state; + if(args[0]==='list-clients') return loaded && afterLoad?.clients !== undefined ? afterLoad.clients : clients; + if(args[0]==='capture-pane') return screen; if(args[0]==='load-buffer') loaded=true; return ''; }, spawn(bin) {return bin==='ps' ? {status:psStatus,stdout:ps} : {status:0};}, }}; } +const count = (f, name) => f.calls.filter(a=>a[0]===name).length; for (const [name, config, code] of [ ['현재 프로세스가 cat으로 교체됨', {ps:'11 1 11 11 cat\n22 1 22 22 codex --model x'}, 'KADAN_AI_PROCESS_UNVERIFIED'], ['AI가 뒤에서만 남고 cat이 입력을 받음', {ps:'11 1 11 11 cat\n12 11 12 11 codex --model x'}, 'KADAN_AI_PROCESS_UNVERIFIED'], - ['죽은 pane', {state:'11|1|0|0'}, 'KADAN_PANE_DEAD'], - ['미제출 입력', {state:'11|0|0|13',screen:'› USER_PENDING_'}, 'KADAN_PANE_INPUT_PENDING'], - ['커서를 맨 앞으로 옮긴 미제출 입력', {screen:'› USER_PENDING_'}, 'KADAN_PANE_INPUT_PENDING'], - ['여러 줄 입력', {state:'11|0|2|0',screen:'› first\nsecond\n'}, 'KADAN_PANE_INPUT_PENDING'], - ['커서 아래 미제출 입력', {screen:'› \nSECOND_PENDING'}, 'KADAN_PANE_INPUT_PENDING'], - ...['│', '┃', '│ │', '┃ ┃', '│┃', ' │ ', ' ┃ '].flatMap(text => [ - ['첫 줄 세로 문자 '+JSON.stringify(text), {screen:'› '+text}, 'KADAN_PANE_INPUT_PENDING'], - ['커서 아래 세로 문자 '+JSON.stringify(text), {screen:'› \n'+text}, 'KADAN_PANE_INPUT_PENDING'], - ]), - ['테두리인지 입력인지 미확인', {screen:'│ › │'}, 'KADAN_PANE_INPUT_PENDING'], - ['버퍼 준비 중 세로 문자 입력', {afterLoad:{screen:'› │'}}, 'KADAN_PANE_INPUT_PENDING'], - ...['›', '>', '❯'].map(prompt => ['본문 속 가짜 프롬프트 '+prompt, {state:'11|0|1|2',screen:'› USER_UNSUBMITTED\n'+prompt+' '}, 'KADAN_PANE_INPUT_UNKNOWN']), - ['본문 속 가짜 구분선', {screen:'› \n──────\nUSER_BELOW_BORDER'}, 'KADAN_PANE_INPUT_PENDING'], - ['본문 끝의 구분선도 빈 입력이 아니다', {screen:'› \n──────'}, 'KADAN_PANE_INPUT_PENDING'], - ['테두리 안 커서 아래 미제출 입력', {state:'11|0|1|2',screen:'╭────╮\n│ › │\n│ SECOND_PENDING │\n╰────╯\nstatus'}, 'KADAN_PANE_INPUT_PENDING'], - // 2026-09-23 실측 화면 모양(폭 40으로 축소). 빈 입력 판별이 실제 TUI에서 풀려도 아래는 계속 거부한다. - ['claude 입력창에 미제출 입력', {state:'11|0|2|12',screen:realClaude('❯ 업무는 내가 완료 처리할게')}, 'KADAN_PANE_INPUT_PENDING'], - ['Try 예시 뒤에 이어 쓴 입력', {state:'11|0|2|2',screen:realClaude('❯ Try "x" 그리고 이것도')}, 'KADAN_PANE_INPUT_PENDING'], - ['흐리지 않은 같은 문장은 사람이 친 글', {state:'11|0|2|2',screen:realClaude('\x1b[39m❯\u00a0\x1b[0m3단계 진행해')}, 'KADAN_PANE_INPUT_PENDING'], - ['흐린 추천 뒤에 사람이 이어 친 글', {state:'11|0|2|2',screen:realClaude('❯ \x1b[2m3단계 진행해\x1b[0m 지금')}, 'KADAN_PANE_INPUT_PENDING'], - ['흐림을 22로 끈 뒤의 글', {state:'11|0|2|2',screen:realClaude('❯ \x1b[2m추천\x1b[22m사람')}, 'KADAN_PANE_INPUT_PENDING'], - ['claude 입력 상자 안 둘째 줄 미제출 입력', {state:'11|0|2|2',screen:'out\n'+rule()+'\n❯ \n SECOND_PENDING\n'+rule()+'\n status'}, 'KADAN_PANE_INPUT_PENDING'], - ['devin 작업 중 자리표시자', {state:'11|0|3|2',screen:realDevin('❭ Guide Devin while it works')}, 'KADAN_PANE_INPUT_PENDING'], - ['pane 폭보다 짧은 가로줄은 입력 상자가 아니다', {state:'11|0|1|2',screen:'─'.repeat(30)+'\n❯ \n'+'─'.repeat(30)}, 'KADAN_PANE_INPUT_PENDING'], - ['빈 줄 건너 위쪽 프롬프트에 미제출 입력', {state:'11|0|2|2',screen:'› USER_UNSUBMITTED\n\n› '}, 'KADAN_PANE_INPUT_UNKNOWN'], - ['codex 상태줄 위 미제출 둘째 줄', {state:'11|0|1|2',screen:'\n› \n SECOND_PENDING\n\n gpt-6-astra medium · ~/Dev/x'}, 'KADAN_PANE_INPUT_PENDING'], - ['codex 상태줄 뒤 추가 줄', {state:'11|0|1|2',screen:'\n› \n\n gpt-6-astra medium · ~/Dev/x\n EXTRA'}, 'KADAN_PANE_INPUT_PENDING'], - ['codex 0.163 프롬프트 바로 아래 글(빈 줄 없음)', {state:'11|0|36|2',width:160,screen:codex163(codex163Status, codex163Hint())}, 'KADAN_PANE_INPUT_PENDING'], - ['codex 0.163 상태줄 + 안내 문구가 아닌 보통 글', {state:'11|0|36|2',width:160,screen:codex163('', codex163Status, ' SECOND_PENDING')}, 'KADAN_PANE_INPUT_PENDING'], - ['codex 0.163 상태줄 + 안내 줄 + 셋째 글 줄', {state:'11|0|36|2',width:160,screen:codex163('', codex163Status, codex163Hint(), ' THIRD_PENDING')}, 'KADAN_PANE_INPUT_PENDING'], - ['codex 안내 문구지만 첫 줄에 가운뎃점이 없음', {state:'11|0|36|2',width:160,screen:codex163('', ' 사람이 쓴 초안', codex163Hint())}, 'KADAN_PANE_INPUT_PENDING'], - ['프롬프트 없는 빈 화면', {screen:''}, 'KADAN_PANE_INPUT_UNKNOWN'], + ['죽은 pane', {state:'11|1'}, 'KADAN_PANE_DEAD'], + ['pane 세대(PID) 변경', {state:'12|0'}, 'KADAN_PID_MISMATCH'], + ['pane 상태 미확인', {state:'?'}, 'KADAN_PANE_STATE_UNKNOWN'], ['실제 모델 불일치', {ps:'11 1 11 11 codex --model other'}, 'KADAN_AI_PROCESS_UNVERIFIED'], ['실제 모델 미확인', {ps:'11 1 11 11 codex'}, 'KADAN_AI_PROCESS_UNVERIFIED'], - ['입력 상태 미확인', {state:'11|0|9|0',screen:''}, 'KADAN_PANE_INPUT_UNKNOWN'], ['프로세스 조회 실패', {psStatus:1}, 'KADAN_AI_PROCESS_UNKNOWN'], - ['버퍼 준비 중 죽음', {afterLoad:{state:'11|1|0|0'}}, 'KADAN_PANE_DEAD'], - ['버퍼 준비 중 사용자 입력', {afterLoad:{screen:'› new input'}}, 'KADAN_PANE_INPUT_PENDING'], + ['버퍼 준비 중 죽음', {afterLoad:{state:'11|1'}}, 'KADAN_PANE_DEAD'], + ['사람 입력 시각 미확인', {clients:'x'}, 'KADAN_HUMAN_ACTIVITY_UNKNOWN'], + ['버퍼 준비 중 사람이 키를 누름', {afterLoad:{clients:String(nowSec() - 1)}}, 'KADAN_HUMAN_ACTIVE'], ]) test(`붙여넣기 전 거부: ${name}`, () => { const f=fixture(config); assert.throws(()=>sendTmux('kadan-r','CARD',f.options), e=>e.code===code && e.delivery==='not-sent'); - assert.equal(f.calls.filter(a=>a[0]==='paste-buffer').length,0); - assert.equal(f.calls.filter(a=>a[0]==='send-keys').length,0); + assert.equal(count(f,'paste-buffer'),0); + assert.equal(count(f,'send-keys'),0); }); -test('빈 프롬프트·현재 pane 자식 AI는 허용하고 일반 우편은 카드 검사를 거치지 않는다', () => { - const f=fixture({screen:'› ',state:'11|0|0|2',ps:'11 1 11 12 /bin/sh\n12 11 12 12 /usr/bin/node /opt/bin/codex.js --model test-model'}); +test('현재 pane 자식 AI는 허용하고 일반 우편은 프로세스 검사를 거치지 않는다', () => { + const f=fixture({ps:'11 1 11 12 /bin/sh\n12 11 12 12 /usr/bin/node /opt/bin/codex.js --model test-model'}); sendTmux('kadan-r','CARD',f.options); - assert.equal(f.calls.filter(a=>a[0]==='paste-buffer').length,1); - assert.equal(f.calls.filter(a=>a[0]==='send-keys').length,1); - const mail=fixture({screen:'› USER_PENDING_',ps:'11 1 11 11 cat'}); - delete mail.options.cardIdentity; + assert.equal(count(f,'paste-buffer'),1); + assert.equal(count(f,'send-keys'),1); + const mail=fixture({ps:'11 1 11 11 cat',card:false}); sendTmux('kadan-r','MAIL',mail.options); - assert.equal(mail.calls.filter(a=>a[0]==='paste-buffer').length,1); + assert.equal(count(mail,'paste-buffer'),1); }); -test('실제 대기 화면(devin·claude·codex)의 빈 입력창에는 카드를 붙여넣는다', () => { - for (const [name, state, screen] of [ - ['devin', '11|0|3|2', realDevin()], - ['claude', '11|0|2|2', realClaude()], - ['새 claude 창의 흐린 예시 문구', '11|0|2|2', realClaude('❯ Try "write a test for watch-runner.mjs"')], - ['작업 뒤 흐린 다음 입력 추천(실측 모양)', '11|0|2|2', realClaude('\x1b[39m❯\u00a0\x1b[2m3단계 진행해\x1b[0m')], - ['codex 시작 화면(배너의 >_ 포함)', '11|0|4|2', '╭──────────╮\n│ >_ OpenAI Codex (v0.155.0) │\n╰──────────╯\n\n› Ask Codex to do anything\n\n gpt-6-astra medium · ~/Dev/x\n\n'], - ['codex 0.163 두 줄 바닥글(경고 있음)', '11|0|36|2', codex163('', codex163Status, codex163Hint())], - ['codex 0.163 두 줄 바닥글(경고 없음)', '11|0|36|2', codex163('', codex163Status, codex163Hint(false))], - ['codex 하단 입력창(과거 프롬프트 포함)', '11|0|5|2', 'old\n› earlier message\n\n• answer\n\n› Ask Codex to do anything\n\n gpt-6-astra medium · ~/Dev/x'], - ]) { - const f=fixture({state, screen}); - sendTmux('kadan-r','CARD',f.options); - assert.equal(f.calls.filter(a=>a[0]==='paste-buffer').length,1,name); - } +// 경로 이름 → 보내기 옵션 만들기. 알림은 감시기 보호 객체(now 고정)를 쓰고, 카드·일반 우편은 현재 시각을 쓴다. +const PATHS = { + '카드': {card:true}, + '알림': {card:false, guard:true}, + '일반 우편': {card:false}, +}; +const send = (f, guard) => sendTmux('kadan-r','MAIL',{...f.options, ...(guard ? {notificationGuard:guard} : {})}); + +for (const [path, cfg] of Object.entries(PATHS)) { + const guard = (extra={}) => cfg.guard ? {humanIdleMs:30_000, ...extra} : undefined; + + test(`9/23 사고 재현: kyle이 비서 창에 문장을 치는 중(5초 전 키 입력)이면 ${path}은 붙여넣지 않는다`, () => { + const f=fixture({clients:String(nowSec() - 5), card:cfg.card}); + assert.throws(()=>send(f, guard()), e=>e.code==='KADAN_HUMAN_ACTIVE' && e.delivery==='not-sent'); + assert.equal(count(f,'paste-buffer'),0); + assert.equal(count(f,'send-keys'),0); + }); + + test(`${path}: 60초 전 키 입력·붙은 client 없음은 통과한다`, () => { + for (const clients of [String(nowSec() - 60), '', `${nowSec() - 60}\n${nowSec() - 3600}`]) { + const f=fixture({clients, card:cfg.card}); + send(f, guard()); + assert.equal(count(f,'paste-buffer'),1, clients); + } + }); + + test(`${path}: 붙은 client가 여럿이면 하나라도 최근 입력했을 때 보류한다`, () => { + const f=fixture({clients:`${nowSec() - 3600}\n${nowSec() - 2}`, card:cfg.card}); + assert.throws(()=>send(f, guard()), e=>e.code==='KADAN_HUMAN_ACTIVE'); + assert.equal(count(f,'paste-buffer'),0); + }); + + test(`${path}: 활동 값이 숫자가 아니면 실패 닫힘`, () => { + const f=fixture({clients:'x', card:cfg.card}); + assert.throws(()=>send(f, guard()), e=>e.code==='KADAN_HUMAN_ACTIVITY_UNKNOWN' && e.delivery==='not-sent'); + assert.equal(count(f,'paste-buffer'),0); + }); + + test(`${path}: 화면에 미제출 글처럼 보이는 내용이 있어도 사람 활동이 없으면 보낸다(바뀐 동작)`, () => { + const f=fixture({screen:'› USER_PENDING_\nSECOND_PENDING', clients:'', card:cfg.card}); + send(f, guard()); + assert.equal(count(f,'paste-buffer'),1); + assert.equal(count(f,'capture-pane'),0, '화면 모양은 읽지 않는다'); + }); +} + +test('감시기 알림: 기준 시간은 호출부가 정한다(10분 기준 600초 안 입력은 보류)', () => { + const now=1_000_000_000_000; + const f=fixture({clients:String(now/1000 - 60), card:false}); + assert.throws(()=>sendTmux('kadan-r','MAIL',{...f.options, notificationGuard:{humanIdleMs:600_000, now}}), e=>e.code==='KADAN_HUMAN_ACTIVE'); + const g=fixture({clients:String(now/1000 - 3600), card:false}); + sendTmux('kadan-r','MAIL',{...g.options, notificationGuard:{humanIdleMs:600_000, now}}); + assert.equal(count(g,'paste-buffer'),1); +}); + +test('감시기 강제 알림(force)은 사람 입력 검사를 건너뛴다', () => { + const f=fixture({clients:String(nowSec() - 1), card:false}); + sendTmux('kadan-r','MAIL',{...f.options, notificationGuard:{humanIdleMs:30_000, force:true}}); + assert.equal(count(f,'paste-buffer'),1); + assert.equal(count(f,'list-clients'),0); +}); + +test('붙여넣기 전·후 두 번 사람 입력을 본다', () => { + const f=fixture({card:false}); + sendTmux('kadan-r','MAIL',f.options); + assert.equal(count(f,'list-clients'),2); }); -test('과거 출력과 본문의 프롬프트·상태 줄을 구별할 수 없으면 거부한다', () => { - const f=fixture({state:'11|0|3|4',screen:'old output\n› OLD_PROMPT\n╭────╮\n│ › │\n│ │\n╰────╯\nstatus'}); - assert.throws(()=>sendTmux('kadan-r','CARD',f.options), e=>e.code==='KADAN_PANE_INPUT_PENDING'); - assert.equal(f.calls.filter(a=>a[0]==='paste-buffer').length,0); +test('notificationHold는 붙여넣지 않고 같은 사람 입력 기준으로 보류 사유만 돌려준다', () => { + const hold = clients => tmuxFloor.notificationHold('kadan-r',{humanIdleMs:30_000},{run:args=> + args.includes('#{pane_in_mode}') ? '0' : args[0]==='list-clients' ? clients : ''}); + assert.equal(hold(String(nowSec() - 5)).code,'KADAN_HUMAN_ACTIVE'); + assert.equal(hold(String(nowSec() - 60)),null); }); test('현재 프로세스의 명시 모델은 실행기 자리와 함께 일치해야 한다', () => { @@ -157,30 +171,3 @@ test('현재 프로세스의 명시 모델은 실행기 자리와 함께 일치 assert.equal(matchesAiProcess(args,harness,'y'),false,args); } }); - -test('감시기 알림은 사람이 최근 입력했거나 입력창에 글이 있으면 붙여넣지 않는다', () => { - const notify = ({screen='› ', clients='', now=1_000_000_000}) => { - const calls=[]; - const run=args=>{calls.push(args); - if(args.includes('#{pane_in_mode}')) return '0'; - if(args.includes('#{cursor_y}|#{pane_width}')) return '0|40'; - if(args[0]==='list-clients') return clients; - if(args[0]==='capture-pane') return screen; - return '';}; - return {calls, options:{run, pid:1, hrtime:()=>1n, spawn:()=>({status:0}), notificationGuard:{humanIdleMs:600_000, now}}}; - }; - for (const [name, config, code] of [ - ['10분 안 키 입력', {clients:String(1_000_000 - 60)}, 'KADAN_HUMAN_ACTIVE'], - ['사람 입력 시각 미확인', {clients:'x'}, 'KADAN_HUMAN_ACTIVITY_UNKNOWN'], - ['입력 중 문장', {screen:'› 298 ->이건 굳굳. 근데', clients:String(1_000_000 - 3600)}, 'KADAN_PANE_INPUT_PENDING'], - ]) { - const f=notify(config); - assert.throws(()=>sendTmux('kadan-r','MAIL',f.options), e=>e.code===code && e.delivery==='not-sent', name); - assert.equal(f.calls.filter(a=>a[0]==='paste-buffer').length,0,name); - } - for (const clients of ['', String(1_000_000 - 3600)]) { - const f=notify({clients}); - sendTmux('kadan-r','MAIL',f.options); - assert.equal(f.calls.filter(a=>a[0]==='paste-buffer').length,1); - } -}); diff --git a/tests/automatic-review-cli.test.mjs b/tests/automatic-review-cli.test.mjs index 45022eb4..3dfdae0e 100644 --- a/tests/automatic-review-cli.test.mjs +++ b/tests/automatic-review-cli.test.mjs @@ -12,7 +12,7 @@ import {digest,findDoneMarkers} from '../src/cli.mjs'; const cli=new URL('../src/cli.mjs',import.meta.url).pathname; const quote=s=>"'"+s.replaceAll("'","'\\''")+"'"; -test('자동 발령도 기존 미제출 입력을 보존하고 paste·Enter·발령을 거부한다',()=>{ +test('자동 발령도 사람이 창에 붙어 최근 입력했으면 paste·Enter·발령을 거부한다',()=>{ const home=fs.mkdtempSync(path.join(os.tmpdir(),'kadan-auto-input-')); createDatabase(home);writeStorageMarker(home,'sqlite'); const env={...process.env,KADAN_HOME:home,KADAN_SOCKET:path.basename(home),KADAN_FLOOR:'tmux',KADAN_WINDOW:'none',KADAN_ROLE:'auto-owner'}; @@ -20,14 +20,15 @@ test('자동 발령도 기존 미제출 입력을 보존하고 paste·Enter·발 const tm=(...args)=>spawnSync('tmux',['-L',env.KADAN_SOCKET,...args],{encoding:'utf8'}); const harness=path.join(home,'codex'); fs.writeFileSync(harness,`#!${process.execPath}\nprocess.stdout.write('› ');process.stdin.on('data',()=>{});\n`,{mode:0o755}); - const roles=['auto-worker','auto-reviewer','auto-owner'],started=[]; + const roles=['auto-worker','auto-reviewer','auto-owner'],started=[];let human; try{ for(const role of roles){run('start',role,'--hidden','--cmd',`${quote(harness)} --model test-model`);started.push(role);} const target='kadan-auto-worker'; - tm('send-keys','-t',target,'-l','USER_PENDING'); + // 사람이 붙은 창은 제어 모드 client로 대신한다. 붙은 시각이 client_activity가 되어 30초 안이다. + human=spawn('tmux',['-L',env.KADAN_SOCKET,'-C','attach','-t','='+target],{stdio:['pipe','ignore','ignore']}); const until=Date.now()+3000; - while(!tm('capture-pane','-p','-t',target).stdout.includes('USER_PENDING')){ - assert.ok(Date.now()e.kind==='send'&&e.role===roles[0]).length,0); assert.equal(readTaskLedger(home).filter(e=>e.kind==='dispatch').length,0); - }finally{for(const role of started)run('stop',role);} + }finally{human?.kill();for(const role of started)run('stop',role);} }); test('격리 SQLite + 실제 tmux + CLI 자동 프로그램: 구현→검수→수정→PASS·마커·동시 실행',async()=>{ diff --git a/tests/herdr-busymail-send.test.mjs b/tests/herdr-busymail-send.test.mjs index 7683a1f2..357f5108 100644 --- a/tests/herdr-busymail-send.test.mjs +++ b/tests/herdr-busymail-send.test.mjs @@ -40,7 +40,6 @@ const herdr = makeHerdrFloor({ if (method === "agent.prompt") return { type: "ok" }; throw new Error(`Unexpected request ${method}`); }, - inputFrame: () => ({ row: 1, width: 120 }), }); Object.assign(floor, { name: "herdr", alive: () => true, pid: () => "123", send: (s, m, options) => { state.seenOptions.push(options); return herdr.send(s, m, options); } }); diff --git a/tests/herdr-floor.test.mjs b/tests/herdr-floor.test.mjs index c1516f32..a80546c4 100644 --- a/tests/herdr-floor.test.mjs +++ b/tests/herdr-floor.test.mjs @@ -5,7 +5,7 @@ import path from "node:path"; import fs from "node:fs"; import { spawn } from "node:child_process"; import { once } from "node:events"; -import { makeHerdrFloor, herdrRpc, herdrInputFrame } from "../src/floor-herdr.mjs"; +import { makeHerdrFloor, herdrRpc } from "../src/floor-herdr.mjs"; import { buildStartLedgerEntry, guardedSend } from "../src/cli.mjs"; import { selectFloorName } from "../src/floor.mjs"; @@ -34,7 +34,6 @@ function fixture() { throw new Error(`Unexpected request ${method}`); }; const floor = makeHerdrFloor({ env, entries: () => [start], rpc, - inputFrame: (...args) => { state.frameArgs = args; return { row: 1, width: 120 }; }, argsForPid: () => state.localArgs || "" }); return { floor, state, env, start, pane }; } @@ -65,9 +64,8 @@ for (const [name, change, code] of [ ["명시 모델 변경", f => { f.state.argv[2] = "other-model"; }, "KADAN_AI_PROCESS_UNVERIFIED"], ["승인 대기", f => { f.state.status = "blocked"; }, "KADAN_HERDR_NOT_READY"], ["상태 미확인", f => { f.state.status = "unknown"; }, "KADAN_HERDR_NOT_READY"], - ["미제출 입력", f => { f.state.output = "\n› 남아 있는 글\n\nstatus"; }, "KADAN_PANE_INPUT_PENDING"], ["기록보기 중", f => { f.pane.scroll.offset_from_bottom = 4; }, "KADAN_HERDR_SCROLLED"], - ["출력 유실", f => { f.state.truncated = true; }, "KADAN_HERDR_INPUT_UNKNOWN"], + ["출력 유실", f => { f.state.truncated = true; }, "KADAN_HERDR_OUTPUT_GAP"], ]) test(`${name}이면 본문 요청이 발생하지 않는다`, () => { const f = fixture(); change(f); assert.throws(() => f.floor.send(f.start.session, "카드 본문"), { code }); @@ -122,8 +120,10 @@ test('알림 유예 중 입력·화면 변화와 working 뒤에는 새로 30초 hold(0); f.state.revision = 8; assert.equal(hold(30_000).code, 'KADAN_HUMAN_ACTIVE'); + // 화면이 바뀌면(사람이 글을 치기 시작) 모양을 판정하지 않고 30초 유예를 새로 시작한다. f.state.output = '\n› 아직 쓰는 문장\n\nstatus'; - assert.equal(hold(60_000).code, 'KADAN_PANE_INPUT_PENDING'); + assert.equal(hold(60_000).code, 'KADAN_HUMAN_ACTIVE'); + assert.equal(hold(89_999).code, 'KADAN_HUMAN_ACTIVE'); f.state.output = '\n› Ask Codex to do anything\n\nstatus'; assert.equal(hold(90_000).code, 'KADAN_HUMAN_ACTIVE'); f.state.status = 'working'; @@ -139,7 +139,7 @@ test('알림 전송 직전 입력이 생기면 본문을 한 번도 보내지 let reads = 0; f.state.beforeRead = () => { if (++reads === 2) f.state.output = '\n› 입력 시작\n\nstatus'; }; assert.throws(() => f.floor.send(f.start.session, '알림', { notificationGuard: { now: 30_000 } }), - { code: 'KADAN_PANE_INPUT_PENDING', delivery: 'not-sent' }); + { code: 'KADAN_HUMAN_ACTIVE', delivery: 'not-sent' }); assert.equal(prompts(f), 0); }); @@ -184,7 +184,7 @@ test("재사용 시작은 같은 서버·터미널·PID의 신원만 이어받 if (method === "pane.read") return { read: { text: successful.state.output, revision: 1, truncated: false } }; if (method === "agent.prompt") return { type: "ok" }; throw new Error(method); - }, inputFrame: () => ({ row: 1, width: 120 }) }); + } }); assert.equal(reused.send(f.start.session, "왕복").keyDelivery, "sent"); records = [{ ...launch, panePid: "old-generation" }, f.start]; assert.throws(() => reused.send(f.start.session, "왕복"), { code: "KADAN_AI_PROCESS_UNVERIFIED" }); @@ -207,15 +207,6 @@ test("소켓 응답의 한글 글자가 패킷 경계에서 나뉘어도 보존 } finally { server.kill(); await once(server, "exit"); } }); -test("입력 커서는 터미널의 실제 세로 줄 수로 관찰한다 (LBR-1 결함 1)", () => { - const f = fixture(); f.floor.send(f.start.session, "왕복 질문"); - assert.equal(f.state.frameArgs[1], "opaque-terminal"); - assert.equal(f.state.frameArgs[2], 58); - // 세로 줄 수를 모르면 기본 관찰 창(120x40)으로 추측하지 않고 거절한다. - assert.throws(() => herdrInputFrame(f.env.KADAN_HERDR_SOCKET, "opaque-terminal", undefined), - { code: "KADAN_HERDR_INPUT_UNKNOWN" }); -}); - for (const [runner, screen, keys] of [ ["claude", " Quick safety check: Is this a project you created or one you trust? (Like your own code)\n\n ❯ No, exit\n Yes, I trust this folder", "down enter"], ["codex", "> You are in /tmp/p\n\n Do you trust the contents of this directory? Working with untrusted contents\n\n› 1. Yes, continue", "enter"], @@ -265,12 +256,17 @@ test("카드 발령은 busyMail이어도 응답 중이면 거절한다", () => { assert.equal(prompts(f), 0); }); -test("미제출 입력이 있으면 응답 중 여부와 무관하게 거절한다", () => { +// 방침(b): herdr는 사람 입력 시각을 얻을 수 없고 CLI 한 번은 30초 안정을 기억할 수 없어, 카드·일반 우편은 +// 화면 모양으로 막지 않는다. 미제출 글처럼 보이는 화면에도 보낸다(2026-10-17 [kyle] 결정, 받아들인 손해). +test("카드·일반 우편은 화면에 미제출 글처럼 보이는 내용이 있어도 보낸다(방침 b)", () => { for (const status of ["working", "idle"]) { const f = fixture(); f.state.status = status; f.state.output = "\n› 사람이 쓰던 글\n\nstatus"; - assert.throws(() => f.floor.send(f.start.session, "우편", busy), { code: "KADAN_PANE_INPUT_PENDING" }); - assert.equal(prompts(f), 0); + assert.equal(f.floor.send(f.start.session, "우편", busy).keyDelivery, "sent"); + assert.equal(prompts(f), 1); } + const f = fixture(); f.state.output = "\n› 사람이 쓰던 글\n\nstatus"; + const cardIdentity = { currentPid: 123, harness: "codex", model: "fixture-model" }; + assert.equal(f.floor.send(f.start.session, "카드", { cardIdentity }).keyDelivery, "sent"); }); test("실제 감시 경보 경로(guardedSend source=watch, notificationOnly)는 응답 중 herdr에서 거절된다", () => { @@ -284,7 +280,7 @@ test("실제 감시 경보 경로(guardedSend source=watch, notificationOnly)는 assert.equal(events.some(e => e.kind === "send"), false); }); -test('강제 알림(force)은 입력창·안정 시간 확인만 건너뛰고 PID·승인 대기·기록보기 확인은 남긴다', () => { +test('강제 알림(force)은 안정 시간 확인만 건너뛰고 PID·승인 대기·기록보기 확인은 남긴다', () => { const force = { notificationGuard: { humanIdleMs: 30_000, force: true } }; const f = fixture(); f.state.output = '\n› 남아 있는 글\n\nstatus'; assert.throws(() => f.floor.send(f.start.session, '알림', { notificationGuard: { now: 0 } }), { delivery: 'not-sent' }); diff --git a/tests/herdr-input-scrollback.test.mjs b/tests/herdr-input-scrollback.test.mjs deleted file mode 100644 index ad4579b6..00000000 --- a/tests/herdr-input-scrollback.test.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { checkEmptyInput } from "../src/floor-tmux.mjs"; - -// LBR-1 결함 1: 출력이 많이 쌓인 Codex 창(세로 58)의 실제 화면 모양(2026-10-12 실측). -// 입력 줄은 55행(0부터)이다. 옛 관찰 창(세로 40)의 마지막 이동은 39행이라 프롬프트가 아니었다. -const history = Array.from({ length: 51 }, (_, i) => (i === 49 ? " └ line 1" : i === 50 ? " … +146 lines" : ` output ${i}`)); -const screen = (input = "› Ask Codex to do anything", ...extra) => - [...history, " done 1:18 AM", "", input, ...extra, "", " gpt-6.1-sol high · ~/proj · main · Context 7% used"].join("\n"); -const result = (text, row) => { - try { checkEmptyInput("test", () => text, row, null, "lbr1"); return "pass"; } - catch (error) { return error.code; } -}; -const inputRow = 53; - -test("스크롤백이 쌓인 빈 Codex 입력창은 실제 커서 행에서 통과한다", () => { - const text = screen(); - assert.equal(text.split("\n")[inputRow], "› Ask Codex to do anything"); - assert.equal(result(text, inputRow), "pass"); -}); - -test("옛 관찰 창의 어긋난 행(40행 안쪽)은 프롬프트로 속지 않고 거절된다", () => { - assert.equal(result(screen(), 39), "KADAN_PANE_INPUT_UNKNOWN"); -}); - -test("스크롤백이 쌓여도 미제출 입력 보호는 그대로다", () => { - assert.equal(result(screen("› 사람이 쓰던 글"), inputRow), "KADAN_PANE_INPUT_PENDING"); - assert.equal(result(screen("› first", " second"), inputRow + 1), "KADAN_PANE_INPUT_PENDING"); - assert.equal(result(screen("› ", " second"), inputRow), "KADAN_PANE_INPUT_PENDING"); -}); diff --git a/tests/herdr-input-width.test.mjs b/tests/herdr-input-width.test.mjs deleted file mode 100644 index 17fb1522..00000000 --- a/tests/herdr-input-width.test.mjs +++ /dev/null @@ -1,61 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { checkEmptyInput } from "../src/floor-tmux.mjs"; - -// herdr는 observe frame의 width(120)와 실제 화면 줄 길이(212)가 어긋난 채로 올 수 있다. -// 그래서 herdr 바닥은 width에 null을 주고, 화면 안의 테두리만으로 판정한다. -const RULE = "─".repeat(212); -const screen = (...lines) => lines.join("\n"); -const header = [" ▐▛███▛█ Claude Code", "", RULE]; -const footer = [" ~/project[main]", " ⏵⏵ bypass permissions on"]; -const check = (text, row, width = null) => checkEmptyInput("s", () => text, row, width, "herdr 전송 보류"); - -test("폭 숫자와 다른 길이의 빈 Claude 상자는 화면 안 테두리로 통과한다", () => { - const text = screen(...header, '❯ Try "how does style.css work?"', RULE, ...footer); - assert.doesNotThrow(() => check(text, 3)); -}); - -test("같은 조건의 한 줄 미제출 입력은 거절한다", () => { - const text = screen(...header, "❯ 남아 있는 글", RULE, ...footer); - assert.throws(() => check(text, 3), { code: "KADAN_PANE_INPUT_PENDING" }); -}); - -test("같은 조건의 여러 줄 미제출 입력은 거절한다", () => { - const text = screen(...header, "❯ 첫 줄", " 둘째 줄", RULE, ...footer); - assert.throws(() => check(text, 4), { code: "KADAN_PANE_INPUT_PENDING" }); - assert.throws(() => check(text, 3), { code: "KADAN_PANE_INPUT_PENDING" }); -}); - -test("본문 속 ──── 인용은 입력창 테두리로 보지 않는다", () => { - const quote = "─".repeat(8); - const text = screen(RULE, "본문 인용입니다", quote, '❯ Try "x"', quote, ...footer); - assert.throws(() => check(text, 3), { code: "KADAN_PANE_INPUT_PENDING" }); -}); - -test("위 테두리만 맞고 아래가 다르면 거절한다", () => { - const text = screen(...header, '❯ Try "x"', "─".repeat(100), ...footer); - assert.throws(() => check(text, 3), { code: "KADAN_PANE_INPUT_PENDING" }); -}); - -test("위 테두리가 화면에서 가장 긴 줄보다 짧으면 거절한다", () => { - const text = screen("─".repeat(100), '❯ Try "x"', "─".repeat(100), "x".repeat(150)); - assert.throws(() => check(text, 1), { code: "KADAN_PANE_INPUT_PENDING" }); -}); - -test("상자 없는 Codex 화면은 기존 판정을 유지한다", () => { - assert.doesNotThrow(() => check("\n› Ask Codex to do anything\n\nstatus", 1)); - assert.throws(() => check("\n› 남아 있는 글\n\nstatus", 1), { code: "KADAN_PANE_INPUT_PENDING" }); -}); - -test("codex 0.163 두 줄 바닥글은 폭 없는 경로에서도 빈 입력으로 본다", () => { - const status = " GPT-6-Astra high · ~/Dev/kadan · main · Context 0% used · weekly 36% left"; - const hint = " ← for agents · ? for shortcuts" + " ".repeat(100) + "⚠ 1 warning · f2 to view"; - assert.doesNotThrow(() => check(`\n› Ask Codex to do anything\n\n${status}\n${hint}`, 1)); - assert.throws(() => check(`\n› Ask Codex to do anything\n\n${status}\n${hint}\n THIRD`, 1), { code: "KADAN_PANE_INPUT_PENDING" }); -}); - -test("폭 숫자를 주는 tmux 경로는 기존처럼 정확히 같은 길이만 통과한다", () => { - const text = screen(...header, '❯ Try "x"', RULE, ...footer); - assert.doesNotThrow(() => check(text, 3, 212)); - assert.throws(() => check(text, 3, 120), { code: "KADAN_PANE_INPUT_PENDING" }); -}); diff --git a/tests/send-buffer.test.mjs b/tests/send-buffer.test.mjs index c97cd8de..d604afb9 100644 --- a/tests/send-buffer.test.mjs +++ b/tests/send-buffer.test.mjs @@ -27,7 +27,7 @@ test("동시 send는 서로의 tmux 버퍼를 붙여넣거나 지우지 않는 const run = (args, input) => { const call = { args: [...args], input }; calls.push(call); - if (args[0] === "display-message") return "0\n"; + if (args[0] === "display-message") return "0\n"; if (args[0] === "list-clients") return ""; if (!reentered && args[0] === "load-buffer" && bufferOf(call) === "kadan-send-101-1") { reentered = true; sendTmux("kadan-b", "B-지시", { run, spawn: noopSpawn, pid: 202, hrtime }); @@ -72,7 +72,7 @@ test("동시 send는 서로의 tmux 버퍼를 붙여넣거나 지우지 않는 test("본문은 괄호 붙여넣기(-p)로 넣고 1초 뒤 Enter를 보낸다 — Claude Code Enter 누락(2026-09-23)", () => { const calls = []; - const run = (args) => { calls.push(["run", ...args]); if (args[0] === "display-message") return "0\n"; }; + const run = (args) => { calls.push(["run", ...args]); if (args[0] === "display-message") return "0\n"; if (args[0] === "list-clients") return ""; }; const spawn = (cmd, args) => { calls.push(["spawn", cmd, ...args]); return {}; }; sendTmux("kadan-a", "짧은 지시\n", { run, spawn, pid: 1, hrtime: () => 1n }); const paste = calls.findIndex((c) => c[1] === "paste-buffer"); @@ -94,6 +94,7 @@ for (const sample of [ const run=args=>{ calls.push(args); if(args[0]==='display-message'){const value=modes.shift();if(value===null)throw new Error('조회 실패');return value;} + if(args[0]==='list-clients')return ''; }; assert.throws(()=>sendTmux('kadan-test','본문',{run,spawn:noopSpawn}),e=>{ assert.equal(e.delivery,sample.delivery);assert.equal(e.inputAcceptance,'unconfirmed');assert.match(e.message,/전송 보류/);return true; @@ -105,14 +106,14 @@ for (const sample of [ test('정상 모드의 키 전달은 입력 접수 확인을 주장하지 않는다',()=>{ const calls=[]; - const receipt=sendTmux('kadan-test','본문',{run:a=>{calls.push(a);return a[0]==='display-message'?'0':undefined;},spawn:noopSpawn}); + const receipt=sendTmux('kadan-test','본문',{run:a=>{calls.push(a);return a[0]==='display-message'?'0':a[0]==='list-clients'?'':undefined;},spawn:noopSpawn}); assert.deepEqual(receipt,{keyDelivery:'sent',inputAcceptance:'unconfirmed'}); assert.equal(calls.filter(c=>c[0]==='paste-buffer').length,1);assert.equal(calls.filter(c=>c[0]==='send-keys').length,1); }); for(const failure of ['load-buffer','paste-buffer','send-keys'])test(`${failure} 오류의 전송 단계를 보존하고 반복하지 않는다`,()=>{ const calls=[];const error=new Error('시험 오류'); - assert.throws(()=>sendTmux('kadan-test','본문',{run:a=>{calls.push(a);if(a[0]===failure)throw error;return a[0]==='display-message'?'0':undefined;},spawn:noopSpawn}),e=>{ + assert.throws(()=>sendTmux('kadan-test','본문',{run:a=>{calls.push(a);if(a[0]===failure)throw error;return a[0]==='display-message'?'0':a[0]==='list-clients'?'':undefined;},spawn:noopSpawn}),e=>{ assert.equal(e,error);assert.equal(e.delivery,failure==='load-buffer'?'not-sent':'unknown');return true; }); assert.equal(calls.filter(c=>c[0]===failure).length,1); @@ -129,7 +130,7 @@ test('미전송·부분전송 오류는 guardedSend에서 덮지 않고 성공 s test('전송 버퍼 정리 오류도 미전송 판정을 보존한다',()=>{ let checks=0; - assert.throws(()=>sendTmux('kadan-test','본문',{run:a=>{if(a[0]==='display-message')return ++checks===1?'0':'1';if(a[0]==='delete-buffer')throw new Error('정리 실패');},spawn:noopSpawn}),e=>e instanceof AggregateError&&e.delivery==='not-sent'); + assert.throws(()=>sendTmux('kadan-test','본문',{run:a=>{if(a[0]==='display-message')return ++checks===1?'0':'1';if(a[0]==='list-clients')return '';if(a[0]==='delete-buffer')throw new Error('정리 실패');},spawn:noopSpawn}),e=>e instanceof AggregateError&&e.delivery==='not-sent'); }); test("paste 실패면 같은 이름 버퍼를 지우고 원래 오류를 던진다(card-48)", () => { @@ -137,7 +138,7 @@ test("paste 실패면 같은 이름 버퍼를 지우고 원래 오류를 던진 const calls = []; const run = (args, input) => { calls.push({ args: [...args], input }); - if (args[0] === "display-message") return "0\n"; + if (args[0] === "display-message") return "0\n"; if (args[0] === "list-clients") return ""; if (args[0] === "paste-buffer") { throw originalError; } diff --git a/tests/watch-notify-hold.test.mjs b/tests/watch-notify-hold.test.mjs index ebf07ea1..43423548 100644 --- a/tests/watch-notify-hold.test.mjs +++ b/tests/watch-notify-hold.test.mjs @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import {spawnSync} from 'node:child_process'; +import {spawn, spawnSync} from 'node:child_process'; import {runWatch} from '../src/watch-runner.mjs'; import {guardedSend} from '../src/cli-guarded-send.mjs'; import {appendLedger, readLedger} from '../src/ledger.mjs'; @@ -206,7 +206,7 @@ readline.createInterface({input:process.stdin}).on('line',l=>{fs.appendFileSync( const target = '=kadan-q-super:'; const contents = () => fs.existsSync(received) ? fs.readFileSync(received, 'utf8') : ''; const waitFor = pred => { const end = Date.now() + 4000; while (Date.now() < end && !pred()) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20); }; - let started = false; + let started = false, human; try { const start = call('q-super', 'start', 'q-super', '--cmd', `'${process.execPath}' '${receiver}'`); assert.equal(start.status, 0, start.stderr); @@ -221,8 +221,9 @@ readline.createInterface({input:process.stdin}).on('line',l=>{fs.appendFileSync( const sends = () => readLedger(home).filter(e => e.kind === 'send'); assert.equal(sends().length, 1); assert.notEqual(sends()[0].transport, 'mailbox', '바로 들어간 편지는 보관 우편이 아니라 감시기가 따로 알리지 않는다'); - // 걸림 → 보관, 창은 그대로 - tmuxCmd(['send-keys', '-t', target, '-l', 'USER_PENDING_']); + // 걸림 → 보관, 창은 그대로. 사람이 붙은 창은 제어 모드 client로 대신한다(붙은 시각이 client_activity가 된다). + human = spawn('tmux', ['-L', socket, '-C', 'attach', '-t', target.replace(/:$/, '')], {env, stdio: ['pipe', 'ignore', 'ignore']}); + waitFor(() => tmuxCmd(['list-clients', '-t', target.replace(/:$/, ''), '-F', '#{client_activity}']).stdout.trim() !== ''); const before = contents(); const held = call('p-super', 'mail', 'q-super', 'PEER_HELD'); assert.equal(held.status, 0, held.stderr); @@ -236,6 +237,7 @@ readline.createInterface({input:process.stdin}).on('line',l=>{fs.appendFileSync( // --raw는 여전히 거절 assert.equal(call('p-super', 'mail', 'q-super', '--raw', 'x').status, 1); } finally { + human?.kill(); if (started) call('q-super', 'stop', 'q-super'); } }); From d6a502e20caf3b45d05438f0d1c5958a510a893f Mon Sep 17 00:00:00 2001 From: fwkyle Date: Sat, 17 Oct 2026 17:54:17 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20herdr=20=EC=9E=98=EB=A6=B0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B1=B0=EC=A0=88=20=EC=9C=A0=EC=A7=80(?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=99=84=EC=A0=84=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC),=20=EB=AC=B8=EC=84=9C=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/watch-overview.md | 2 +- src/floor-herdr.mjs | 2 +- tests/herdr-floor.test.mjs | 9 ++++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/watch-overview.md b/docs/watch-overview.md index 16c07396..8fc10112 100644 --- a/docs/watch-overview.md +++ b/docs/watch-overview.md @@ -238,7 +238,7 @@ AI가 정상 보고를 마쳤으면 마지막 대화 응답의 호칭·문구· 감시기가 창에 넣는 알림은 입력 보호(최근 30초 사람 입력)에 걸리면 미룬다. 2026-10-17부터 입력창 글 모양은 판정하지 않는다. 옛 코드 `KADAN_PANE_INPUT_PENDING`·`KADAN_PANE_INPUT_UNKNOWN`은 옛 원장 기록을 표시하는 데만 남아 있다. 예전에는 상한이 없어 쓰다 만 입력이 남거나 화면 모양이 바뀌면 조용히 계속 밀릴 수 있었다. [kyle] 결정으로 상한을 둔다. 판정 기준과 일반 `kadan mail/send`는 그대로다. -- **센다:** 받는 창(역할) 단위로 감시기 메모리에서 센다. 한 주기에 그 창으로 갈 것(미확인 우편 재알림·감시 경보·놀고 있음 본인 알림·감시AI 보고의 재시도(첫 시도는 판정 AI 자식 프로세스가 보내 세지 못한다)·슈퍼 간 보관 우편 알림) 중 하나라도 입력 보호로 밀리면 1을 더한다. 전달에 성공하거나 보낼 것이 없어지면 0이다. 한 주기에 밀림과 성공이 같이 있으면 성공으로 보아 0이다. 관찰이 실패한 주기는 세지 않는다. 사람 입력 시각 미확인(`KADAN_HUMAN_ACTIVITY_UNKNOWN`)과 pane 상태 미확인도 미룸으로 센다. tmux 복사 모드(`KADAN_PANE_IN_MODE`)와 모드 조회 실패(`KADAN_PANE_MODE_UNKNOWN`)도 미룸이라 같은 카운터에 더한다(2026-10-14). 예전에는 이 둘이 일반 전달 실패로 끝나 알림이 다시 시도되지 않고 사라졌다. 일반 `kadan mail/send`와 큐 재개의 복사 모드 거절은 그대로다. 감시기를 다시 시작하면 0부터 센다. +- **센다:** 받는 창(역할) 단위로 감시기 메모리에서 센다. 한 주기에 그 창으로 갈 것(미확인 우편 재알림·감시 경보·놀고 있음 본인 알림·감시AI 보고의 재시도(첫 시도는 판정 AI 자식 프로세스가 보내 세지 못한다)·슈퍼 간 보관 우편 알림) 중 하나라도 입력 보호로 밀리면 1을 더한다. 전달에 성공하거나 보낼 것이 없어지면 0이다. 한 주기에 밀림과 성공이 같이 있으면 성공으로 보아 0이다. 관찰이 실패한 주기는 세지 않는다. 사람 입력 시각 미확인(`KADAN_HUMAN_ACTIVITY_UNKNOWN`)도 미룸으로 센다. tmux 복사 모드(`KADAN_PANE_IN_MODE`)와 모드 조회 실패(`KADAN_PANE_MODE_UNKNOWN`)도 미룸이라 같은 카운터에 더한다(2026-10-14). 예전에는 이 둘이 일반 전달 실패로 끝나 알림이 다시 시도되지 않고 사라졌다. 일반 `kadan mail/send`와 큐 재개의 복사 모드 거절은 그대로다. 감시기를 다시 시작하면 0부터 센다. - **3번 연속:** `알림밀림` 경보 하나가 `@user`에게만 간다(디스코드, 안 되면 OS 알림 — `user-notify.mjs`가 `display notification`으로 내려가며 창을 앞으로 가져오지 않는다. [사용자 알림](user-notify.md)). 원장 `alert` 줄에 창·사유 코드(`holdCode`)·횟수(`holdCount`)·시작 시각(`heldSince`)이 남고 대시보드가 열린 경보로 보여 준다. 같은 연속 구간에서는 한 번만 울린다. 지난 주기까지의 횟수로 만들어 최대 한 주기 늦다. - **5번 연속:** 그 창에는 입력 보호(사람 입력·안정 시간 확인)만 건너뛰고 넣는다. 생존 확인·PID 대조, herdr의 승인 대기·기록보기 확인은 그대로라 세션이 없거나 PID가 다르거나 herdr 기록 보기 중이면 강제로도 넣지 않는다. tmux 복사 모드는 복사 모드에서 보낸 키를 tmux가 복사 모드 명령으로 읽으므로 그대로 넣지 않고, 강제 차례에 한해 `send-keys -X cancel`로 빠져나온 뒤 `pane_in_mode`가 0인지 다시 확인하고 넣는다(사람의 스크롤 위치가 풀리며 쓰다 만 글 뒤에 붙는 위험과 같은 급으로 [kyle]이 수용, 2026-10-14). 다시 확인해도 0이 아니면 넣지 않고 미룸으로 센다. 해제하고 넣었으면 `watch-notify-forced`와 `send` 원장 줄에 `copyModeReleased: true`가 남는다. 그 밖의 이런 실패는 미룸이 아니라 일반 전달 실패라 횟수에 더하지도 0으로 되돌리지도 않는다. 넣는 데 성공하면 원장에 `watch-notify-forced` 줄(역할·횟수·사유·시작 시각)을 남기고 0으로 돌아가 경보가 풀린다. 쓰다 만 글 뒤에 붙을 위험은 [kyle]이 수용했다. diff --git a/src/floor-herdr.mjs b/src/floor-herdr.mjs index fc6350c4..e473d2e2 100644 --- a/src/floor-herdr.mjs +++ b/src/floor-herdr.mjs @@ -151,7 +151,7 @@ export function makeHerdrFloor({ env = process.env, rpc = herdrRpc, entries = re if (pane.scroll?.offset_from_bottom > 0) deny("KADAN_HERDR_SCROLLED", "과거 기록 조회 중이라 전송을 보류한다"); // 화면 모양으로 미제출 글을 판정하지 않는다(2026-10-17 [kyle]). 읽은 화면은 알림의 30초 안정 검사 지문에만 쓴다. const visible = call("pane.read", { pane_id: pane.pane_id, source: "visible", format: "text", strip_ansi: true }).read; - if (typeof visible?.text !== "string") deny("KADAN_HERDR_INPUT_UNKNOWN", "현재 화면 미확인"); + if (typeof visible?.text !== "string" || visible.truncated) deny("KADAN_HERDR_INPUT_UNKNOWN", "현재 화면 미확인"); return { record, pane, visible }; }; const checkNotification = herdrNotificationGuard(session => checkAgent(session)); diff --git a/tests/herdr-floor.test.mjs b/tests/herdr-floor.test.mjs index a80546c4..904707d4 100644 --- a/tests/herdr-floor.test.mjs +++ b/tests/herdr-floor.test.mjs @@ -65,7 +65,7 @@ for (const [name, change, code] of [ ["승인 대기", f => { f.state.status = "blocked"; }, "KADAN_HERDR_NOT_READY"], ["상태 미확인", f => { f.state.status = "unknown"; }, "KADAN_HERDR_NOT_READY"], ["기록보기 중", f => { f.pane.scroll.offset_from_bottom = 4; }, "KADAN_HERDR_SCROLLED"], - ["출력 유실", f => { f.state.truncated = true; }, "KADAN_HERDR_OUTPUT_GAP"], + ["출력 유실", f => { f.state.truncated = true; }, "KADAN_HERDR_INPUT_UNKNOWN"], ]) test(`${name}이면 본문 요청이 발생하지 않는다`, () => { const f = fixture(); change(f); assert.throws(() => f.floor.send(f.start.session, "카드 본문"), { code }); @@ -134,6 +134,13 @@ test('알림 유예 중 입력·화면 변화와 working 뒤에는 새로 30초 assert.equal(prompts(f), 0); }); +test('알림 경로도 잘린 화면(truncated)은 안정 검사 지문으로 쓰지 않고 거절한다', () => { + const f = fixture(); f.state.truncated = true; + assert.throws(() => f.floor.send(f.start.session, '알림', { notificationGuard: { now: 30_000 } }), + { code: 'KADAN_HERDR_INPUT_UNKNOWN', delivery: 'not-sent' }); + assert.equal(prompts(f), 0); +}); + test('알림 전송 직전 입력이 생기면 본문을 한 번도 보내지 않는다', () => { const f = fixture(); f.floor.notificationHold(f.start.session, { now: 0 }); let reads = 0; From 20a6c750760109d7a8532f0178c1d0f4a5696b60 Mon Sep 17 00:00:00 2001 From: fwkyle Date: Sat, 17 Oct 2026 18:09:04 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=EB=B6=99=EA=B8=B0=EB=A7=8C=20?= =?UTF-8?q?=ED=95=9C=20=EC=B0=BD=EC=9D=80=20=EC=82=AC=EB=9E=8C=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EC=9C=BC=EB=A1=9C=20=EB=B3=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tmux는 client가 붙는 순간 client_activity를 client_created와 같게 두고 키 입력으로만 갱신한다 (격리 소켓 실측). 활동이 붙은 시각보다 1초 넘게 뒤일 때만 사람 입력으로 센다. 로티 탭이 시작과 동시에 붙어도 첫 안내와 발령이 막히지 않는다. Co-Authored-By: Claude Sonnet 5.5 --- docs/design.md | 5 ++- docs/peer-super-mail.md | 2 +- src/floor-tmux.mjs | 15 ++++--- tests/ai-identity-boundaries-cli.test.mjs | 18 ++++---- tests/ai-identity-send-boundaries.test.mjs | 48 ++++++++++++++++++---- tests/automatic-review-cli.test.mjs | 10 ++--- tests/helpers/human-client.mjs | 24 +++++++++++ tests/watch-notify-hold.test.mjs | 15 +++++-- 8 files changed, 104 insertions(+), 33 deletions(-) create mode 100644 tests/helpers/human-client.mjs diff --git a/docs/design.md b/docs/design.md index ed90a4d3..b92134be 100644 --- a/docs/design.md +++ b/docs/design.md @@ -91,7 +91,10 @@ tmux는 (a)(b)(c)를 충족했고 (d)는 Mac에서 통과했다(서버 왕복은 실행기 업데이트로 화면 모양이 바뀌면 빈 입력창을 미제출 글로 오판해 발령과 알림이 통째로 막혔기 때문이다 (2026-09-23 devin·claude, 10-11 herdr 폭, 10-17 codex 0.163). **받아들인 손해:** 사람이 글을 치다 30초 넘게 손을 떼고 그대로 두면 그 글 뒤에 우편이 붙어 제출될 수 있다. - 또 사람이 창에 새로 붙은 순간도 활동으로 잡히므로 붙은 직후 30초는 보류된다. + **붙기만 한 창은 사람 입력으로 보지 않는다:** tmux는 client가 붙는 순간 `client_activity`를 `client_created`와 같게 두고, + 크기 바꾸기·`paste-buffer`·`send-keys`로는 바뀌지 않으며 그 client의 키 입력으로만 바뀐다(제어 모드 client는 명령을 보내도 안 바뀐다, 2026-10-17 격리 소켓 실측). + 그래서 활동이 붙은 시각보다 1초 넘게 뒤일 때만 사람 입력으로 센다. 로티 탭이 시작과 동시에 붙어도 첫 안내·발령이 막히지 않는다. + 한계: 붙은 지 1초 안에 누른 첫 키는 놓친다. 이는 화면에 보이는 확인이 아니며 AI 인증·모델 응답 성공을 증명하지 않는다. 복사모드 검사는 기존 공통 전송 경계를 재사용한다. 이 tmux 프로세스·입력 검증은 Rottie 바닥의 실측을 대신하지 않는다. 실제 전경 AI 프로세스의 명시 모델도 시작 기록과 대조한다. 불일치·미확인이면 거부한다. diff --git a/docs/peer-super-mail.md b/docs/peer-super-mail.md index 62e57943..ba1d2df4 100644 --- a/docs/peer-super-mail.md +++ b/docs/peer-super-mail.md @@ -60,7 +60,7 @@ 같은 창에 3번 연속 밀리면 사용자에게 `알림밀림`이 가고 5번 연속이면 입력 보호 없이 넣는다([입력 보호 상한](watch-overview.md#입력-보호-상한-2026-10-13)). 감시기가 꺼졌거나 수신 세션이 없으면 저장만 되므로 inbox에서 확인해야 한다. -tmux는 최근 사람 입력(`client_activity` 30초)만 검사한다. 입력창 글 모양은 보지 않는다(2026-10-17). +tmux는 최근 사람 입력(`client_activity` 30초, 붙기만 한 창은 입력 아님)만 검사한다. 입력창 글 모양은 보지 않는다(2026-10-17). herdr는 사람 입력 시각 API가 없으므로 대기 상태·동일 화면을 30초 이상 관측하고 전송 직전에 재검사한다. 작업 중·승인 대기·스크롤 중은 보류한다. 화면 검사와 전송 사이의 짧은 경합은 서버에서 원자적으로 잠그지 못한다([herdr 경계](experiments/herdr-roundtrip.md#자동-알림-보호-보완--2026-10-13)). diff --git a/src/floor-tmux.mjs b/src/floor-tmux.mjs index 8a58522b..e561758c 100644 --- a/src/floor-tmux.mjs +++ b/src/floor-tmux.mjs @@ -445,14 +445,19 @@ export function sendTmuxEnter(session, {run = tmuxOut} = {}) { // 사람 보호는 이것 하나다(2026-10-17 [kyle] 결정). 화면 모양(프롬프트·자리표시자·테두리·바닥글)을 읽어 // 미제출 글을 판정하던 장치는 실행기 업데이트마다 빈 입력창을 오판해 발령과 알림을 막아 없앴다. // 창에 붙은 사람이 humanIdleMs 안에 키를 눌렀으면 보류한다. 붙은 client가 없으면 통과한다. -// 받아들인 손해: 사람이 글을 치다 그 시간 넘게 손을 떼면 그 글 뒤에 우편이 붙어 제출될 수 있다. -// kadan의 붙여넣기(paste-buffer)는 client_activity를 바꾸지 않는다(키 입력만 센다). +// 붙기만 한 client는 사람 입력이 아니다: tmux는 붙는 순간 client_activity를 client_created와 같게 두고 +// (격리 소켓 실측 2026-10-17: 크기 바꾸기·paste-buffer·send-keys로는 안 변하고 그 client의 키 입력으로만 변한다), +// 제어 모드 client는 명령을 보내도 변하지 않는다. 그래서 활동이 생성 시각보다 1초 넘게 뒤일 때만 입력으로 센다. +// 받아들인 손해: 사람이 글을 치다 그 시간 넘게 손을 떼면 그 글 뒤에 우편이 붙어 제출될 수 있고, +// 붙은 지 1초 안에 누른 첫 키는 놓친다. kadan의 붙여넣기(paste-buffer)는 client_activity를 바꾸지 않는다. +const ATTACH_ONLY_SECONDS = 1; function checkHumanIdle(session, run, {humanIdleMs = HUMAN_IDLE_MS, now} = {}) { const deny = (code, message) => { const error = new Error(message); error.code = code; throw error; }; - const clients = run(["list-clients", "-t", `=${session}`, "-F", "#{client_activity}"]).split("\n").filter(Boolean); - if (clients.some(value => !/^\d+$/.test(value))) deny("KADAN_HUMAN_ACTIVITY_UNKNOWN", "전송 보류: 사람 입력 시각 미확인 — 잠시 뒤 다시 보내라"); + const clients = run(["list-clients", "-t", `=${session}`, "-F", "#{client_created} #{client_activity}"]).split("\n").filter(Boolean); + if (clients.some(value => !/^\d+ \d+$/.test(value))) deny("KADAN_HUMAN_ACTIVITY_UNKNOWN", "전송 보류: 사람 입력 시각 미확인 — 잠시 뒤 다시 보내라"); const at = now ?? Date.now(); - if (clients.some(value => at - Number(value) * 1000 < humanIdleMs)) deny("KADAN_HUMAN_ACTIVE", "전송 보류: 사람이 이 창에서 최근 입력함 — 잠시 뒤 다시 보내라"); + const typed = clients.map(value => value.split(" ").map(Number)).filter(([created, activity]) => activity - created > ATTACH_ONLY_SECONDS); + if (typed.some(([, activity]) => at - activity * 1000 < humanIdleMs)) deny("KADAN_HUMAN_ACTIVE", "전송 보류: 사람이 이 창에서 최근 입력함 — 잠시 뒤 다시 보내라"); } // 카드는 pane 종료·PID·AI 프로세스를 확인하고 사람 입력 시각을 본다. 기존 복사모드 검사는 그대로 둔다. diff --git a/tests/ai-identity-boundaries-cli.test.mjs b/tests/ai-identity-boundaries-cli.test.mjs index 249f9ef0..0bf76de8 100644 --- a/tests/ai-identity-boundaries-cli.test.mjs +++ b/tests/ai-identity-boundaries-cli.test.mjs @@ -5,6 +5,7 @@ import os from 'node:os'; import fs from 'node:fs'; import path from 'node:path'; import {spawn,spawnSync} from 'node:child_process'; +import {attachHuman} from './helpers/human-client.mjs'; import {CardStore} from '../src/card-store.mjs'; import {readLedger,readTaskLedger} from '../src/ledger.mjs'; import {createDatabase,writeStorageMarker} from '../src/storage.mjs'; @@ -30,13 +31,13 @@ if(a.includes('load-buffer')&&fs.existsSync(raceFile)) { const race=JSON.parse(fs.readFileSync(raceFile,'utf8')); if(race.active) { fs.writeFileSync(raceFile,JSON.stringify({...race,active:false})); - if(race.attach)spawn('sh',['-c',"sleep 6 | '"+${JSON.stringify(realTmux)}+"' -L '"+process.env.KADAN_SOCKET+"' -C attach -t =kadan-"+race.role],{detached:true,stdio:'ignore'}).unref(); + if(race.attach)spawn('sh',['-c',"(sleep 2.5; printf x; sleep 8) | python3 -c 'import pty,sys;pty.spawn(sys.argv[1:])' '"+${JSON.stringify(realTmux)}+"' -L '"+process.env.KADAN_SOCKET+"' attach -t =kadan-"+race.role],{detached:true,stdio:'ignore'}).unref(); else spawnSync(${JSON.stringify(realTmux)},['-L',process.env.KADAN_SOCKET,'send-keys','-t','kadan-'+race.role,...race.keys]); - const end=Date.now()+3000;let observed=false; + const end=Date.now()+(race.attach?9000:3000);let observed=false; while(Date.now()a-c>1)(state.trim().split(' ').map(Number)):race.dead?state.trim()==='1':state.includes('RACE_PENDING')){observed=true;break;} Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,20); } if(!observed)throw Error('race fixture readiness timeout'); @@ -67,12 +68,15 @@ process.exit(r.status??1);\n`,{mode:0o755}); tm('copy-mode','-t','kadan-resume');probe('copy-mode','resume',['--task','resume']);tm('send-keys','-t','kadan-resume','-X','cancel'); // 화면에 쓰다 만 글이 있어도 사람이 붙어 최근 입력하지 않았으면 막지 않는다(화면 모양 판정 제거, 2026-10-17). tm('send-keys','-t','kadan-resume','-l','USER_PENDING_');probe('partial-input-no-human','resume',['--execution','r/resume'],'accept fixture'); - // 사람이 붙은 창(제어 모드 client의 붙은 시각이 client_activity)은 보류한다. 본문은 붙지 않는다. - const humanClient=spawn('sh',['-c',"sleep 8 | '"+realTmux+"' -L '"+env.KADAN_SOCKET+"' -C attach -t =kadan-resume"],{detached:true,stdio:'ignore'});humanClient.unref(); - waitFor(()=>tm('list-clients','-t','=kadan-resume','-F','#{client_activity}').stdout.trim(),v=>v!=='','사람 client'); + // 사람이 붙기만 하고 키를 누르지 않았으면 막지 않는다(로티 탭이 시작과 동시에 붙는 표준 흐름, 2026-10-17). + const human=attachHuman(realTmux,env.KADAN_SOCKET,'=kadan-resume'); + probe('attached-only','resume',['--execution','r/resume'],'accept fixture'); + // 사람이 키를 누르면(실제 pty client 입력) 보류한다. 본문은 붙지 않는다. + human.type('x'); const humanProbe=probe('human-active','resume',['--task','resume']); assert.match(humanProbe.error,/사람이 이 창에서 최근 입력/); assert.doesNotMatch(tm('capture-pane','-p','-t','kadan-resume').stdout,/REVIEW_BODY_human-active/); + human.kill(); start('dead',bin+'/codex --model model-a');tm('set-option','-t','kadan-dead','remain-on-exit','on');tm('send-keys','-t','kadan-dead','C-c');waitFor(()=>tm('display-message','-p','-t','kadan-dead','#{pane_dead}').stdout.trim(),dead=>dead==='1','dead pane');probe('dead-pane','dead',['--task','dead']); start('spoof',bin+'/codex --version; exec cat # --model model-a',false);probe('version-then-cat','spoof',['--execution','r/spoof']); const replaced=path.join(home,'replaced');fs.mkdirSync(replaced); diff --git a/tests/ai-identity-send-boundaries.test.mjs b/tests/ai-identity-send-boundaries.test.mjs index e8f1c5f2..5be7973b 100644 --- a/tests/ai-identity-send-boundaries.test.mjs +++ b/tests/ai-identity-send-boundaries.test.mjs @@ -43,6 +43,9 @@ test('프로세스 실행 파일/스크립트 자리만 실행기 이름으로 // 화면 모양(프롬프트·자리표시자·테두리·바닥글)은 보지 않는다(2026-10-17 [kyle]). 사람 보호는 client_activity 하나다. // 가짜 run은 kadan 자신의 paste-buffer가 client_activity를 바꾸지 않는다는 전제를 따른다(실제 tmux도 키 입력만 센다). const nowSec = () => Math.floor(Date.now() / 1000); +// list-clients 한 줄: "붙은 시각 마지막 활동". 붙기만 하면 둘이 같고, 키를 눌러야 활동이 뒤로 간다(2026-10-17 격리 소켓 실측). +const typed = (ago, at = nowSec(), attached = at - 3600) => `${attached} ${at - ago}`; +const attachedOnly = (ago, at = nowSec()) => `${at - ago} ${at - ago}`; function fixture({state='11|0', ps='11 1 11 11 /opt/bin/codex --model test-model', clients='', afterLoad, psStatus=0, screen='› 쓰던 글', card=true} = {}) { const calls=[]; let loaded=false; return {calls, options:{...(card ? {cardIdentity:identity} : {}), pid:1, hrtime:()=>1n, @@ -71,7 +74,7 @@ for (const [name, config, code] of [ ['프로세스 조회 실패', {psStatus:1}, 'KADAN_AI_PROCESS_UNKNOWN'], ['버퍼 준비 중 죽음', {afterLoad:{state:'11|1'}}, 'KADAN_PANE_DEAD'], ['사람 입력 시각 미확인', {clients:'x'}, 'KADAN_HUMAN_ACTIVITY_UNKNOWN'], - ['버퍼 준비 중 사람이 키를 누름', {afterLoad:{clients:String(nowSec() - 1)}}, 'KADAN_HUMAN_ACTIVE'], + ['버퍼 준비 중 사람이 키를 누름', {afterLoad:{clients:typed(1)}}, 'KADAN_HUMAN_ACTIVE'], ]) test(`붙여넣기 전 거부: ${name}`, () => { const f=fixture(config); assert.throws(()=>sendTmux('kadan-r','CARD',f.options), e=>e.code===code && e.delivery==='not-sent'); @@ -101,14 +104,14 @@ for (const [path, cfg] of Object.entries(PATHS)) { const guard = (extra={}) => cfg.guard ? {humanIdleMs:30_000, ...extra} : undefined; test(`9/23 사고 재현: kyle이 비서 창에 문장을 치는 중(5초 전 키 입력)이면 ${path}은 붙여넣지 않는다`, () => { - const f=fixture({clients:String(nowSec() - 5), card:cfg.card}); + const f=fixture({clients:typed(5), card:cfg.card}); assert.throws(()=>send(f, guard()), e=>e.code==='KADAN_HUMAN_ACTIVE' && e.delivery==='not-sent'); assert.equal(count(f,'paste-buffer'),0); assert.equal(count(f,'send-keys'),0); }); test(`${path}: 60초 전 키 입력·붙은 client 없음은 통과한다`, () => { - for (const clients of [String(nowSec() - 60), '', `${nowSec() - 60}\n${nowSec() - 3600}`]) { + for (const clients of [typed(60), '', `${typed(60)}\n${typed(3600)}`]) { const f=fixture({clients, card:cfg.card}); send(f, guard()); assert.equal(count(f,'paste-buffer'),1, clients); @@ -116,7 +119,7 @@ for (const [path, cfg] of Object.entries(PATHS)) { }); test(`${path}: 붙은 client가 여럿이면 하나라도 최근 입력했을 때 보류한다`, () => { - const f=fixture({clients:`${nowSec() - 3600}\n${nowSec() - 2}`, card:cfg.card}); + const f=fixture({clients:`${typed(3600)}\n${typed(2)}`, card:cfg.card}); assert.throws(()=>send(f, guard()), e=>e.code==='KADAN_HUMAN_ACTIVE'); assert.equal(count(f,'paste-buffer'),0); }); @@ -135,17 +138,44 @@ for (const [path, cfg] of Object.entries(PATHS)) { }); } +test('방금 붙었지만 키를 누르지 않은 client는 사람 입력이 아니다 — 세 경로 모두 통과', () => { + for (const cfg of Object.values(PATHS)) for (const clients of [attachedOnly(0), attachedOnly(1), `${attachedOnly(0)}\n${typed(3600)}`]) { + const f=fixture({clients, card:cfg.card}); + send(f, cfg.guard ? {humanIdleMs:30_000} : undefined); + assert.equal(count(f,'paste-buffer'),1, clients); + } +}); + +test('붙은 뒤 10초에 키를 누르면 보류한다(붙은 시각 기준 1초 넘게 뒤의 활동만 입력)', () => { + const at=nowSec(); + for (const cfg of Object.values(PATHS)) { + const f=fixture({clients:`${at - 12} ${at - 2}`, card:cfg.card}); + assert.throws(()=>send(f, cfg.guard ? {humanIdleMs:30_000} : undefined), e=>e.code==='KADAN_HUMAN_ACTIVE' && e.delivery==='not-sent'); + assert.equal(count(f,'paste-buffer'),0); + } + // 경계: 붙은 지 정확히 1초 뒤 활동은 입력으로 세지 않고, 2초 뒤부터 센다. + assert.equal(count((()=>{const f=fixture({clients:`${at - 5} ${at - 4}`, card:false}); send(f); return f;})(),'paste-buffer'),1); + assert.throws(()=>send(fixture({clients:`${at - 5} ${at - 3}`, card:false})), e=>e.code==='KADAN_HUMAN_ACTIVE'); +}); + +test('client 값이 한쪽만 있거나 음수·소수면 실패 닫힘', () => { + for (const clients of ['1792000000', '-1 5', '1.5 2', 'x y', '1 ']) { + const f=fixture({clients, card:false}); + assert.throws(()=>send(f), e=>e.code==='KADAN_HUMAN_ACTIVITY_UNKNOWN', clients); + } +}); + test('감시기 알림: 기준 시간은 호출부가 정한다(10분 기준 600초 안 입력은 보류)', () => { const now=1_000_000_000_000; - const f=fixture({clients:String(now/1000 - 60), card:false}); + const f=fixture({clients:typed(60, now/1000), card:false}); assert.throws(()=>sendTmux('kadan-r','MAIL',{...f.options, notificationGuard:{humanIdleMs:600_000, now}}), e=>e.code==='KADAN_HUMAN_ACTIVE'); - const g=fixture({clients:String(now/1000 - 3600), card:false}); + const g=fixture({clients:typed(3600, now/1000), card:false}); sendTmux('kadan-r','MAIL',{...g.options, notificationGuard:{humanIdleMs:600_000, now}}); assert.equal(count(g,'paste-buffer'),1); }); test('감시기 강제 알림(force)은 사람 입력 검사를 건너뛴다', () => { - const f=fixture({clients:String(nowSec() - 1), card:false}); + const f=fixture({clients:typed(1), card:false}); sendTmux('kadan-r','MAIL',{...f.options, notificationGuard:{humanIdleMs:30_000, force:true}}); assert.equal(count(f,'paste-buffer'),1); assert.equal(count(f,'list-clients'),0); @@ -160,8 +190,8 @@ test('붙여넣기 전·후 두 번 사람 입력을 본다', () => { test('notificationHold는 붙여넣지 않고 같은 사람 입력 기준으로 보류 사유만 돌려준다', () => { const hold = clients => tmuxFloor.notificationHold('kadan-r',{humanIdleMs:30_000},{run:args=> args.includes('#{pane_in_mode}') ? '0' : args[0]==='list-clients' ? clients : ''}); - assert.equal(hold(String(nowSec() - 5)).code,'KADAN_HUMAN_ACTIVE'); - assert.equal(hold(String(nowSec() - 60)),null); + assert.equal(hold(typed(5)).code,'KADAN_HUMAN_ACTIVE'); + assert.equal(hold(typed(60)),null); }); test('현재 프로세스의 명시 모델은 실행기 자리와 함께 일치해야 한다', () => { diff --git a/tests/automatic-review-cli.test.mjs b/tests/automatic-review-cli.test.mjs index 3dfdae0e..8e56e6e2 100644 --- a/tests/automatic-review-cli.test.mjs +++ b/tests/automatic-review-cli.test.mjs @@ -5,6 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import {createHash} from 'node:crypto'; import {spawnSync,spawn} from 'node:child_process'; +import {attachHuman} from './helpers/human-client.mjs'; import {createDatabase,writeStorageMarker} from '../src/storage.mjs'; import {readLedger,readMailLedger,readTaskLedger,readMailBody} from '../src/ledger.mjs'; import {digest,findDoneMarkers} from '../src/cli.mjs'; @@ -24,12 +25,9 @@ test('자동 발령도 사람이 창에 붙어 최근 입력했으면 paste·Ent try{ for(const role of roles){run('start',role,'--hidden','--cmd',`${quote(harness)} --model test-model`);started.push(role);} const target='kadan-auto-worker'; - // 사람이 붙은 창은 제어 모드 client로 대신한다. 붙은 시각이 client_activity가 되어 30초 안이다. - human=spawn('tmux',['-L',env.KADAN_SOCKET,'-C','attach','-t','='+target],{stdio:['pipe','ignore','ignore']}); - const until=Date.now()+3000; - while(tm('list-clients','-t','='+target,'-F','#{client_activity}').stdout.trim()===''){ - assert.ok(Date.now() spawnSync(tmuxBin, ['-L', socket, 'list-clients', '-t', target, '-F', '#{client_created} #{client_activity}'], + {encoding: 'utf8'}).stdout.trim(); + const sleep = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + const end = Date.now() + 5000; + while (clients() === '' && Date.now() < end) sleep(20); + return { + child, clients, + // 붙은 지 2초 넘은 뒤 키를 눌러야 입력으로 센다(초 단위 시각). + type(keys = 'x') { + sleep(2500); child.stdin.write(keys); + const until = Date.now() + 5000; + const moved = () => { const [c, a] = clients().split(' ').map(Number); return a - c > 1; }; + while (!moved() && Date.now() < until) sleep(20); + }, + kill() { child.kill(); }, + }; +} diff --git a/tests/watch-notify-hold.test.mjs b/tests/watch-notify-hold.test.mjs index 43423548..36d5d1d0 100644 --- a/tests/watch-notify-hold.test.mjs +++ b/tests/watch-notify-hold.test.mjs @@ -4,7 +4,8 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import {spawn, spawnSync} from 'node:child_process'; +import {spawnSync} from 'node:child_process'; +import {attachHuman} from './helpers/human-client.mjs'; import {runWatch} from '../src/watch-runner.mjs'; import {guardedSend} from '../src/cli-guarded-send.mjs'; import {appendLedger, readLedger} from '../src/ledger.mjs'; @@ -221,9 +222,15 @@ readline.createInterface({input:process.stdin}).on('line',l=>{fs.appendFileSync( const sends = () => readLedger(home).filter(e => e.kind === 'send'); assert.equal(sends().length, 1); assert.notEqual(sends()[0].transport, 'mailbox', '바로 들어간 편지는 보관 우편이 아니라 감시기가 따로 알리지 않는다'); - // 걸림 → 보관, 창은 그대로. 사람이 붙은 창은 제어 모드 client로 대신한다(붙은 시각이 client_activity가 된다). - human = spawn('tmux', ['-L', socket, '-C', 'attach', '-t', target.replace(/:$/, '')], {env, stdio: ['pipe', 'ignore', 'ignore']}); - waitFor(() => tmuxCmd(['list-clients', '-t', target.replace(/:$/, ''), '-F', '#{client_activity}']).stdout.trim() !== ''); + // 사람이 방금 붙기만 했고 키는 안 눌렀으면 막지 않는다(로티 탭이 시작과 동시에 붙는 표준 흐름). + human = attachHuman('tmux', socket, '=kadan-q-super'); + const attachedOnly = call('p-super', 'mail', 'q-super', 'PEER_ATTACHED_ONLY'); + assert.equal(attachedOnly.status, 0, attachedOnly.stderr); + waitFor(() => contents().includes('PEER_ATTACHED_ONLY')); + assert.match(contents(), /PEER_ATTACHED_ONLY/); + assert.equal(sends().length, 2); + // 사람이 키를 누르면 보류 → 보관, 창은 그대로. + human.type('x'); const before = contents(); const held = call('p-super', 'mail', 'q-super', 'PEER_HELD'); assert.equal(held.status, 0, held.stderr); From a63ab2820877094abe890aa759bfc86378d6e4aa Mon Sep 17 00:00:00 2001 From: fwkyle Date: Sat, 17 Oct 2026 18:22:00 +0900 Subject: [PATCH 4/6] =?UTF-8?q?test:=20=EC=8B=9C=ED=97=98=EC=9A=A9=20?= =?UTF-8?q?=EC=82=AC=EB=9E=8C=20client=EC=97=90=20TERM=C2=B7UTF-8=20?= =?UTF-8?q?=EC=A7=80=EC=A0=95,=20=EB=B6=99=EC=A7=80=20=EB=AA=BB=ED=95=98?= =?UTF-8?q?=EB=A9=B4=20=EB=AA=85=EC=8B=9C=20=EC=8B=A4=ED=8C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- tests/ai-identity-boundaries-cli.test.mjs | 2 +- tests/helpers/human-client.mjs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/ai-identity-boundaries-cli.test.mjs b/tests/ai-identity-boundaries-cli.test.mjs index 0bf76de8..157d2b59 100644 --- a/tests/ai-identity-boundaries-cli.test.mjs +++ b/tests/ai-identity-boundaries-cli.test.mjs @@ -31,7 +31,7 @@ if(a.includes('load-buffer')&&fs.existsSync(raceFile)) { const race=JSON.parse(fs.readFileSync(raceFile,'utf8')); if(race.active) { fs.writeFileSync(raceFile,JSON.stringify({...race,active:false})); - if(race.attach)spawn('sh',['-c',"(sleep 2.5; printf x; sleep 8) | python3 -c 'import pty,sys;pty.spawn(sys.argv[1:])' '"+${JSON.stringify(realTmux)}+"' -L '"+process.env.KADAN_SOCKET+"' attach -t =kadan-"+race.role],{detached:true,stdio:'ignore'}).unref(); + if(race.attach)spawn('sh',['-c',"(sleep 2.5; printf x; sleep 8) | TERM=xterm-256color python3 -c 'import pty,sys;pty.spawn(sys.argv[1:])' '"+${JSON.stringify(realTmux)}+"' -u -L '"+process.env.KADAN_SOCKET+"' attach -t =kadan-"+race.role],{detached:true,stdio:'ignore'}).unref(); else spawnSync(${JSON.stringify(realTmux)},['-L',process.env.KADAN_SOCKET,'send-keys','-t','kadan-'+race.role,...race.keys]); const end=Date.now()+(race.attach?9000:3000);let observed=false; while(Date.now() {}); const clients = () => spawnSync(tmuxBin, ['-L', socket, 'list-clients', '-t', target, '-F', '#{client_created} #{client_activity}'], {encoding: 'utf8'}).stdout.trim(); const sleep = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); const end = Date.now() + 5000; while (clients() === '' && Date.now() < end) sleep(20); + if (clients() === '') { child.kill(); throw new Error('시험 사람 client가 tmux에 붙지 못했다'); } return { child, clients, // 붙은 지 2초 넘은 뒤 키를 눌러야 입력으로 센다(초 단위 시각). From 2029bffa6133fc37dfc41b61576233d761346f14 Mon Sep 17 00:00:00 2001 From: fwkyle Date: Sat, 17 Oct 2026 18:25:58 +0900 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20merge=20=EB=92=A4=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EC=A7=80=EB=8F=84=20=EC=95=8C=EB=A6=BC=EB=B0=80?= =?UTF-8?q?=EB=A6=BC=20=EC=A4=84=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/flows.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/flows.md b/docs/flows.md index 7e78d1d5..0b27ae70 100644 --- a/docs/flows.md +++ b/docs/flows.md @@ -87,6 +87,7 @@ | 큐 대기 (`큐대기`) | 발령 없는 세션 등 | 그 밖의 큐 배너·큐 블록이 5분 넘게 남음 | 직속 상위 | | 상태 확인 필요 (`모름`) | 등록 역할·감시기 | 화면을 읽지 못함(역할), 원장을 읽지 못함(감시기) | 역할은 직속 상위, 원장 오류는 공통 수신자 | | 감시AI 오류 (`감시AI오류`) | 감시 AI 호출 | 판정 호출이 실패·시간 초과·보고 누락(대체 후보까지 실패). 본문은 "감시기의 점검 AI가 <역할> 점검에 실패 — 무슨 일 · 대상 문제 여부는 모름 · 할 일"로 쉽게 쓴다. 인터넷 없음은 깨어 있는 동안 3번 연속일 때만, 맥북 잠자기로 끊긴 호출은 알리지 않음 | 사용자(디스코드, 안 되면 OS 알림·감시 출력). 판정 프로그램·모델 장애는 운영자 몫이라 감독에게 보내지 않는다 | +| 알림 밀림 (`알림밀림`) | 받는 창(역할) | 감시기가 보내려는 알림(미확인 우편 재알림·감시 경보·슈퍼 간 보관 우편)이 입력 보호(최근 30초 사람 입력)나 tmux 복사 모드로 3번 연속 밀림. 5번 연속이면 입력 보호를 건너뛰고(복사 모드는 해제·재확인 뒤) 넣고 원장에 `watch-notify-forced`를 남김. 전달에 성공하면 닫힘 → [입력 보호 상한](watch-overview.md#입력-보호-상한-2026-10-13) | 사용자(OS 알림). 밀리는 그 창이나 상위로는 보내지 않음 | | 전달 실패 (`전달실패`) | 경보 자체 | 경보 우편을 받는 쪽 창에 넣지 못함 | 받지 못한 쪽의 상위(관계표에 없으면 `--super`) | | 감시 제외 (`감시제외`) | 경보 자체 | 대상이 감시 범위를 벗어나 경보를 닫음(회복 편지 없음) | 기록만 | | 자원 (`자원`) | 이 컴퓨터 | 메모리·스왑·부하 | 우편 없음. 대시보드에서 본다 | From 094028eb34fc7398eec4a895e638aa6c0a912018 Mon Sep 17 00:00:00 2001 From: fwkyle Date: Sat, 17 Oct 2026 19:46:50 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=EB=B6=99=EC=9D=80=20=EB=92=A4=201?= =?UTF-8?q?=EC=B4=88=EB=8C=80=20=EC=8B=A4=EC=A0=9C=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EB=8F=84=20=EC=82=AC=EB=9E=8C=20=EC=9E=85=EB=A0=A5=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B4=ED=98=B8=20(activity=20>=20created)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 초 단위 값에서 activity - created > 1 규칙은 붙은 뒤 약 1.25초에 친 문장을 놓쳤다(검수 P2). 활동이 붙은 시각보다 뒤이면(차이 1초 이상) 사람 입력으로 센다. Co-Authored-By: Claude Sonnet 5.5 --- docs/automatic-review.md | 4 ++-- docs/design.md | 4 ++-- src/floor-tmux.mjs | 7 +++---- tests/ai-identity-boundaries-cli.test.mjs | 8 ++++++++ tests/ai-identity-send-boundaries.test.mjs | 16 +++++++++++----- tests/helpers/human-client.mjs | 8 ++++---- tests/watch-notify-hold.test.mjs | 6 ++++-- 7 files changed, 34 insertions(+), 19 deletions(-) diff --git a/docs/automatic-review.md b/docs/automatic-review.md index d1e15540..23ebbf35 100644 --- a/docs/automatic-review.md +++ b/docs/automatic-review.md @@ -26,8 +26,8 @@ ready/assigned/done 상태만 가능하다. 원본 본문을 복제하지 않고 같은 업무의 다른 실행이나 다른 담당이 두 역할을 점유하고 있으면 설정·전달을 막는다. 자동 발령에도 일반 카드 발령과 같은 실제 AI 프로세스·모델·사람 최근 입력(30초) 검사가 적용된다. -등록된 실행기라도 화면의 안내 문구·테두리·기존 출력 때문에 빈 입력 영역을 확정할 수 -없으면 전송 전에 `exception`으로 멈춘다. 실제 Devin 화면에서도 이 제한을 확인했으므로, +사람이 그 창에서 최근 30초 안에 키를 눌렀으면 전송 전에 `exception`으로 멈춘다. +화면의 안내 문구·테두리·기존 출력으로 빈 입력 영역을 판정하지 않는다(2026-10-17). 지원 명령 목록이나 격리 시험 통과만으로 실제 UI의 자동 왕복이 검증됐다고 보지 않는다. ```sh diff --git a/docs/design.md b/docs/design.md index b92134be..1ea1a9cf 100644 --- a/docs/design.md +++ b/docs/design.md @@ -93,8 +93,8 @@ tmux는 (a)(b)(c)를 충족했고 (d)는 Mac에서 통과했다(서버 왕복은 **받아들인 손해:** 사람이 글을 치다 30초 넘게 손을 떼고 그대로 두면 그 글 뒤에 우편이 붙어 제출될 수 있다. **붙기만 한 창은 사람 입력으로 보지 않는다:** tmux는 client가 붙는 순간 `client_activity`를 `client_created`와 같게 두고, 크기 바꾸기·`paste-buffer`·`send-keys`로는 바뀌지 않으며 그 client의 키 입력으로만 바뀐다(제어 모드 client는 명령을 보내도 안 바뀐다, 2026-10-17 격리 소켓 실측). - 그래서 활동이 붙은 시각보다 1초 넘게 뒤일 때만 사람 입력으로 센다. 로티 탭이 시작과 동시에 붙어도 첫 안내·발령이 막히지 않는다. - 한계: 붙은 지 1초 안에 누른 첫 키는 놓친다. + 그래서 활동이 붙은 시각보다 뒤일 때(`activity > created`)만 사람 입력으로 센다. 로티 탭이 시작과 동시에 붙어도 첫 안내·발령이 막히지 않는다. + 한계: 붙은 바로 그 초 안에 누른 키는 놓친다. 반대로 붙은 뒤 터미널 응답 같은 입력으로 활동이 바뀌면 그 창은 30초 보류된다(막히는 것은 시작 첫 안내뿐이고 start는 성공). 이는 화면에 보이는 확인이 아니며 AI 인증·모델 응답 성공을 증명하지 않는다. 복사모드 검사는 기존 공통 전송 경계를 재사용한다. 이 tmux 프로세스·입력 검증은 Rottie 바닥의 실측을 대신하지 않는다. 실제 전경 AI 프로세스의 명시 모델도 시작 기록과 대조한다. 불일치·미확인이면 거부한다. diff --git a/src/floor-tmux.mjs b/src/floor-tmux.mjs index e561758c..f462d5c7 100644 --- a/src/floor-tmux.mjs +++ b/src/floor-tmux.mjs @@ -447,16 +447,15 @@ export function sendTmuxEnter(session, {run = tmuxOut} = {}) { // 창에 붙은 사람이 humanIdleMs 안에 키를 눌렀으면 보류한다. 붙은 client가 없으면 통과한다. // 붙기만 한 client는 사람 입력이 아니다: tmux는 붙는 순간 client_activity를 client_created와 같게 두고 // (격리 소켓 실측 2026-10-17: 크기 바꾸기·paste-buffer·send-keys로는 안 변하고 그 client의 키 입력으로만 변한다), -// 제어 모드 client는 명령을 보내도 변하지 않는다. 그래서 활동이 생성 시각보다 1초 넘게 뒤일 때만 입력으로 센다. +// 제어 모드 client는 명령을 보내도 변하지 않는다. 그래서 활동이 생성 시각보다 뒤일 때(`activity > created`, 초 단위라 차이 1초 이상)만 입력으로 센다. // 받아들인 손해: 사람이 글을 치다 그 시간 넘게 손을 떼면 그 글 뒤에 우편이 붙어 제출될 수 있고, -// 붙은 지 1초 안에 누른 첫 키는 놓친다. kadan의 붙여넣기(paste-buffer)는 client_activity를 바꾸지 않는다. -const ATTACH_ONLY_SECONDS = 1; +// 붙은 바로 그 초 안에 누른 키는 놓친다. 반대로 붙은 뒤 터미널 응답 같은 입력이 다음 초에 들어와 활동이 바뀌면 그 창은 30초 보류된다. kadan의 붙여넣기(paste-buffer)는 client_activity를 바꾸지 않는다. function checkHumanIdle(session, run, {humanIdleMs = HUMAN_IDLE_MS, now} = {}) { const deny = (code, message) => { const error = new Error(message); error.code = code; throw error; }; const clients = run(["list-clients", "-t", `=${session}`, "-F", "#{client_created} #{client_activity}"]).split("\n").filter(Boolean); if (clients.some(value => !/^\d+ \d+$/.test(value))) deny("KADAN_HUMAN_ACTIVITY_UNKNOWN", "전송 보류: 사람 입력 시각 미확인 — 잠시 뒤 다시 보내라"); const at = now ?? Date.now(); - const typed = clients.map(value => value.split(" ").map(Number)).filter(([created, activity]) => activity - created > ATTACH_ONLY_SECONDS); + const typed = clients.map(value => value.split(" ").map(Number)).filter(([created, activity]) => activity > created); if (typed.some(([, activity]) => at - activity * 1000 < humanIdleMs)) deny("KADAN_HUMAN_ACTIVE", "전송 보류: 사람이 이 창에서 최근 입력함 — 잠시 뒤 다시 보내라"); } diff --git a/tests/ai-identity-boundaries-cli.test.mjs b/tests/ai-identity-boundaries-cli.test.mjs index 157d2b59..53a90dc4 100644 --- a/tests/ai-identity-boundaries-cli.test.mjs +++ b/tests/ai-identity-boundaries-cli.test.mjs @@ -77,6 +77,14 @@ process.exit(r.status??1);\n`,{mode:0o755}); assert.match(humanProbe.error,/사람이 이 창에서 최근 입력/); assert.doesNotMatch(tm('capture-pane','-p','-t','kadan-resume').stdout,/REVIEW_BODY_human-active/); human.kill(); + // 검수 P2 재현: 붙은 뒤 약 1.25초에 친 문장도 카드·일반 우편 모두 보호한다. + const quick=attachHuman(realTmux,env.KADAN_SOCKET,'=kadan-resume'); + quick.type('y',1250); + for(const [name,args] of [['quick-card',['--task','resume']],['quick-mail',['--execution','r/resume']]]){ + const q=probe('human-1.25s-'+name,'resume',args); + assert.match(q.error,/사람이 이 창에서 최근 입력/); + } + quick.kill(); start('dead',bin+'/codex --model model-a');tm('set-option','-t','kadan-dead','remain-on-exit','on');tm('send-keys','-t','kadan-dead','C-c');waitFor(()=>tm('display-message','-p','-t','kadan-dead','#{pane_dead}').stdout.trim(),dead=>dead==='1','dead pane');probe('dead-pane','dead',['--task','dead']); start('spoof',bin+'/codex --version; exec cat # --model model-a',false);probe('version-then-cat','spoof',['--execution','r/spoof']); const replaced=path.join(home,'replaced');fs.mkdirSync(replaced); diff --git a/tests/ai-identity-send-boundaries.test.mjs b/tests/ai-identity-send-boundaries.test.mjs index 5be7973b..e44aede4 100644 --- a/tests/ai-identity-send-boundaries.test.mjs +++ b/tests/ai-identity-send-boundaries.test.mjs @@ -139,23 +139,29 @@ for (const [path, cfg] of Object.entries(PATHS)) { } test('방금 붙었지만 키를 누르지 않은 client는 사람 입력이 아니다 — 세 경로 모두 통과', () => { - for (const cfg of Object.values(PATHS)) for (const clients of [attachedOnly(0), attachedOnly(1), `${attachedOnly(0)}\n${typed(3600)}`]) { + for (const cfg of Object.values(PATHS)) for (const clients of [attachedOnly(0), attachedOnly(3), `${attachedOnly(0)}\n${typed(3600)}`]) { const f=fixture({clients, card:cfg.card}); send(f, cfg.guard ? {humanIdleMs:30_000} : undefined); assert.equal(count(f,'paste-buffer'),1, clients); } }); -test('붙은 뒤 10초에 키를 누르면 보류한다(붙은 시각 기준 1초 넘게 뒤의 활동만 입력)', () => { +test('붙은 뒤 10초에 키를 누르면 보류한다(활동이 붙은 시각보다 뒤일 때만 입력)', () => { const at=nowSec(); for (const cfg of Object.values(PATHS)) { const f=fixture({clients:`${at - 12} ${at - 2}`, card:cfg.card}); assert.throws(()=>send(f, cfg.guard ? {humanIdleMs:30_000} : undefined), e=>e.code==='KADAN_HUMAN_ACTIVE' && e.delivery==='not-sent'); assert.equal(count(f,'paste-buffer'),0); } - // 경계: 붙은 지 정확히 1초 뒤 활동은 입력으로 세지 않고, 2초 뒤부터 센다. - assert.equal(count((()=>{const f=fixture({clients:`${at - 5} ${at - 4}`, card:false}); send(f); return f;})(),'paste-buffer'),1); - assert.throws(()=>send(fixture({clients:`${at - 5} ${at - 3}`, card:false})), e=>e.code==='KADAN_HUMAN_ACTIVE'); + // 경계: 차이 0이면 통과, 차이 1이면 보류(붙은 뒤 1초대 입력도 입력이다, 검수 P2). + for (const cfg of Object.values(PATHS)) { + const pass=fixture({clients:`${at - 5} ${at - 5}`, card:cfg.card}); + send(pass, cfg.guard ? {humanIdleMs:30_000} : undefined); + assert.equal(count(pass,'paste-buffer'),1); + const held=fixture({clients:`${at - 5} ${at - 4}`, card:cfg.card}); + assert.throws(()=>send(held, cfg.guard ? {humanIdleMs:30_000} : undefined), e=>e.code==='KADAN_HUMAN_ACTIVE'); + assert.equal(count(held,'paste-buffer'),0); + } }); test('client 값이 한쪽만 있거나 음수·소수면 실패 닫힘', () => { diff --git a/tests/helpers/human-client.mjs b/tests/helpers/human-client.mjs index becb25f0..0dafac0b 100644 --- a/tests/helpers/human-client.mjs +++ b/tests/helpers/human-client.mjs @@ -14,11 +14,11 @@ export function attachHuman(tmuxBin, socket, target) { if (clients() === '') { child.kill(); throw new Error('시험 사람 client가 tmux에 붙지 못했다'); } return { child, clients, - // 붙은 지 2초 넘은 뒤 키를 눌러야 입력으로 센다(초 단위 시각). - type(keys = 'x') { - sleep(2500); child.stdin.write(keys); + // 붙은 뒤 delayMs 지나 키를 누른다. 활동은 초 단위라 붙은 시각보다 1초 이상 뒤여야 값이 달라진다. + type(keys = 'x', delayMs = 2500) { + sleep(delayMs); child.stdin.write(keys); const until = Date.now() + 5000; - const moved = () => { const [c, a] = clients().split(' ').map(Number); return a - c > 1; }; + const moved = () => { const [c, a] = clients().split(' ').map(Number); return a > c; }; while (!moved() && Date.now() < until) sleep(20); }, kill() { child.kill(); }, diff --git a/tests/watch-notify-hold.test.mjs b/tests/watch-notify-hold.test.mjs index 36d5d1d0..31246058 100644 --- a/tests/watch-notify-hold.test.mjs +++ b/tests/watch-notify-hold.test.mjs @@ -229,8 +229,10 @@ readline.createInterface({input:process.stdin}).on('line',l=>{fs.appendFileSync( waitFor(() => contents().includes('PEER_ATTACHED_ONLY')); assert.match(contents(), /PEER_ATTACHED_ONLY/); assert.equal(sends().length, 2); - // 사람이 키를 누르면 보류 → 보관, 창은 그대로. - human.type('x'); + // 사람이 붙은 뒤 1.25초에 키를 누르면(검수 P2) 보류 → 보관, 창은 그대로. + human.kill(); + human = attachHuman('tmux', socket, '=kadan-q-super'); + human.type('x', 1250); const before = contents(); const held = call('p-super', 'mail', 'q-super', 'PEER_HELD'); assert.equal(held.status, 0, held.stderr);