-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtick.sh
More file actions
executable file
·2271 lines (2118 loc) · 132 KB
/
Copy pathtick.sh
File metadata and controls
executable file
·2271 lines (2118 loc) · 132 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/bash
# 파일시스템 티켓 디스패처(프로젝트 무관). 진입점은 워커 스크립트다 - 이 파일을 직접 부르지 않는다.
# 워커 = <티켓루트>/workers/<이름>.sh, 이 파일을 `.`(source)하는 두 줄짜리 셸 스크립트.
# 크론잡 하나가 워커 하나고, 한 번 실행에 티켓 1건을 동기로 끝낸다. 더 돌리려면 워커를 더 둔다.
# <루트>/workers/w1.sh 티켓 1건 디스패치 (cron 진입점)
# <루트>/workers/w1.sh list 열린 티켓 큐 상태
# <루트>/workers/w1.sh unassign H 티켓 H의 할당(session_id) 해제 -> 큐 복귀
# (산 세션이면 거부하고 exit 3. --force면 그 pid를 죽여서 푼다)
# <루트>/workers/w1.sh reap 스테일 수거만 1회
# <루트>/workers/w1.sh dryrun 실행 없이 선정 결과만 출력
# <루트>/workers/w1.sh poll H 스크립트파일명 상한 폴링 대기로 전환(§폴링 대기 결정 8).
# <루트>/polls/<스크립트파일명>이 있어야 하고, 검사 다섯을 하나라도
# 걸리면 frontmatter를 안 고치고 0이 아닌 코드로 거절한다
# <루트>/workers/w1.sh tick H 지목 디스패치(DESIGN.md §1-5 갈래 B) - 후보를 H 하나로 좁힌다
# <루트>/workers/w1.sh preempt H [--dryrun] 선점(§1-5 갈래 C) - 피해자를 끊고 H를 지목 디스패치
# 워커 계약(설정 가능한 값)은 worker.sh.example 참조.
set -uo pipefail
export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
# 코드(이 레포)와 워커(티켓 루트 안)는 다른 곳에 있다.
# BASH_SOURCE = 이 파일(source돼도 맞다) | $0 = 나를 source한 워커 스크립트
SELF="${BASH_SOURCE[0]}"
while [ -L "$SELF" ]; do SELF="$(readlink "$SELF")"; done
CODE="$(cd "$(dirname "$SELF")" && pwd -P)"
PY="$CODE/tickets.py"
# 워커 위치가 곧 티켓 루트다: <루트>/workers/<이름>.sh. 그래서 루트를 어디에도 적지 않는다.
WORKERS="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P)"
if [ "$(basename "${WORKERS:-/}")" != "workers" ]; then
echo "이 파일은 직접 실행하지 않는다. <티켓루트>/workers/<이름>.sh 를 만들어 source하세요 ($CODE/worker.sh.example 참조)" >&2
exit 2
fi
TICKET_ROOT="$(dirname "$WORKERS")"
LOGDIR="$WORKERS/logs"
RUNLOG="$WORKERS/runner.log"
# 머신 로컬 상태(토큰·실행 락). 티켓 루트가 공유 드라이브여도 비밀과 pid는 여기 남는다.
LOCAL="${TICKET_LOCAL:-$HOME/.config/dira}"
# 멀티플레잉(§0-18): TICKET_SLOT이 비어 있으면 이 값은 오늘과 글자 그대로 같다.
TOKENF="$LOCAL/oauth-token${TICKET_SLOT:+-$TICKET_SLOT}"
mkdir -p "$LOGDIR" "$LOCAL/run" "$TICKET_ROOT/tickets" "$TICKET_ROOT/polls"
log() { printf '%s [%s] %s\n' "$(date '+%F %T')" "${TICKET_NAME:-?}" "$*" >> "$RUNLOG"; }
TICKET_NAME="${TICKET_NAME:-$(basename "$0" .sh)}"
# 기본 작업 디렉터리 = 루트의 부모(<프로젝트>/.dira/workers/w1.sh -> <프로젝트>)
TICKET_CWD="${TICKET_CWD:-$(dirname "$TICKET_ROOT")}"
# 프롬프트에 큐 경로를 적을 때, 같은 곳을 가리키는 워크트리 안의 이름이 있으면 그 이름을
# 쓴다(§워커는 언제나 자기 워크트리에서 일한다 §개정 1). 읽는 파일은 안 갈린다 - 프롬프트
# 문자열만 바뀐다. $1이 $TICKET_ROOT 밖이거나(TICKET_ONTOLOGY 같은 오버라이드) 후보 디렉터리가
# 같은 canonical로 안 풀리면(TICKET_CWD 없는 워커) 원래 경로를 그대로 낸다.
wtpath() {
local canonical="$1" rest base candroot
case "$canonical" in
"$TICKET_ROOT"/*) rest="${canonical#"$TICKET_ROOT"/}" ;;
*) printf '%s\n' "$canonical"; return ;;
esac
base="$(basename "$TICKET_ROOT")"
candroot="$TICKET_CWD/$base"
if [ -d "$candroot" ] && [ "$(cd "$candroot" 2>/dev/null && pwd -P)" = "$TICKET_ROOT" ]; then
printf '%s\n' "$candroot/$rest"
else
printf '%s\n' "$canonical"
fi
}
TICKET_MAXRUN="${TICKET_MAXRUN:-5400}"
# 디스패치가 실제로 섰다고 볼 때까지 기다리는 상한(프롬프트 주입 완료 + 엔진 init).
# 프롬프트는 파이프 버퍼보다 크므로 엔진이 stdin을 빨아야만 주입이 끝난다 - 기동 중 멎으면 영영 안 끝난다.
TICKET_FEED_TIMEOUT="${TICKET_FEED_TIMEOUT:-120}"
# 프롬프트 포맷의 %s = 티켓 해시 하나뿐이다(역할 호칭은 페르소나 프로필이 대신한다).
TICKET_PROMPT_FMT="${TICKET_PROMPT_FMT:-%s 티켓을 확인해 주세요. (해당 티켓은 이미 진행중으로 잡아두었습니다. 수행을 마치면 완료 상태로 rename하고, 막히면 티켓 본문에 블록 이력을 남겨주세요.)}"
# 세션 재활용(§4-11, §제약 1 §결정 기록 §열셋째). 0이면 기능 전체를 끈다(기본 켜짐).
# CTX는 마지막 assistant 턴 컨텍스트(input+cache_creation+cache_read)의 재활용 상한이다.
TICKET_REUSE="${TICKET_REUSE:-1}"
TICKET_REUSE_CTX="${TICKET_REUSE_CTX:-100000}"
# 엔진 수정 서른네 번째 승인 §판정 1. `## 진행 계획`이 이 초(기본 1200 = 20분) 동안 안 갈리면
# 감시 루프가 세션 inbox에 참견 한 줄을 넣는다. 0이면 장치가 꺼진다. 값의 근거는 임계값
# 실측표(정상 간격의 5.1%만 넘고, 반례 세션의 34분 생존은 잡는다) - docs/DESIGN.md 그 절.
TICKET_PLAN_NUDGE="${TICKET_PLAN_NUDGE:-1200}"
# 엔진 수정 서른여섯 번째 승인 §판정 2. 참견을 넣은 뒤 이 초 동안도 티켓 파일이 안 갈리면
# 그 세션을 끊고 티켓을 되돌린다. 0이면 장치가 꺼진다(기본값) - 반례가 4.8%뿐인 실측표라
# 켜는 값은 운영이 관측하며 고른다. 사람 참견이 그 사이 들어왔으면 안 끊는다(check_plan_kill).
TICKET_PLAN_KILL="${TICKET_PLAN_KILL:-0}"
# 엔진 수정 마흔한 번째 승인 §판정 1. `result ok`를 낸 세션이 티켓을 `.wip`으로 남긴 채
# 죽기 전, 이번 구간에 하네스가 밀어낸(`moved to the background`) 명령이 있으면 그 알림이
# 닿을 창을 이 초(기본 40)만큼 준다. 0이면 장치가 꺼진다. 기본값은 apps/teams의 pnpm test
# 실측 셋(24.07s/22.41s/22.51s, 최댓값의 1.5배 36.105s를 10초 단위로 올림)이다.
TICKET_BG_GRACE="${TICKET_BG_GRACE:-40}"
# 한 티켓에서 유예가 몇 번까지 걸리는지의 상한. 밀린 작업이 계속 새로 생기는 세션이 창을
# 무한히 늘리는 것을 이 수가 막는다 - REUSE로 새 티켓을 받으면 다시 0부터 잰다.
TICKET_BG_MAX="${TICKET_BG_MAX:-2}"
# 엔진 수정 서른아홉 번째 승인 §판정 1. MAXRUN 감시 루프가 이 초(기본 60)마다
# `workers/health/YYYYMMDD.log`에 바깥 상태 한 줄을 적는다. 부모 셸까지 함께 죽는 세션은 종료
# 줄을 남길 손이 없으므로, 죽기 직전 상태를 남기려면 종료 시점이 아니라 도는 동안 적어야 한다.
# 0이면 장치가 꺼지고 파일도 디렉터리도 안 생긴다.
TICKET_HEALTH_INTERVAL="${TICKET_HEALTH_INTERVAL:-60}"
# 엔진 수정 스물네·스물일곱 번째 계약: 워커가 부르는 실행 파일의 자리를 고정 경로로 모은다.
# claude 실행 파일은 심링크고 실체가 업데이트마다 새 버전 디렉터리로 옮겨간다(이 머신 버전
# 디렉터리 32개) - macOS TCC는 허용을 절대경로로 적어서 매 업데이트가 새 TCC 항목이 된다.
# BIN_DIR을 그 실체로 매 tick 다시 겨눠, 경로 문자열은 고정한 채(TCC 항목 1개) 가리키는
# 실체만 갈리게 한다. 심링크는 답이 아니다 - exec가 심링크를 풀어서 TCC가 여전히 버전 경로를 본다.
# §27 계약 1: bin 자리는 TICKET_LOCAL을 안 탄다 - 프로젝트가 락·토큰을 자기 디렉터리로 빼도
# 실행 파일은 머신에 한 자리다(오늘 기본값이 이미 이 값이라 도는 경로는 안 갈린다).
BIN_DIR="$HOME/.config/dira/bin"
FIXED_ENGINE="$BIN_DIR/dira"
refresh_fixed_engine() {
# §27 계약 2: 카탈로그 넷을 각각 굽는다 - claude만 이름이 dira, 나머지는 dira-<엔진>
# (§24 계약 3 접미사 판정과 대응). PATH에 없는 엔진은 조용히 건너뛴다(WARN 없음).
local pair cmd bin src real tmp
mkdir -p "$BIN_DIR" 2>/dev/null || return 1
for pair in claude:dira codex:dira-codex grok:dira-grok agy:dira-agy; do
cmd="${pair%%:*}"
bin="$BIN_DIR/${pair#*:}"
src="$(command -v "$cmd" 2>/dev/null)" || continue
[ -x "$src" ] || continue
# §계약 3 판정(2026-08-25): 신선도는 굽힌 파일의 inode가 아니라 <PATH의 실체가 여전히
# 같은가>로 잰다 - 아래가 복사라 `-ef`(device+inode 비교)는 언제나 거짓이 된다. 실체 경로를
# `$bin.src`에 적어 두고 문자열로 대조한다(`readlink -f`는 BSD에 없어 python3로 낸다).
real="$(python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$src" 2>/dev/null)"
[ -n "$real" ] || continue
[ -e "$bin" ] && [ "$(cat "$bin.src" 2>/dev/null)" = "$real" ] && continue
tmp="$bin.tmp.$$"
# 별도 inode로 굽는다 - 하드링크는 tccd가 원본 inode의 이름을 그대로 아이덴티티로 적어
# TCC 항목이 안 모인다(§계약 3 판정 실측). APFS 클론(`cp -c`)이 서면 디스크를 안 물고,
# 안 서면(다른 볼륨·비-APFS) 보통 복사로 떨어진다. 임시 이름에 만든 뒤 mv로 원자 교체 -
# 여러 워커가 동시에 tick을 돌아도 실행 파일이 반쪽으로 보이는 창이 없다.
if cp -c "$src" "$tmp" 2>/dev/null || cp "$src" "$tmp" 2>/dev/null; then
mv -f "$tmp" "$bin" && printf '%s' "$real" > "$bin.src" 2>/dev/null
else
rm -f "$tmp"
fi
done
# §27 계약 4: mv -f가 지금 실행 중인 고정 경로를 못 덮을 때(macOS ETXTBSY, 워커 여러 장이
# 도는 큐에서는 정상) 남는 임시 파일을 갱신할 때마다 쓸어낸다. 갱신 실패는 종전대로 조용하다.
rm -f "$BIN_DIR"/*.tmp.[0-9]* 2>/dev/null
}
refresh_fixed_engine
# 실행 엔진. 워커가 TICKET_ENGINE 배열로 덮어쓴다. {prompt}/{sid}는 실행 직전 치환된다.
# ${arr[@]+"..."}는 set -u에서 미정의 배열을 안전하게 전개하는 관용구(bash 3.2 포함).
# 기본 엔진은 스트리밍 입력이다: 최초 프롬프트도 argv가 아니라 stdin(FIFO)으로 간다.
# 그래야 세션이 도는 동안 사람이 같은 입구로 말을 걸 수 있다(참견).
TICKET_ENGINE=(${TICKET_ENGINE[@]+"${TICKET_ENGINE[@]}"})
[ ${#TICKET_ENGINE[@]} -eq 0 ] && TICKET_ENGINE=("$FIXED_ENGINE" -p --session-id "{sid}" \
--dangerously-skip-permissions \
--input-format stream-json --output-format stream-json --verbose)
# 상태 접미사는 tickets.py가 환경변수로 읽는다(미설정이면 .wip/.done).
# TICKET_ONTOLOGY는 graph.py가 같은 형태로 읽는다(미설정이면 <큐>/ontology, 아래 ONTDIR과 동일).
export TICKET_INPROGRESS="${TICKET_INPROGRESS:-}" TICKET_DONE="${TICKET_DONE:-}" \
TICKET_ONTOLOGY="${TICKET_ONTOLOGY:-}"
# 이 시점의 값이 "기본 엔진"이다(워커 대입 -> 없으면 위 기본값). `personas/<이름>/engine`이
# 있으면 티켓 선정 루프 안에서 후보의 persona가 확정된 뒤 이 배열을 덮어쓴다(§제약 1 §결정
# 기록 §열한 번째) - 그래서 ENGINE_NAME 계산·쿨다운·claude 인증 게이트도 전부 그 뒤로
# 옮겨졌다. 여기서 하던 계산은 이제 없다(아래 선정 루프 참조).
BASE_ENGINE=("${TICKET_ENGINE[@]}")
CDOWN_W=300 # 리밋이 복귀 시각을 안 줄 때(네트워크 실패 등)의 창 + 만료 직후 재무장 창
# 세션이 실제로 디스패치되는 순간(engine_gate_ok가 게이트를 통과하는 순간)의 엔진 지문.
# api_error는 세션이 끝난(최대 몇 시간 뒤) 자리에서 arm_cdown을 부르는데, 그때 $TOKENF를 다시
# 읽으면 이미 다음 계정으로 회전된 뒤일 수 있다 - 그 값을 그대로 쓰면 늦게 도착한 보고가 리밋을
# 만난 적 없는 새 계정을 소진으로 찍고, 동시 세션 수만큼 계정이 연쇄로 말라 NO_ELIGIBLE로 큐가
# 멎는다(DESIGN.md §0-13 §한 리밋 사건에 계정 하나만 태운다, 실측 2026-08-27). 그래서 게이트를
# 통과하는 순간의 값을 여기 붙잡아 두고 arm_cdown이 기본값으로 쓴다.
DISPATCH_FP=""
# 엔진 지문 = **인증 토큰만**. 사람이 계정을 바꾸면 값이 갈리고 그 순간 쿨다운이 풀린다 -
# 리밋이 준 복귀 시각을 그대로 기다리면 계정을 바꿔도 몇 시간을 놀기 때문이다(요구 15ceae18).
#
# 엔진 argv(모델 플래그)는 **일부러 안 넣는다**. 창은 이름이 엔진별일 뿐 머신에 하나인데
# argv는 큐마다 다르다 - 넣었더니 "지문이 갈리면 푼다"가 *남의 창을 지우는* 동작이 됐다
# (실측 2026-08-05: `--model sonnet`인 dira와 모델 플래그가 없는 stream이 서로의 창을
# 1분마다 풀어서, 16:30까지 닫혀 있어야 할 창에서 같은 티켓을 27번 태웠다).
# 모델을 갈아도 5시간 리밋은 계정에 걸린 채라 애초에 풀 근거가 아니다.
engine_fp() {
python3 -c 'import hashlib,sys
try: tok = open(sys.argv[1], "rb").read()
except OSError: tok = b""
print(hashlib.sha1(tok).hexdigest()[:12])' \
"$TOKENF"
}
# $2(known)는 복귀 시각을 리밋이 실제로 줬는지 - 1이면 있음, 그 외(빈 값 포함)는 미상.
# token-rotate.sh의 tick 모드가 이 3번째 줄로 "미상" 쿨다운엔 exhaustedUntil을 안 찍는다
# (요구 61028b02/fc92d14e: 미상 쿨다운을 진짜 계정 소진과 같은 칸에 찍어 마지막 eligible
# 계정이 죽고 NO_ELIGIBLE로 큐가 11분 멈춘 실측). 옛 2줄 파일을 읽는 쪽은 3번째 read가
# EOF라 빈 값 -> 안전측(미상 취급)으로 떨어진다.
arm_cdown() { printf '%s\n%s\n%s\n' "$1" "${DISPATCH_FP:-$(engine_fp)}" "${2:-0}" > "$CDOWN"; }
# claude 인증 + 엔진 쿨다운 게이트. ENGINE_NAME·CDOWN이 이미 정해진 상태에서 부른다.
# 통과 못하면 SKIP을 로그하고 1을 반환한다 - 이 엔진을 쓰는 후보 전체를 건너뛰라는 뜻이라
# 선정 루프가 이 반환값으로 ENGOVER에 등록한다(§4-11 재활용 경로는 후보가 하나뿐이라 등록 없이
# 반환값만 쓴다 - 같은 게이트를 다시 지나는 것이 §4-11 §규칙 ⑤다).
engine_gate_ok() {
if [ "$ENGINE_NAME" = "claude" ]; then
# claude setup-token 으로 발급 후: printf %s '<토큰>' > ~/.config/dira/oauth-token
[ -r "$TOKENF" ] && export CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$TOKENF")"
# 비대화형 claude만 장기 토큰이 없으면 이 엔진으로 못 뜬다. `.authwarn`으로 "최초 1회만
# 남긴다"는 종전 계약 그대로 둔다.
if [ ! -r "$TOKENF" ] && [ ! -t 1 ]; then
if [ ! -f "$LOCAL/.authwarn" ]; then
log "SKIP AUTH 대기 $ENGINE_NAME (claude setup-token 발급 후 $TOKENF 에 저장 필요)"
touch "$LOCAL/.authwarn"
fi
return 1
fi
fi
local CUR_FP
CUR_FP="$(engine_fp)"
if [ -f "$CDOWN" ]; then
local UNTIL WAS_FP NOW
UNTIL=""; WAS_FP=""
{ read -r UNTIL; read -r WAS_FP; } < "$CDOWN"
case "$UNTIL" in ''|*[!0-9]*) UNTIL=0 ;; esac
NOW=$(date +%s)
if [ "$WAS_FP" != "$CUR_FP" ]; then
rm -f "$CDOWN"
log "NOTE 엔진 쿨다운 해제 - 토큰·모델이 바뀌었다(남은 창 $((UNTIL - NOW))초를 안 기다린다)"
elif [ "$NOW" -lt "$UNTIL" ]; then
log "SKIP 엔진 쿨다운 · $((UNTIL - NOW))초 남음"
return 1
fi
fi
DISPATCH_FP="$CUR_FP" # 이 후보가 실제로 나가는 순간의 지문 - arm_cdown이 나중에 이 값을 쓴다
return 0
}
# 선정 잠금 획득(§5-4 SLOCK). 실패하면 SLOCK=""로 남기고 1을 반환한다 - 트랩이 남의 잠금을
# 안 지우게 하는 값이자, §4-11 재활용 판정 ⑤가 실패로 떨어지는 신호로 두 호출부가 나눠 쓴다.
acquire_slock() {
SLOCK="$LOCAL/run/select.lock"
if ! mkdir "$SLOCK" 2>/dev/null; then
local SOWNER
SOWNER=$(cat "$SLOCK/pid" 2>/dev/null)
if [ -n "$SOWNER" ] && kill -0 "$SOWNER" 2>/dev/null; then
# 남의 잠금이다. 비워야 트랩이 주인의 잠금을 지우지 않는다.
SLOCK=""
log "SKIP 다른 워커가 선정 중이다 pid=$SOWNER"
return 1
fi
log "WARN 스테일 선정 잠금 회수 pid=${SOWNER:-?}"
rm -rf "$SLOCK"
mkdir "$SLOCK" 2>/dev/null || { SLOCK=""; log "SKIP 선정 잠금 재획득 실패"; return 1; }
fi
printf %s "$$" > "$SLOCK/pid"
}
# `personas/<이름>/limit` -> 정수 하나, 또는 "" = 상한 없음. 파서를 만들지 않는다.
# 양끝 공백·후행 개행은 값이 아니다 - 화면이 쓰는 사이드카는 전부 끝에 `\n`이 붙어서
# `2\n`·` 2 `·`2\n\n`이 전부 정수 2여야 한다(`read`가 첫 줄만 읽고 양끝 공백을 떼는 것이
# 그 규약 그대로다). 정수가 아니면 **상한 없음 + WARN**이고 0으로 읽지 않는다 - 오타 하나가
# 페르소나를 영구히 굶기는 쪽으로 떨어지면 안 된다.
# (아래 §선점 판정이 이 함수를 워커 락 exit 경로에서 부르므로 CMD 분기보다 앞에 선다.)
persona_limit() {
LIMF="${TICKET_PERSONAS:-$TICKET_ROOT/personas}/$1/limit"
[ -f "$LIMF" ] || return 0
PL=""; read -r PL < "$LIMF" 2>/dev/null
case "$PL" in
'') ;; # 빈 파일 = 상한 없음(기본값)
*[!0-9]*) log "WARN 페르소나 상한이 정수가 아니다: $LIMF ($PL) - 상한 없이 돈다" ;;
*) printf %s "$PL" ;;
esac
}
# 그 페르소나가 지금 물고 있는 수. 단위는 `.wip` 하나고 사본이 없다 - 보드가 세는 수와 같은
# 수이고, 손 claim(session_id 없이 진행중인 티켓)도 그래서 공짜로 같이 센다. 락 디렉터리 같은
# 두 번째 표현을 만들면 스테일이 그 페르소나를 영구히 0으로 굶긴다.
# 판정은 tickets.py를 그대로 불러 쓴다(NFC·상태 접미사·persona 문자 규칙이 한 벌이어야 한다).
persona_wip() {
python3 -c 'import sys
sys.path.insert(0, sys.argv[1])
import tickets as T
n = 0
for p in T.in_progress(sys.argv[2]):
try: fm, _, end = T.read_fm(p)
except (OSError, UnicodeDecodeError): continue
if end >= 0 and T.persona_of(fm) == sys.argv[3]: n += 1
print(n)' "$CODE" "$TICKET_ROOT" "$1"
}
# 엔진 수정 서른네 번째 승인 §판정 2 - `tickets.py`의 `fresh_block`을 그대로 불러 쓴다(NFC ·
# 상태 접미사 판정을 두 곳에 두지 않는다, persona_wip과 같은 이유). exit 0 = 신선한 블록이다.
fresh_block_sh() {
python3 -c 'import sys
sys.path.insert(0, sys.argv[1])
import tickets as T
sys.exit(0 if T.fresh_block(sys.argv[2]) else 1)' "$CODE" "$1"
}
# --- §1-5 갈래 C / §1-3 §5 — 피해자 판정은 여기 한 자리뿐이다(엔진 계약 `3acc1a56`) ---
# `maybe_preempt`(자동, 소유자 필터 있음)와 `preempt`(사람이 부르는 명령, 소유자 필터 없음)가
# 둘 다 이 함수만 부른다 - 정렬을 두 곳에 안 둔다(§1-5 §갈리는 것 하나). 성공하면 VPATH·VHASH·
# VEFF·VASSIGN·VPID·VOWNER를 전역에 채우고 0을 반환한다. $1=1이면 "내 티켓만"(소유자 필터).
pick_victim() {
local WROWS
VPATH=""; VHASH=""; VEFF=""; VASSIGN=""; VPID=""; VOWNER=""
WROWS=$(python3 "$PY" wips "$TICKET_ROOT" 2>/dev/null) || return 1
[ -z "$WROWS" ] && return 1
# 유효 우선순위가 최저인 것, 동률이면 assigned_at이 가장 늦은 것(가장 나중에 시작한 것).
# 정렬 키 하나로 둘 다 접는다.
IFS='|' read -r VPATH VHASH VEFF VASSIGN VPID VOWNER <<< \
"$(printf '%s\n' "$WROWS" | sort -t'|' -k3,3n -k4,4r | head -1)"
[ -z "$VPATH" ] && return 1
[ "$VEFF" -lt 5 ] || return 1 # 5끼리는 안 끊는다
if [ "$1" = 1 ]; then
case "$VOWNER" in *" / $TICKET_NAME-"*) ;; *) return 1 ;; esac # 내 티켓이 아니면 손대지 않는다
fi
[ -f "$VPATH" ] || return 1
case "$VPID" in ''|*[!0-9]*) return 1 ;; esac
return 0
}
# --- §1-3 §5 — 선점: 바쁜 워커의 즉시 exit 경로가 유일하게 도는 자리다 ---
# 중앙 스케줄러가 없다. 전원이 바쁘면 아무도 큐를 안 보므로, 워커 락에 막혀 나가는 이 순간이
# 유일하게 남은 "바쁜 워커가 도는 자리"다(새 프로세스·새 데몬 0개). 새 잠금도 0개다 - claim처럼
# 자원을 다투는 게 아니라 파일을 읽기만 하고, 각 워커는 **자기 자신이 물고 있는 티켓일 때만**
# 죽인다. 전원이 같은 파일을 보고 같은 계산을 해도 "내 티켓이 전역 최저다"는 결정적으로 한
# 워커에서만 참이라 정확히 하나만 죽는다 - 조율이 필요 없다.
maybe_preempt() {
local PUSH5 q_path q_hash q_kind q_persona q_prio q_base q_eff q_squadp PLIM PWIP
local WT COMMIT
pick_victim 1 || return 0
# 조건 1 — 유효 5 후보가 있고 자기 게이트(deps·페르소나 상한)를 다 지난다. `select`가 이미
# deps 미충족·할당됨을 걸렀으니 여기서는 페르소나 상한만 본다(선정 루프와 같은 판정).
PUSH5=""
while IFS='|' read -r q_path q_hash q_kind q_persona q_prio q_base q_eff q_squadp; do
[ "$q_eff" = "5" ] || continue
[ -n "$q_squadp" ] && q_persona="$q_squadp" # 스쿼드 리더가 게이트를 대신 받는다(§5-5)
if [ -n "$q_persona" ]; then
PLIM=$(persona_limit "$q_persona")
if [ -n "$PLIM" ]; then
PWIP=$(persona_wip "$q_persona")
[ "$PWIP" -ge "$PLIM" ] && continue
fi
fi
PUSH5="$q_hash"; break
done < <(python3 "$PY" select "$TICKET_ROOT")
[ -z "$PUSH5" ] && return 0
# 끊긴 티켓 본문에 무슨 일이 있었는지 남긴다(§1-3 §5 §표) - git은 있으면 쓰고 없으면 그
# 항목만 빈다(2>/dev/null, 의존성 0을 안 깬다).
WT="$TICKET_ROOT/worktrees/$TICKET_NAME"
COMMIT=$(git -C "$WT" rev-parse --short HEAD 2>/dev/null)
{
printf '\n## 선점\n\n'
printf '| | |\n|---|---|\n'
printf '| 시각 | %s |\n' "$(date '+%F %T')"
printf '| 밀어낸 티켓 | %s |\n' "$PUSH5"
printf '| 워커 · 브랜치 | %s · wt/%s |\n' "$TICKET_NAME" "$TICKET_NAME"
printf '| 워크트리 | %s |\n' "$WT"
printf '| 커밋 | %s |\n' "$COMMIT"
printf '| 회수 | `.dira/protocols/재디스패치-복구.md`를 읽고 그대로 하세요. |\n'
} >> "$VPATH"
log "PREEMPT $VHASH -> $PUSH5 pid=$VPID"
kill -TERM "$VPID" 2>/dev/null
}
# §1-5 갈래 B §검증 (2) — 지목한 해시가 `select` 후보에 없을 때 로그에 남길 사유 한 줄. 새
# 판정을 만들지 않는다 - `scan()`이 이미 셈한 assigned·polling·unmet 플래그와 `find_any`(상태
# 무관 검색)를 그대로 읽는다(developer PROFILE §엔진 의미 복제 - 패리티는 부르는 것으로 지킨다).
target_reason() {
python3 -c 'import sys, os
sys.path.insert(0, sys.argv[1])
import tickets as T
troot, want = sys.argv[2], T.nfc(sys.argv[3])
for r in T.scan(troot):
if T.nfc(r["hash"]) != want:
continue
if r["assigned"]:
print("이미 할당됨(session=" + r["session_id"] + ")"); sys.exit()
if r["polling"]:
print("폴링 대기 중"); sys.exit()
if r["unmet"]:
print("deps 미충족: " + ",".join(r["unmet"])); sys.exit()
print("대기 중인데 못 집었다 - 경합"); sys.exit()
p = T.find_any(troot, want)
print(("파일: " + os.path.basename(p)) if p else "티켓을 못 찾음")
' "$CODE" "$TICKET_ROOT" "$1"
}
# --- §폴링 대기 결정 4 §실행 상한 30초 — 백그라운드 + kill 관용구(`timeout` 바이너리는
# macOS 기본 설치에 없다). $1=스크립트 경로, $2=출력 파일(호출자가 마지막 200줄만 남긴다).
# 반환값은 스크립트의 진짜 종료 코드, 상한을 넘겨 죽인 경우는 124(결정 4 "그 밖" 취급).
run_capped() {
: > "$2"
"$1" > "$2" 2>&1 &
local pid=$! waited=0
while [ "$waited" -lt 30 ] && kill -0 "$pid" 2>/dev/null; do
sleep 1; waited=$((waited + 1))
done
if kill -0 "$pid" 2>/dev/null; then
kill -TERM "$pid" 2>/dev/null; sleep 2; kill -KILL "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
return 124
fi
wait "$pid"
}
CMD="${1:-tick}"
# §1-5 갈래 B(지목 디스패치, 엔진 계약 `3acc1a56`) — `tick <해시>`일 때만 채운다. 인자 없는
# `tick` · `dryrun`은 그대로 빈 값이라 아래 선정 루프가 종전과 한 글자도 안 다르게 돈다.
TARGETHASH=""
[ "$CMD" = "tick" ] && [ -n "${2:-}" ] && TARGETHASH="$2"
case "$CMD" in
list)
python3 "$PY" list "$TICKET_ROOT"; exit $? ;;
unassign)
USAGE="사용법: $(basename "$0") unassign <티켓해시> [--force]"
H="${2:-}"; [ -z "$H" ] && { echo "$USAGE"; exit 2; }
# --force = 산 세션을 죽여서 푼다. 그 밖의 인자는 사용법(exit 2).
FORCE=""; shift 2
for a in ${@+"$@"}; do
[ "$a" = "--force" ] && { FORCE=1; continue; }
echo "$USAGE" >&2; exit 2
done
P=$(python3 "$PY" find "$TICKET_ROOT" "$H") || exit 1
# 산 세션을 두고 할당만 풀면 티켓이 다시 열려 두 워커가 같은 티켓을 문다
# (2026-08-01 실사고: b7bacafb·a461c2f7이 각각 세션 2개를 달았다). 죽은 뒤에 풀어라.
# reap과 같은 근거를 쓴다: pid 생존, 그리고 ps의 --session-id.
UPID=$(sed -n 's/^pid:[[:space:]]*//p' "$P" | head -1 | tr -d "\"' ")
USID=$(sed -n 's/^session_id:[[:space:]]*//p' "$P" | head -1 | tr -d "\"' ")
ALIVE=""
case "$UPID" in ''|*[!0-9]*) ;; *) ps -p "$UPID" -o pid= >/dev/null 2>&1 && ALIVE="pid=$UPID" ;; esac
# ps를 변수에 먼저 담고 셸이 매칭한다. `ps -eo command= | grep -- "--session-id $USID"`는
# **grep 자신을 문다** — grep의 명령줄에 그 문자열이 그대로 들어 있고 ps가 그걸 본다.
# 그러면 세션이 죽어도 항상 살아 있다고 판정해 unassign이 영영 거부된다(CI 실측
# 2026-08-01: `거부: ... (session=dead-sid)`). 파이프를 없애면 매칭 시점에 grep이 없다.
# §개정(421f440d) 결정 1 - pid= 필드를 같이 받아 매칭된 행의 pid를 $UPID에 되찾는다.
# 헤어스트링은 이미 담긴 $PSOUT을 읽을 뿐 새 프로세스를 안 띄운다 - 함정은 그대로 닫혀 있다.
if [ -z "$ALIVE" ] && [ -n "$USID" ]; then
PSOUT=$(ps -eo pid=,command= 2>/dev/null)
while IFS= read -r LINE; do
case "$LINE" in *"--session-id $USID"*)
set -- $LINE
case "$1" in ''|*[!0-9]*) ;; *) UPID="$1"; ALIVE="session=$USID" ;; esac
break ;;
esac
done <<< "$PSOUT"
fi
# 주인 세션이 자기 손으로 푸는 것은 통과다. 왕복 절차(PM PROFILE §요구사항 왕복 3단계)가
# 부르는 자리가 바로 여기이고, 거기서 산 pid는 **부르는 세션 자신**이다(티켓 828dc247).
# 조상 사슬로 본다 - 남의 산 세션을 푸는 것은 사슬에 없으므로 그대로 막힌다.
if [ -n "$ALIVE" ] && [ -n "$UPID" ]; then
ANC=$$
while [ "$ANC" != "0" ] && [ "$ANC" != "1" ] && [ -n "$ANC" ]; do
[ "$ANC" = "$UPID" ] && { ALIVE=""; break; }
ANC=$(ps -p "$ANC" -o ppid= 2>/dev/null | tr -d ' ')
done
fi
if [ -n "$ALIVE" ]; then
# §개정(421f440d) 결정 2 - session= 갈래도 이제 $UPID를 들고 있어 --force로 풀 수 있다.
# 종전엔 여기서 거부하고 끝(exit 1)이었지만, 그 판정이 죽일 프로세스를 이미 찾아 놓고도
# pid 열을 버렸을 뿐이었다 - 아래 공통 경로로 그대로 흘려보낸다.
if [ -z "$FORCE" ]; then
echo "거부: $H 의 세션이 아직 살아 있다($ALIVE). 먼저 끝내거나 죽인 뒤 다시 시도하세요." >&2
log "UNASSIGN-DENY $H $ALIVE 생존"
# 3 = 산 세션이라 거부했다(--force면 풀 수 있다). 화면이 이 코드로 확인을 띄운다 -
# 문구를 정규식으로 읽으면 문구를 고치는 순간 확인이 조용히 사라진다.
exit 3
fi
# 죽이기 **전에** 답변 대기로 잠근다(§2-5 §개정). 열림에는 잠금이 없어서 같은 워커가
# 1~4초 뒤 다시 문다 - 중단이 중단이 아니게 된다. 여기가 세 가지가 동시에 성립하는
# 유일한 구간이다: 창이 0이고(deps·awaiting은 clear+release를 지나서도 남는다), 티켓 파일을
# 쓰는 것이 나 혼자고(부모는 wait에 서 있다), 인용할 transcript가 아직 안 지워졌다.
if ASK=$(python3 "$PY" askhuman "$P"); then log "$ASK"; else log "ASK-FAIL $H 답변 대기 잠금 실패"; fi
# 강제: pid를 죽이면 그 세션의 부모 tick.sh가 wait에서 실패 판정으로 떨어져 이미
# clear + release를 한다. 새 상태 전이가 아니라 그 경로를 밟게 하고 결과를 기다리는 것이다.
kill -TERM "$UPID" 2>/dev/null
log "UNASSIGN-FORCE $H $ALIVE 강제 중단"
N=0
while [ -e "$P" ] && [ "$N" -lt 15 ]; do sleep 1; N=$((N+1)); done
if [ -e "$P" ]; then
# 손 클레임 티켓(tickets.py handclaim)에는 풀어 줄 부모가 없다 - 여기서 우리가 푼다.
# 단, pid가 아직 살아 있으면 풀지 않는다. 산 세션을 두고 할당만 푸는 것이 바로
# 2026-08-01 실사고(b7bacafb·a461c2f7)의 원인이다.
# ps는 좀비(부모가 아직 wait 안 한 죽은 자식)도 "있다"고 답한다. 그건 도는 세션이 아니다.
case "$(ps -p "$UPID" -o state= 2>/dev/null | tr -d ' ')" in
''|Z*) ;;
*) echo "실패: $H 의 pid $UPID 가 ${N}s 뒤에도 살아 있다. 할당은 그대로 둔다. 답변 대기 잠금은 걸려 있다 — 그 세션이 끝나면 백로그가 아니라 답변 대기로 선다." >&2
log "UNASSIGN-FORCE $H pid=$UPID 안 죽음 - 해제 보류"
exit 1 ;;
esac
python3 "$PY" clear "$P" || exit 1
RP=$(python3 "$PY" release "$P") || exit 1
[ "$RP" != "$P" ] && echo "백로그 복귀: $(basename "$RP")"
fi
log "UNASSIGN $H 강제(pid=$UPID ${N}s)"
echo "강제 할당 해제: $H — 답변 대기로 잠갔다(답을 쓸 때까지 아무 워커도 안 가져간다)"
exit 0
fi
# 결정 9 -- 세션이 `## 블록`을 남기고 스스로 unassign한 경로. clear+release **앞에서** 조건부로
# 잠근다(신선한 블록 + 미충족 dep 0). 열리자마자 잠긴 채로 서야 하므로 순서가 계약이다.
if ASK=$(python3 "$PY" askhuman "$P" --if-blocked); then [ -n "$ASK" ] && log "$ASK"; fi
python3 "$PY" clear "$P" || exit 1
RP=$(python3 "$PY" release "$P") || exit 1
[ "$RP" != "$P" ] && echo "백로그 복귀: $(basename "$RP")"
log "UNASSIGN $H"; echo "할당 해제: $H"
exit 0 ;;
reap)
python3 "$PY" reap "$TICKET_ROOT" "$LOCAL" | while IFS= read -r line; do
[ -n "$line" ] && { log "$line"; echo "$line"; }
done
exit 0 ;;
poll)
# §폴링 대기 결정 8 - 워커 스크립트 서브커맨드 하나가 대기에 드는 문이다. 검사 다섯을
# 하나라도 걸리면 frontmatter를 한 글자도 안 고치고 0이 아닌 코드로 거절한다.
USAGE="사용법: $(basename "$0") poll <티켓해시> <스크립트 파일명> <상한 ISO 8601>"
H="${2:-}"; SCRIPT="${3:-}"; UNTIL="${4:-}"
if [ -z "$H" ] || [ -z "$SCRIPT" ] || [ -z "$UNTIL" ]; then echo "$USAGE" >&2; exit 2; fi
P=$(python3 "$PY" find "$TICKET_ROOT" "$H") || exit 1
# 검사 1 - 값에 '/'가 없다(`../`도 걸린다) + 파일이 실제로 있고 실행 권한이 있다.
case "$SCRIPT" in
*/*) echo "거부: 스크립트 파일명에 '/'를 쓸 수 없다: $SCRIPT" >&2; exit 1 ;;
esac
SCRIPTPATH="$TICKET_ROOT/polls/$SCRIPT"
if [ ! -f "$SCRIPTPATH" ] || [ ! -x "$SCRIPTPATH" ]; then
echo "거부: 스크립트가 없거나 실행 권한이 없다: $SCRIPTPATH" >&2; exit 1
fi
# 검사 2 - 머리에 `# dira-poll-interval:` 줄이 있으면 그 값이 양의 정수다.
IVLINE=$(grep -m1 -E '^#[[:space:]]*dira-poll-interval:' "$SCRIPTPATH" 2>/dev/null)
if [ -n "$IVLINE" ]; then
IVVAL=$(printf '%s\n' "$IVLINE" | sed -n 's/^#[[:space:]]*dira-poll-interval:[[:space:]]*//p')
case "$IVVAL" in
''|*[!0-9]*|0) echo "거부: dira-poll-interval이 양의 정수가 아니다: $IVVAL" >&2; exit 1 ;;
esac
fi
# 검사 3 - <상한>이 파싱되고 지금보다 뒤다.
UNTIL_EPOCH=$(python3 -c 'import sys, datetime
try:
dt = datetime.datetime.fromisoformat(sys.argv[1])
if dt.tzinfo is None: dt = dt.astimezone()
print(int(dt.timestamp()))
except Exception:
pass' "$UNTIL" 2>/dev/null)
case "$UNTIL_EPOCH" in
''|*[!0-9]*) echo "거부: 상한 시각을 못 읽는다: $UNTIL" >&2; exit 1 ;;
esac
[ "$UNTIL_EPOCH" -gt "$(date +%s)" ] || { echo "거부: 상한이 과거다: $UNTIL" >&2; exit 1; }
# 검사 4 - 그 티켓의 주인이 자기 세션이다. unassign(위 §조상 사슬)과 같은 관용구다 -
# 이 poll 서브커맨드를 부르는 것은 그 티켓을 물고 있는 세션 자신이어야 한다.
UPID=$(sed -n 's/^pid:[[:space:]]*//p' "$P" | head -1 | tr -d "\"' ")
case "$UPID" in
''|*[!0-9]*) echo "거부: 이 티켓에 pid가 없다 - 세션 소유를 확인할 수 없다" >&2; exit 1 ;;
esac
ANC=$$; OWNED=""
while [ "$ANC" != "0" ] && [ "$ANC" != "1" ] && [ -n "$ANC" ]; do
[ "$ANC" = "$UPID" ] && { OWNED=1; break; }
ANC=$(ps -p "$ANC" -o ppid= 2>/dev/null | tr -d ' ')
done
[ -n "$OWNED" ] || { echo "거부: 남의 티켓이다(pid=$UPID) - 자기 세션만 폴링에 넣을 수 있다" >&2; exit 1; }
# 검사 5 - 스크립트를 그 자리에서 한 번 돌려 본다. 0이면 이미 조건에 도달한 것이라
# 대기에 넣지 않고 그대로 둔다(tick 하나를 안 버린다). 그 밖(1이 아님)이면 거절한다.
TESTOUT="$LOCAL/run/poll-test-$$.log"
run_capped "$SCRIPTPATH" "$TESTOUT"; TESTRC=$?
rm -f "$TESTOUT"
if [ "$TESTRC" -eq 0 ]; then
echo "이미 조건에 도달했다 - 대기에 넣지 않는다: $H"
exit 0
elif [ "$TESTRC" -ne 1 ]; then
echo "거부: 스크립트가 오류를 냈다(rc=$TESTRC) - 대기에 넣지 않는다" >&2
exit 1
fi
# 다섯 검사를 다 지났다. unassign 결정 9 종료 경로와 같은 두 줄(clear+release)로 열고
# 그 위에 대기 키 둘을 얹는다.
python3 "$PY" clear "$P" || exit 1
RP=$(python3 "$PY" release "$P") || exit 1
python3 "$PY" pollstart "$RP" "$SCRIPT" "$UNTIL" || exit 1
log "POLL-START $H $SCRIPT until=$UNTIL"
echo "폴링 대기: $H -> $(basename "$RP") (스크립트 $SCRIPT, 상한 $UNTIL)"
exit 0 ;;
preempt)
# §1-5 갈래 C(사람이 부르는 선점, 엔진 계약 `3acc1a56`) - 순서 넷이 계약이다.
USAGE="사용법: $(basename "$0") preempt <티켓해시> [--dryrun]"
H="${2:-}"; [ -z "$H" ] && { echo "$USAGE" >&2; exit 2; }
DRYRUN=""; shift 2
for a in ${@+"$@"}; do
[ "$a" = "--dryrun" ] && { DRYRUN=1; continue; }
echo "$USAGE" >&2; exit 2
done
# 1. 피해자를 고른다(위 §피해자 판정 한 자리) - `maybe_preempt`와 달리 소유자 필터가 없다.
if ! pick_victim ""; then
if [ -z "$DRYRUN" ]; then
echo "피해자 없음 - 도는 티켓이 없거나 전부 유효 5" >&2
log "PREEMPT-NONE $H 요청 - 피해자 없음"
fi
exit 1
fi
VWORKER="${VOWNER#*/ }"; VWORKER="${VWORKER%-*}" # owner "<페르소나> / <워커>-<SID 8글자>"
if [ -n "$DRYRUN" ]; then
VTITLE=$(sed -n 's/^title:[[:space:]]*//p' "$VPATH" | head -1 | tr -d "\"'")
printf '%s - %s - %s\n' "$VHASH" "$VTITLE" "$VWORKER"
exit 0
fi
# 2. 피해자 본문에 `## 선점` 절을 붙인다(§1-3 §5 §표와 같은 표, 라벨은 `밀어낸 티켓`).
# 워커·워크트리는 **피해자를 물었던 워커**(VWORKER)다 - 부르는 워커($TICKET_NAME)가 아니다.
# 소유자 필터가 없어서(§1-5 §갈리는 것 하나) 피해자가 남의 워커에서 돌 수 있고, 회수
# 안내가 가리켜야 하는 것은 그 미커밋 변경이 실제로 있는 워크트리다.
WT="$TICKET_ROOT/worktrees/$VWORKER"
COMMIT=$(git -C "$WT" rev-parse --short HEAD 2>/dev/null)
{
printf '\n## 선점\n\n'
printf '| | |\n|---|---|\n'
printf '| 시각 | %s |\n' "$(date '+%F %T')"
printf '| 밀어낸 티켓 | %s |\n' "$H"
printf '| 워커 · 브랜치 | %s · wt/%s |\n' "$VWORKER" "$VWORKER"
printf '| 워크트리 | %s |\n' "$WT"
printf '| 커밋 | %s |\n' "$COMMIT"
printf '| 회수 | `.dira/protocols/재디스패치-복구.md`를 읽고 그대로 하세요. |\n'
} >> "$VPATH"
# 3. 그 세션을 끊는다. 피해자를 물었던 워커의 부모 tick.sh가 kill을 보고 clear+release를
# 밟는다(위 unassign --force와 같은 경로, `unassign --force`와 달리 답변 대기로 안 잠근다 -
# askhuman을 안 부른다) - VPATH가 사라지면 열림으로 돌아온 것이다.
log "PREEMPT $VHASH -> $H (사람 요청) pid=$VPID"
kill -TERM "$VPID" 2>/dev/null
N=0
while [ -e "$VPATH" ] && [ "$N" -lt 15 ]; do sleep 1; N=$((N+1)); done
# 4. 비워진 워커에 갈래 B의 지목 디스패치(`tick <해시>`)를 건다 - 워커 락이 풀릴 때까지
# 기다린다. 15·20·30초는 이 파일의 다른 생존 대기(unassign·MAXRUN 감시)와 같은 자릿수다.
VWLOCK="$LOCAL/run/$VWORKER-$(python3 -c \
'import hashlib,sys;print(hashlib.sha1(sys.argv[1].encode()).hexdigest()[:8])' "$WORKERS/$VWORKER").lock"
N=0
while [ -d "$VWLOCK" ] && [ "$N" -lt 30 ]; do sleep 1; N=$((N+1)); done
if [ -d "$VWLOCK" ]; then
log "PREEMPT-TIMEOUT $H 워커 $VWORKER 락이 30초 안에 안 풀렸다 - 다음 cron이 받는다"
exit 0
fi
VWSCRIPT="$WORKERS/$VWORKER.sh"
if [ ! -x "$VWSCRIPT" ]; then
log "PREEMPT-NOWORKER $H 워커 스크립트 없음: $VWSCRIPT"
exit 0
fi
# 던지고 잊는다(§4-5·§1-5 갈래 B와 같은 계약 - 결과를 안 본다) - 물면 세션이 5~25분을
# 붙드는데, 이 명령은 GUI의 `execFile`(60초 상한, engine.ts)로 불린다. fd를 다 떼야
# execFile이 자식의 표준출력이 안 닫혀서 60초를 그대로 기다리는 사고가 안 난다.
nohup "$VWSCRIPT" tick "$H" </dev/null >/dev/null 2>&1 &
disown 2>/dev/null || true
exit 0 ;;
dryrun|tick) ;;
*) echo "알 수 없는 명령: $CMD"; exit 2 ;;
esac
# --- 스테일 수거: 세션이 죽었는데 진행중으로 남은 티켓을 백로그로 되돌린다 ---
# 없으면 사람에게 질문하고 rc=0으로 종료한 세션의 티켓이 영구 유실된다(2026-07-28 스트림 실사고 3건).
# §폴링 대기 결정 5와 같은 이유로 아래 워커 락(SKIP)보다 앞이다 - reap은 이 워커의 바쁨과
# 무관한 큐 전체의 스테일 수거라, 원래도 워커 락 성패에 기댈 이유가 없었다.
if [ "$CMD" = "tick" ]; then
python3 "$PY" reap "$TICKET_ROOT" "$LOCAL" 2>/dev/null | while IFS= read -r line; do
[ -n "$line" ] && log "$line"
done
fi
# --- 로그 보존: workers/logs·workers/health 아래 오래된 파일을 지운다 ---
# 실측(P409-2, 2026-09-15): 3,014개 3.8GB, 세션 로그 하나가 119MB까지 간다 - 그 위에서 큐
# 탐색이 느려진다. 하루 한 번만 훑는다 - 매 tick(1분 주기)마다 3,000개를 훑으면 이 손이 스스로
# 탐색 지연을 보탠다. 실패해도 디스패치를 막지 않는다(reap과 같은 이유).
if [ "$CMD" = "tick" ]; then
LOGKEEPDAYS="${TICKET_LOG_KEEP_DAYS:-14}"
LOGRETMARK="$LOCAL/run/log-retention-date"
LOGRETTODAY="$(date +%F)"
if [ "$(cat "$LOGRETMARK" 2>/dev/null)" != "$LOGRETTODAY" ]; then
if [ "$LOGKEEPDAYS" != "0" ]; then
for d in "$WORKERS/logs" "$WORKERS/health"; do
[ -d "$d" ] && find "$d" -type f -mtime "+$LOGKEEPDAYS" \
! -name 'runner.log' ! -name 'cron.log' -delete 2>/dev/null
done
fi
mkdir -p "$LOCAL/run" 2>/dev/null
printf %s "$LOGRETTODAY" > "$LOGRETMARK" 2>/dev/null
fi
true
fi
# --- §폴링 대기 결정 3·5·6 — 폴링 단계: reap 다음, `select` 앞이고, 이 워커가 바빠서 아래
# 워커 락에 걸려 SKIP하는 자리보다도 앞이다 - 대기는 워커 슬롯과 무관한 큐 전체의 상태라서다.
# 새 cron 줄 0개, 새 프로세스 0개 - 이미 깨어난 이 tick에 얹는다. 대기 티켓이 0건이면 파일
# 목록 한 번 훑는 것으로 끝난다.
POLL_FLOOR="${TICKET_POLL_FLOOR:-30}"
poll_step() {
local ROWS p_path p_hash LOCKP OWNER PLAN rest scriptpath outf rc
ROWS=$(python3 "$PY" pollrows "$TICKET_ROOT" 2>/dev/null)
[ -z "$ROWS" ] && return 0
while IFS='|' read -r p_path p_hash; do
[ -z "$p_path" ] && continue
LOCKP="$LOCAL/run/poll-$p_hash.lock"
if ! mkdir "$LOCKP" 2>/dev/null; then
OWNER=$(cat "$LOCKP/pid" 2>/dev/null)
# 못 얻으면 기다리지 않고 이 티켓을 넘긴다(결정 6) - 다음 tick이 30~60초 뒤에 온다.
if [ -n "$OWNER" ] && kill -0 "$OWNER" 2>/dev/null; then continue; fi
rm -rf "$LOCKP"
mkdir "$LOCKP" 2>/dev/null || continue
fi
printf %s "$$" > "$LOCKP/pid"
# 주기 판정(polled_at + 머리의 초)은 잠금을 얻은 뒤에 다시 읽는다(결정 6) - pollplan이
# 매번 파일을 새로 읽으므로 여기서 부르는 것 자체가 그 재읽기다.
PLAN=$(python3 "$PY" pollplan "$p_path" "$POLL_FLOOR" 2>/dev/null)
case "$PLAN" in
WAIT) : ;;
"RUN "*)
rest="${PLAN#RUN }"
scriptpath="${rest%%|*}"
[ "$rest" != "$scriptpath" ] && log "${rest#*|}"
outf="$TICKET_ROOT/polls/$p_hash.log"
run_capped "$scriptpath" "$outf.tmp"; rc=$?
tail -n 200 "$outf.tmp" > "$outf" 2>/dev/null; rm -f "$outf.tmp"
python3 "$PY" pollresult "$p_path" "$rc" "$outf" | while IFS= read -r line; do
[ -n "$line" ] && log "$line"
done
;;
"ASK "*) log "$PLAN" ;;
"POLL "*"상한 초과"*) log "$PLAN" ;;
*) log "WARN POLL 알 수 없는 pollplan 출력: $p_hash: $PLAN" ;;
esac
rm -rf "$LOCKP"
done <<EOF
$ROWS
EOF
}
[ "$CMD" = "tick" ] && poll_step
# 참견 입구(FIFO)와 최초 프롬프트 파일. 스트리밍 입력 엔진일 때만 실제 경로가 들어간다.
INBOX=""; PRIMEF=""
# 선정·claim 임계구역 잠금(§5-4). 트랩보다 먼저 선언한다 - 잠금을 잡기 전에 종료하는 경로가
# 여럿이고(선정 잠금 획득 실패 등) set -u에서 미정의 변수를 트랩이 읽으면 그 자리에서 죽는다.
SLOCK=""
# --- 워커 락: 한 워커는 한 번에 티켓 1건 ---
# 워커는 동기 프로세스다. 앞 실행이 아직 세션을 물고 있으면 이번 tick은 그냥 넘긴다
# (cron은 1분마다 깨우지만 티켓 하나는 보통 5~25분 걸린다). 동시성은 워커 개수로 조절한다.
# 락은 머신 로컬에 둔다 - 안에 든 pid는 이 머신에서만 뜻이 있다.
# 서로 다른 티켓을 든 두 워커가 같은 파일·공유 DB를 만지는 건 여전히 사람이 조절할 몫이다
# (관측 사고: 동시 4세션이 같은 dev DB에서 컬럼 드롭, 2026-07-28 스트림).
if [ "$CMD" = "tick" ]; then
LOCK="$LOCAL/run/$TICKET_NAME-$(python3 -c \
'import hashlib,sys;print(hashlib.sha1(sys.argv[1].encode()).hexdigest()[:8])' "$WORKERS/$TICKET_NAME").lock"
if ! mkdir "$LOCK" 2>/dev/null; then
OWNER=$(cat "$LOCK/pid" 2>/dev/null)
if [ -n "$OWNER" ] && kill -0 "$OWNER" 2>/dev/null; then
maybe_preempt
log "SKIP 이 워커가 아직 티켓을 물고 있다 pid=$OWNER"
exit 0
fi
log "WARN 스테일 락 회수 pid=${OWNER:-?}"
rm -rf "$LOCK"
mkdir "$LOCK" 2>/dev/null || exit 0
fi
printf %s "$$" > "$LOCK/pid"
# 빈 값이면 rm -f ""가 되고 아무 일도 안 한다 -- 어떻게 죽든 FIFO가 남지 않게 여기 한 번만 건다.
trap 'rm -rf "$LOCK" "$SLOCK"; rm -f "$INBOX" "$PRIMEF" "${PRIMEF:+$PRIMEF.fed}" "${INBOX:+$INBOX.human}"' EXIT
fi
# 엔진 쿨다운 게이트·claude 인증 게이트는 여기 없다 - 페르소나가 엔진을 정하므로 어느
# 엔진인지가 어느 후보를 고르느냐에 달렸다(§제약 1 §결정 기록 §열한 번째). 둘 다 아래 선정
# 루프 안, 후보의 persona가 확정된 뒤로 옮겼다 - reap만 이 앞에 그대로 둔다(스테일 수거는
# 엔진 가용성과 무관하다).
# --- 티켓 선정: 상태접미사 없고 session_id 비어있는 것, 유효 우선순위 높은 순(§1-3) ---
# select가 이미 `(-effective, birth, path)`로 정렬해 준다(tickets.py scan()) - 여기는
# 그 순서를 그대로 훑을 뿐이다.
CANDS=$(python3 "$PY" select "$TICKET_ROOT") || { log "ERROR select 실패"; exit 1; }
# §1-5 갈래 B — 지목 디스패치: 후보 목록을 그 해시 하나로 좁힌다(무수정 tick과 다른 점 전부).
# 후보가 아니면(이미 할당됨·폴링 대기·deps 미충족·없는 티켓) 사유 한 줄만 로그하고 나간다.
# 게이트(페르소나 상한·유효 1·엔진 쿨다운)에 걸리는 경우는 후보 목록에는 있으므로 아래 선정
# 루프가 후보 하나를 그대로 훑다가 종전 SKIP 로그를 남기고 TPATH가 빈 채로 끝난다(§검증 (2)).
if [ -n "$TARGETHASH" ]; then
MCANDS=$(printf '%s\n' "$CANDS" | awk -F'|' -v h="$TARGETHASH" '$2==h')
if [ -z "$MCANDS" ]; then
log "SKIP 지목 $TARGETHASH — $(target_reason "$TARGETHASH")"
exit 0
fi
CANDS="$MCANDS"
fi
[ -z "$CANDS" ] && exit 0
# --- 선정·claim 임계구역: 페르소나 상한·1 게이트는 여기 없으면 게이트가 아니다 ---
# 상한(§5-4)은 `personas/<이름>/limit` 한 줄이고 **세는 것과 잡는 것이 한 임계구역**이어야 한다.
# 지금 구조는 select가 후보를 전부 주고 각 워커가 위에서부터 claim하니 카운트가 claim보다 앞이다.
# 워커 8개는 cron 같은 분에 뜨고 같은 1초에 최대 5개가 DISPATCH된다(실측) - 상한 1의 티켓이
# 다섯 장 열려 있으면 다섯 워커가 모두 `0 < 1`을 읽고 각자 다른 티켓을 잡는다. 로그에는 아무
# 이상이 안 남아서 아무도 못 잡는다.
# 1 게이트(§1-3)도 같은 이유다 - "유효 1은 .wip 0건일 때만 후보"를 세고 나서 잡으면 게이트가
# 아니다. 그래서 같은 그릇(같은 SLOCK)을 쓴다 - 새 잠금은 0개다.
# 그릇은 위 워커 락과 같은 관용구다(mkdir + pid + kill -0 스테일 회수). 다른 점 하나 - 워커 락은
# 워커 이름별이고 이것은 **머신에 하나**다. reap은 이 앞에 그대로 둔다.
# **못 얻으면 기다리지 않고 종료한다**: 다음 cron이 60초 뒤에 오고, 죽은 워커가 물고 있어도
# 큐가 서지 않는다(스테일 회수가 그 위에 있다). 대가는 §5-4가 적은 `마지막 워커가 1초 늦게
# 뜬다`가 아니다 - 줄을 서지 않고 나가므로 **같은 순간에 깬 워커 중 한 명만 뜬다**
# (실측 3회: 워커 5개 동시 · 안 걸리는 상한 하나 -> 디스패치 1건. 상한 파일이 없으면 5건).
# 즉 상한을 한 번 쓰면 그 큐의 디스패치가 분당 1건으로 눌린다. 세션이 5~25분이라 정상 상태는
# 견디지만 냉시동이 워커 수만큼 느려진다 - 뒤집으려면 짧은 재시도가 필요하고 그건 스펙의 일이다.
#
# **상한 파일도 유효 1 후보도 없으면 잠금을 안 잡는다.** `상한 없음`이 기본값이고(스캐폴딩이
# 이 파일을 안 만든다) 그 판은 §5-4 표대로 **종전 그대로**여야 한다 - 지킬 게이트가 0개인데
# 직렬화하면 같은 분에 깬 워커 여덟 중 하나만 뜬다. 파일이 하나라도 생기거나 유효 1이 뜨면
# 그때부터 전원이 줄을 선다.
LIMITED=""
for lf in "${TICKET_PERSONAS:-$TICKET_ROOT/personas}"/*/limit; do
[ -f "$lf" ] && { LIMITED=1; break; }
done
HASPRIO1=""
# select 출력은 이제 8필드다(끝에 squad_persona가 붙는다, §5-5) - `|1$`는 그 필드가 비어 있을
# 때만 우연히 맞는다. 7번째 필드(effective) 위치로 고정해서 본다.
grep -qE '^([^|]*\|){6}1\|' <<< "$CANDS" && HASPRIO1=1
if [ "$CMD" = "tick" ] && { [ -n "$LIMITED" ] || [ -n "$HASPRIO1" ]; }; then
acquire_slock || exit 0
fi
# 전체 큐의 진행중 수(페르소나 무관) - 1 게이트(§1-3)가 "모든 워커가 idle"을 판정하는 값.
# 워커 락을 세지 않는 이유는 위 persona_wip과 같다(락에서 프로젝트를 역추적할 수 없다).
total_wip() {
python3 -c 'import sys
sys.path.insert(0, sys.argv[1])
import tickets as T
print(len(T.in_progress(sys.argv[2])))' "$CODE" "$TICKET_ROOT"
}
# §1-4 §로그: DISPATCH 줄의 prio= 괄호 출처. raw(원값) != baseline(기준값)이면 duedate가
# 기준값을 덮었다는 뜻이라 `마감` - baseline != effective(유효)면 §1-3의 상속이 그 위에
# 얹혔다는 뜻이라 `상속 <baseline>`(보여주는 값은 상속이 얹히기 전 값, 종전 표기와 같은 자리).
# 둘 다면 이어붙인다. 새 게이트가 아니다 - 이미 있는 세 수를 비교만 한다.
prio_log() {
local raw="$1" base="$2" eff="$3"
local out="prio=$eff"
if [ "$raw" != "$base" ] && [ "$base" != "$eff" ]; then
out="prio=$eff(마감·상속 $base)"
elif [ "$raw" != "$base" ]; then
out="prio=$eff(마감)"
elif [ "$base" != "$eff" ]; then
out="prio=$eff(상속 $base)"
fi
printf '%s' "$out"
}
# §4-12 §개정 2026-08-11 — 술어 둘. unassign(위 249-270)의 관용구를 그대로 쓴다(새 도구 0).
# 조상 사슬은 안 가져온다 - 부모는 자기 $SID를 알아서 「내 것」을 한 번에 가른다.
sid_of() { sed -n 's/^session_id:[[:space:]]*//p' "$1" | head -1 | tr -d "\"' "; }
owns() { [ -f "$1" ] && [ "$(sid_of "$1")" = "$SID" ]; } # ③이 쓴다
live_other() { # 파일이 있고 · 내 것이 아니고 · 그 주인이 살아 있다
local OSID OPID PSOUT
[ -f "$1" ] || return 1
OSID=$(sid_of "$1"); [ "$OSID" = "$SID" ] && return 1
OPID=$(sed -n 's/^pid:[[:space:]]*//p' "$1" | head -1 | tr -d "\"' ")
case "$OPID" in ''|*[!0-9]*) ;; *) ps -p "$OPID" -o pid= >/dev/null 2>&1 && return 0 ;; esac
[ -n "$OSID" ] || return 1
PSOUT=$(ps -eo command= 2>/dev/null) # 파이프 금지 — grep이 자기 명령줄을 문다(tick.sh:253)
case "$PSOUT" in *"--session-id $OSID"*) return 0 ;; esac
return 1
}
# 조용한 실패 회수 자리 넷(§4-10 §개정) 공용 헬퍼 - `clear`+`release` 두 줄을 `tickets.py`의
# `reapclear`(엔진 `reap_release`) 한 번으로 묶는다. release를 clear보다 먼저 돌려 reclaim과
# 순서를 맞추고(5f0498c9), 실패하면 REAP-FAIL 한 줄을 표준출력으로 돌려준다(빈 값 = 성공).
# 호출자가 그 줄을 그대로 log에 남긴다. 2번째 인자(사유)는 §4-13의 메인 FAIL 경로만 준다
# (P362-2) - 나머지 셋(assign 실패·cwd 없음·이어받기 기동 실패)은 생략해 종전대로 attempts를
# 안 건드린다.
reap_silent() { python3 "$PY" reapclear "$1" "${2:-}"; }
# 엔진 수정 마흔 번째 승인 §판정 2 - 디스패치 직전에 워크트리 3항(주인이 .wip/미배정 ->
# 버린다)만 대신 집행한다. 4항(보존)-5항(후속 티켓)은 판단이 들어가 세션의 몫으로 남긴다.
# 순서 계약: 이 함수는 DISPATCH 로그 직후, cd "$TICKET_CWD" 전에 돈다(판정 1의 dirty-<해시>
# 기록보다 먼저 -> AC13). 실패해도 디스패치를 막지 않는다(AC12) - NOTE만 남기고 넘어간다.
cleanup_stray_wip() {
local wt="$1" tdir="$TICKET_ROOT/tickets" line status path owner_hash owner_file hit_count f
git -C "$wt" status --porcelain 2>/dev/null | while IFS= read -r line; do
status="${line:0:2}"
path="${line:3}"
# 이름바꾸기(R old -> new)는 새 이름만 본다 - 옛 이름은 이미 트리에 없다
case "$path" in *' -> '*) path="${path##*' -> '}" ;; esac
[ -z "$path" ] && continue
hit_count=0; owner_file=""
for f in "$tdir"/*.md; do
[ -e "$f" ] || continue
if grep -qF -- "$path" "$f" 2>/dev/null; then
hit_count=$((hit_count + 1))
owner_file="$f"
fi
done
[ "$hit_count" -eq 1 ] || continue
case "$owner_file" in
*.done.md) continue ;; # 4항 - 보존, 세션의 몫
*.wip.md|*.md) ;; # 3항 - 버린다 (미배정 <해시>.md도 이 갈래)
*) continue ;;
esac
owner_hash="$(basename "$owner_file")"; owner_hash="${owner_hash%%.*}"
case "$status" in
'??') rm -f -- "$wt/$path" 2>/dev/null && log "NOTE cleanup_stray_wip 버림(미추적) path=$path owner=$owner_hash" ;;
*) git -C "$wt" restore --staged --worktree -- "$path" 2>/dev/null \
&& log "NOTE cleanup_stray_wip 버림 path=$path owner=$owner_hash" \
|| log "NOTE cleanup_stray_wip 실패 path=$path owner=$owner_hash" ;;
esac
done
return 0
}
SID=$(python3 -c 'import uuid;print(uuid.uuid4())')
TPATH=""; THASH=""; TKIND=""; TPERSONA=""; TPRIO=""; TBASE=""; TEFF=""; TSQUAD=""
OVER="" # 이 판에서 이미 상한이던 페르소나들. 후보가 여럿이어도 SKIP은 페르소나당 한 줄이다
ENGOVER="" # 이 판에서 이미 디스패치 불가이던 엔진들. 후보가 여럿이어도 SKIP은 엔진당 한 줄이다
while IFS='|' read -r c_path c_hash c_kind c_persona c_prio c_base c_eff c_squadp; do
[ -z "$c_path" ] && continue
# `squad:`가 풀렸으면 리더가 이 후보의 수행자다 - 아래 상한 게이트-엔진 선택-claim이
# 전부 c_persona를 보므로 여기서 한 번만 덮으면 나머지는 종전 코드 그대로다(§5-5 §개정).
[ -n "$c_squadp" ] && c_persona="$c_squadp"
# 1 게이트(§1-3) — 유효 1은 진행중 티켓이 0건일 때만 후보다. "모든 워커가 idle"의 큐 쪽
# 값이 그것이다. 걸려도 큐에서 안 빠진다 - 이번 tick의 후보가 아닐 뿐이고 다음 후보로
# 넘어간다(페르소나 상한의 continue와 같은 모양 - 다른 우선순위 티켓을 안 굶긴다).
if [ "$c_eff" = "1" ]; then
WIPN=$(total_wip)
if [ "$WIPN" -gt 0 ]; then
log "SKIP 우선순위 1 $c_hash — 진행중 ${WIPN}건"
continue
fi
fi
# 상한에 걸려 안 뜨는 것은 SKIP이다(새 로그 낱말 0). 다른 페르소나 후보는 계속 본다 -
# 한 페르소나가 상한이어도 나머지를 굶기지 않는 것이 이 요구의 절반이다.
if [ -n "$c_persona" ]; then
case " $OVER " in *" $c_persona "*) continue ;; esac
PLIM=$(persona_limit "$c_persona")
if [ -n "$PLIM" ]; then
PWIP=$(persona_wip "$c_persona")
if [ "$PWIP" -ge "$PLIM" ]; then
OVER="$OVER $c_persona"
log "SKIP 페르소나 상한 $c_persona $PWIP/$PLIM"
continue
fi
fi
fi
# --- 엔진 재구성: 확정된 후보의 persona:가 personas/<이름>/engine을 가지면 그 값으로,
# 없으면 종전 그대로(워커 대입 -> 기본값)다(§제약 1 §결정 기록 §열한 번째). 매 후보마다
# 기본값으로 되돌린 뒤 다시 얹는다 - 안 그러면 앞 후보의 override가 다음 후보로 샌다.
TICKET_ENGINE=("${BASE_ENGINE[@]}")
if [ -n "$c_persona" ]; then
PENGINE="${TICKET_PERSONAS:-$TICKET_ROOT/personas}/$c_persona/engine"
[ -r "$PENGINE" ] && . "$PENGINE"
fi
# §27 계약 3: 접미사에서 이름을 딴다 - dira -> claude(§24 계약 4 그대로), dira-<x> -> <x>.
# 판정은 여기 한 자리뿐이다 - 아래 스킬 주입 조건도 다시 basename을 부르지 않고 이 값을 읽는다.
ENGBN="$(basename "${TICKET_ENGINE[0]}")"
case "$ENGBN" in
dira) ENGSUF="claude" ;;
dira-*) ENGSUF="${ENGBN#dira-}" ;;
*) ENGSUF="" ;;
esac
# 엔진 수정 24번째 계약 5 + §27 계약 3 폴백: 고정 경로를 못 만들었거나(권한-볼륨-원본 없음)
# 그 경로가 실행 가능하지 않으면 종전대로 PATH의 이름으로 돌아간다 - 디스패치를 막지 않는다.
if [ -n "$ENGSUF" ] && [ ! -x "${TICKET_ENGINE[0]}" ]; then
TICKET_ENGINE[0]="$ENGSUF"
fi
ENGINE_NAME="${ENGSUF:-$ENGBN}"
CDOWN="$LOCAL/run/cooldown-$ENGINE_NAME${TICKET_SLOT:+-$TICKET_SLOT}"
# 어느 엔진인지가 후보에 달렸으므로 ENGINE_NAME·claude 인증·쿨다운도 후보 확정 뒤에 판정한다
# (dryrun은 미리보기라 건너뛴다 - 종전에도 이 게이트는 CMD=tick 전용이었다). 디스패치 불가면
# 페르소나 상한과 같은 자리에서 skip-and-continue다(같은 로그 낱말 SKIP) - 이 엔진을 못
# 쓴다고 다른 엔진 쓰는 후보까지 굶기지 않는다. 건너뛸 후보가 없으면 이번 tick은 그냥
# 아무것도 안 고르고 끝난다(TPATH가 빈 채로 루프가 끝난다).
if [ "$CMD" = "tick" ]; then
case " $ENGOVER " in *" $ENGINE_NAME "*) continue ;; esac
if ! engine_gate_ok; then
ENGOVER="$ENGOVER $ENGINE_NAME"
continue
fi
fi
if [ "$CMD" = "dryrun" ]; then
TPATH="$c_path"; THASH="$c_hash"; TKIND="$c_kind"; TPERSONA="$c_persona"; TPRIO="$c_prio"; TBASE="$c_base"; TEFF="$c_eff"; break
fi
# 잡기 = 원자적 rename. 이게 진짜 락 - 다른 세션·다른 tick도 이걸 보고 피한다.
if CPATH=$(python3 "$PY" claim "$c_path" 2>/dev/null); then
TPATH="$CPATH"; THASH="$c_hash"; TKIND="$c_kind"; TPERSONA="$c_persona"; TPRIO="$c_prio"; TBASE="$c_base"; TEFF="$c_eff"; TSQUAD="$c_squadp"
# 스쿼드 리더 이름을 SLOCK 반환 전에 persona:로 남긴다 - 여기서 늦추면 persona_wip()이
# 이 임계구역이 지키려는 상한(§27 setpersona 위치 개정)을 뒤늦게 세어 다음 워커가 통과한다.
[ -n "$TSQUAD" ] && python3 "$PY" setpersona "$TPATH" "$TSQUAD"
break
fi
done <<EOF
$CANDS
EOF