-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtickets.py
More file actions
executable file
·1817 lines (1585 loc) · 89 KB
/
Copy pathtickets.py
File metadata and controls
executable file
·1817 lines (1585 loc) · 89 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
#!/usr/bin/env python3
"""티켓 큐 헬퍼(프로젝트 무관). <루트> = `tickets/`(큐)와 `personas/`를 담은 티켓 루트.
select <루트> 미할당 열린 티켓들을 오래된 순으로 ->
"path|hash|kind|persona|priority|baseline|effective|squad_persona"
(여덟째 필드는 `squad:`가 풀린 리더 이름 - 없으면 빈 문자열)
wips <루트> 진행중(`.wip`) 티켓 전부 -> "path|hash|effective|assigned_at|pid|owner"
assign <path> <sid> frontmatter에 session_id/assigned_at 기록
setpersona <path> <이름> frontmatter의 persona: 기록 (claim 뒤 스쿼드 해석 결과를 남긴다)
clear <path> frontmatter의 session_id/assigned_at 비우기 (할당 취소)
list <루트> 열린 티켓 전체 상태 표
find <루트> <hash> 해시로 티켓 경로 찾기
reap <루트> [로컬] 세션이 죽은 진행중 티켓을 백로그로 회수 (스테일 수거). [로컬]은
`tick.sh`의 `$LOCAL`(생략 시 `$TICKET_LOCAL` -> `~/.config/dira`) -
그 티켓 소유 워커가 사후처리 중인지(P360-2) 잴 때만 쓴다
handclaim <path> [owner] 대화형 세션이 손으로 잡기. claim + pid/claimed_at/transcript 기록
askhuman <path> [--if-blocked] 답변 대기로 잠그기 (deps + awaiting + `## 질문 n`).
--if-blocked면 신선한 블록 + 미충족 dep 0일 때만 잠그고, 아니면 조용히 끝남
pollrows <루트> 열린+폴링 대기 티켓 전부 -> "path|hash" (§폴링 대기 결정 3·5)
pollstart <path> <스크립트파일명> <상한> polling/polling_until 기록 (`poll` 서브커맨드 성공 경로)
pollplan <path> [하한초] 이번 tick에 이 폴링 티켓을 어떻게 할지 -> WAIT / RUN <경로>[|로그] / ASK ...
pollresult <path> <종료코드> [로그파일] 결정 4의 판정 반영 (0=해제, 1=대기, 그 밖=실패 카운트)
큐는 루트 한 곳이고 하위 디렉터리는 없다. 디렉터리가 뜻하던 것은 전부 frontmatter로 갔다 --
누가 수행하는지는 `persona:`(없으면 페르소나 없는 평범한 에이전트), 성격은 `kind:`.
상태만 여전히 파일명 접미사다(rename이 원자적 락이라 그렇다).
프로젝트별 값은 환경변수로만 갈린다:
TICKET_INPROGRESS 진행중 상태 접미사 (기본 ".wip")
TICKET_DONE 완료 상태 접미사 (기본 ".done")
"""
import os
import re
import sys
import glob
import json
import uuid
import errno
import hashlib
import subprocess
import unicodedata
from datetime import datetime, timezone, timedelta
# 기본 접미사는 ASCII + 마침표 구분이다: <hash>.md / <hash>.wip.md / <hash>.done.md.
# (ls·grep·탭완성에서 한글이 걸리적거리고, 마침표는 확장자처럼 읽혀 해시와 상태가 눈에 갈린다.)
# 다른 접미사로 만든 티켓이 이미 있는 설치는 워커에서 그 값으로 고정해야 한다 - 안 하면
# 접미사가 이름의 일부로 보여서 이미 잡힌 티켓이 큐에 다시 뜬다.
IN_PROGRESS = os.environ.get("TICKET_INPROGRESS") or ".wip"
DONE = os.environ.get("TICKET_DONE") or ".done"
CLOSED_SUFFIXES = (IN_PROGRESS, DONE)
def nfc(s):
return unicodedata.normalize("NFC", s)
def is_open_name(basename):
stem = nfc(basename)
if stem.endswith(".md"):
stem = stem[:-3]
return not any(stem.endswith(nfc(sfx)) for sfx in CLOSED_SUFFIXES)
def read_fm(path):
"""frontmatter를 (dict, 원문줄들, 끝인덱스)로 반환. 없으면 (None, ...)."""
with open(path, "r", encoding="utf-8") as f:
lines = f.read().split("\n")
if not lines or lines[0].strip() != "---":
return {}, lines, -1
end = -1
for i in range(1, len(lines)):
if lines[i].strip() == "---":
end = i
break
if end < 0:
return {}, lines, -1
fm = {}
for i in range(1, end):
m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$", lines[i])
if m:
fm[m.group(1)] = m.group(2).strip()
return fm, lines, end
def ticket_hash(path, fm):
h = (fm.get("ticket") or "").strip().strip("\"'")
if h:
return h
base = nfc(os.path.basename(path))
return base[:-3] if base.endswith(".md") else base
def is_assigned(fm):
return bool((fm.get("session_id") or "").strip().strip("\"'"))
def is_polling(fm):
"""frontmatter `polling`(스크립트 파일명)이 비어 있지 않으면 폴링 대기다(§폴링 대기 결정 2)."""
return bool((fm.get("polling") or "").strip().strip("\"'"))
PERSONA_RE = re.compile(r"^[A-Za-z0-9_-]+$")
def persona_of(fm):
"""frontmatter `persona:`. 없으면 "" - 페르소나 없는 평범한 에이전트가 처리한다.
디스패처가 이 값을 <personas>/<값>/PROFILE.md 경로로 조립하므로 이름 문자만 통과시킨다
(`persona: ../../.ssh/id_rsa` 같은 값이면 임의 파일이 프롬프트에 실려 나간다).
"""
p = (fm.get("persona") or "").strip().strip("\"'")
return p if PERSONA_RE.match(p) else ""
def squad_of(fm):
"""frontmatter `squad:`. 없으면 "" - 이름 규칙은 페르소나와 한 이름공간이다(§5-5 §값)."""
s = (fm.get("squad") or "").strip().strip("\"'")
return s if PERSONA_RE.match(s) else ""
def squad_leader(troot, squad, h=""):
"""`squads/<squad>/members` 첫 유효 줄의 페르소나 이름(리더) - §5-5 §개정.
리더 모델에서 고르기는 이 줄 하나다 - 진행중 수를 세지 않으므로 프로세스가 안 뜬다(§E12).
squads/ 없음 - members 없음/빈 파일 - 첫 줄 이름이 PERSONA_RE 밖, 넷 다 같은 처분이다:
"" + WARN 한 줄(§5-5 §검증 (E5)) - 호출자는 종전 경로(`persona:`)로 떨어진다. 이름 검증은
여기서 한다 - 이 값이 그대로 `persona:`에 쓰여 `personas/<값>/PROFILE.md` 경로를 만든다
(`persona_of`와 같은 신뢰 경계).
"""
path = os.path.join(troot, "squads", squad, "members")
try:
with open(path, "r", encoding="utf-8") as f:
lines = f.read().split("\n")
except OSError:
print("WARN 스쿼드 {} 못 읽음 {} ({}) - 종전 경로로 처리".format(squad, h, path),
file=sys.stderr)
return ""
for line in lines:
line = line.strip()
if not line:
continue
name = line.split(None, 1)[0]
if PERSONA_RE.match(name):
return name
print("WARN 스쿼드 {} 첫 줄 이름이 규칙 밖이다 {} 값={!r} - 종전 경로로 처리".format(
squad, h, name), file=sys.stderr)
return ""
print("WARN 스쿼드 {} members가 비었다 {} - 종전 경로로 처리".format(squad, h),
file=sys.stderr)
return ""
def tickets_in(troot):
"""큐(<루트>/tickets/)의 티켓 파일들. 평면이다 - 하위 디렉터리는 보지 않는다."""
return [p for p in glob.glob(os.path.join(troot, "tickets", "*.md"))
if not os.path.basename(p).startswith(".")]
# 구 레이아웃(to-<역할>/<성격>/) 잔여물. 큐에서 안 보이므로 조용히 굶는 대신 알린다.
LEGACY_GLOBS = ("to-*/*/*.md", "to-*/*.md", "request/*.md", "work/*.md", "feedback/*.md",
"tickets/*/*.md")
def warn_legacy(troot):
hits = [p for g in LEGACY_GLOBS for p in glob.glob(os.path.join(troot, g))]
if hits:
print("WARN 구 레이아웃에 티켓 {}건이 남아 있다 - 큐에서 안 보인다. 루트로 옮기고 "
"frontmatter에 kind:/persona:를 넣어라 (예: {})".format(len(hits), hits[0]),
file=sys.stderr)
def birth(path):
st = os.stat(path)
return getattr(st, "st_birthtime", st.st_mtime)
def deps_of(lines, end):
"""frontmatter deps의 선행 해시들. `deps: [a, b]`·블록 리스트 둘 다 읽는다.
read_fm은 스칼라 `key: value`만 담아서 블록 리스트를 못 보므로 원문 줄에서 직접 뽑는다."""
out = []
for i in range(1, end):
m = re.match(r"^deps:\s*(.*)$", lines[i])
if not m:
continue
inline = m.group(1).strip().strip("[]")
if inline:
out += [h.strip().strip("\"'") for h in inline.split(",")]
for j in range(i + 1, end):
m2 = re.match(r"^\s+-\s*(.+)$", lines[j])
if not m2:
break
out.append(m2.group(1).strip().strip("\"'"))
break
return [h for h in out if h]
PRIORITY_DEFAULT = 3
PRIORITY_MIN, PRIORITY_MAX = 1, 5
def priority_of(fm, h=""):
"""frontmatter `priority:`. 없으면 3(무경고). 정수가 아니거나 1~5 밖이면 3 + WARN 한 줄
(§1-3 §값 — 파서를 만들지 않는다, `read_fm`이 준 문자열에 `int()` 한 번이다)."""
raw = (fm.get("priority") or "").strip().strip("\"'")
if not raw:
return PRIORITY_DEFAULT
try:
n = int(raw)
except ValueError:
print("WARN priority가 정수가 아니다 {} 값={!r} - 3으로 읽음".format(h, raw),
file=sys.stderr)
return PRIORITY_DEFAULT
if n < PRIORITY_MIN or n > PRIORITY_MAX:
print("WARN priority가 1~5 밖이다 {} 값={} - 3으로 읽음".format(h, n), file=sys.stderr)
return PRIORITY_DEFAULT
return n
DUE_ESCALATE = timedelta(hours=5) # 남은 <= 이 값이면 파생 5 (지난 마감 포함)
DUE_DEMOTE = timedelta(days=7) # 남은 >= 이 값이고 자기 duedate가 있으면 파생 1
def duedate_of(fm, h=""):
"""frontmatter `duedate:`. 키가 없으면 마감 없음(None, 무경고) - 큐 마이그레이션 0건이
이 무경고에 걸려 있다. 키가 있는데 못 읽으면(빈 값 포함) 마감 없음 + WARN 한 줄
(§1-4 §값 — `datetime.fromisoformat` 하나, 새 파서를 만들지 않는다). 오프셋 있는 값은
로컬로 변환해 버린다 - `now`(로컬, naive)와 늘 같은 형이어야 뺄 수 있다."""
if "duedate" not in fm:
return None
raw = (fm.get("duedate") or "").strip().strip("\"'")
try:
if not raw:
raise ValueError("empty")
dt = datetime.fromisoformat(raw)
except ValueError:
print("WARN duedate 못 읽음 {} 값={!r} - 마감 없음으로 읽음".format(h, raw),
file=sys.stderr)
return None
if dt.tzinfo is not None:
dt = dt.astimezone().replace(tzinfo=None)
return dt
def derive_priority(remaining, has_own_duedate):
"""§1-4 §파생: 남은 <= 5시간이면 5(지난 마감 포함) · 자기 duedate가 있고 남은 >= 7일이면
1 · 그 사이는 없음(None). 강등(1)만 `has_own_duedate`로 막는다 - 급한 쪽(5)은 전이하지만
느긋한 쪽(1)은 전이하지 않는다(§1-4 §전이)."""
if remaining is None:
return None
if remaining <= DUE_ESCALATE:
return 5
if has_own_duedate and remaining >= DUE_DEMOTE:
return 1
return None
def _priority_graph(troot):
"""열린 티켓 + `.wip`의 (해시 -> priority, 해시 -> deps 원본, 해시 -> duedate). `.done`은
안 본다 - 끝난 티켓은 더는 아무것도 기다리지 않는다(§1-3 §유효 우선순위)."""
prio, deps, duedate = {}, {}, {}
for p in tickets_in(troot):
base = nfc(os.path.basename(p))
stem = base[:-3] if base.endswith(".md") else base
if stem.endswith(nfc(DONE)):
continue
try:
fm, lines, end = read_fm(p)
except (OSError, UnicodeDecodeError):
continue
if end < 0:
continue
h = ticket_hash(p, fm)
prio[h] = priority_of(fm, h)
deps[h] = deps_of(lines, end)
duedate[h] = duedate_of(fm, h)
return prio, deps, duedate
def _warn_duedate_reversals(duedate, deps):
"""§1-4 §역전: t가 기다리는 선행 d의 duedate가 t 자신의 duedate보다 늦으면 모순이다
(선행이 후행보다 늦게 끝나도 된다는 뜻이 되어 버린다). 거부할 자리가 없다 - 파일은 이미
있고, 지우는 것은 사람이 적은 일을 엔진이 지우는 것이다. WARN 한 줄만 찍고 판정은 그대로
진행한다. 둘 다 자기 duedate가 있을 때만 본다 - 없는 쪽은 애초에 모순을 못 적는다."""
for h, ds in deps.items():
due_h = duedate.get(h)
if due_h is None:
continue
for d in ds:
due_d = duedate.get(d)
if due_d is not None and due_d > due_h:
print("WARN 마감 역전 {} > {}".format(d, h), file=sys.stderr)
def _effective_from_graph(prio, deps, duedate, now):
"""§1-3 유효 우선순위 + §1-4 유효마감을 **같은 순회에서** 함께 접는다(추가 순회 0).
유효마감(t) = min({t.duedate} ∪ {유효마감(w) | w의 deps에 t가 있다}) - 아무 것도 없으면
마감 없음(None). 기준(t) = 파생(남은(t) = 유효마감(t)-now, 자기 duedate 유무)이 있으면
파생, 없으면 t.priority. 유효(t) = max(기준(t), {유효(w) | w의 deps에 t가 있다}).
방향은 §1-3과 같은 역방향이다 - t를 기다리는 w의 값을 t가 물려받는다. 순환은 방문
집합으로 자른다(재방문하면 그 노드를 더 안 접고 자기 값만 반환한다 - 무한재귀 없이,
다른 비순환 경로의 값은 그대로 접힌다). 파일에는 아무것도 안 쓴다.
반환은 (유효 우선순위, 기준값, 유효마감) 세 dict - 기준값은 DISPATCH 로그의 출처
표기(`(마감)` · `(상속 N)` · `(마감·상속 N)`)가 원값과 갈라 보는 데 쓴다.
"""
waiters = {}
for h, ds in deps.items():
for d in ds:
waiters.setdefault(d, []).append(h)
eff, base, eff_due = {}, {}, {}
def calc(h, visiting):
if h in eff:
return eff[h], eff_due[h]
own_prio = prio.get(h, PRIORITY_DEFAULT)
own_due = duedate.get(h)
if h in visiting:
return own_prio, own_due
visiting.add(h)
best_due = own_due
best_eff = None
for w in waiters.get(h, []):
w_eff, w_due = calc(w, visiting)
if w_due is not None and (best_due is None or w_due < best_due):
best_due = w_due
best_eff = w_eff if best_eff is None else max(best_eff, w_eff)
visiting.discard(h)
remaining = (best_due - now) if best_due is not None else None
derived = derive_priority(remaining, own_due is not None)
h_base = derived if derived is not None else own_prio
best = h_base if best_eff is None else max(h_base, best_eff)
eff[h] = best
base[h] = h_base
eff_due[h] = best_due
return best, best_due
for h in prio:
calc(h, set())
return eff, base, eff_due
def deps_unmet(troot, deps):
"""미완료 선행 해시. 티켓을 못 찾으면 미완료로 본다(보수적 - 오탈자 해시로 착수되는 편보다 안전)."""
unmet = []
for h in deps:
pth = find_any(troot, h)
if not pth or not nfc(os.path.basename(pth)).endswith(nfc(DONE + ".md")):
unmet.append(h)
return unmet
def scan(troot, now=None):
"""열린 티켓(상태 접미사 없음)을 유효 우선순위 높은 순, 같은 값 안에서는 생성일 오름차순으로
(§1-3 §순서 — `(-effective, birth, path)`). `now`는 §1-4 §계산 시점 - 안 주면 한 번 읽어
그 호출의 행 전부에 같은 값을 쓴다(시계를 기다려야 검증되는 코드를 만들지 않는다)."""
if now is None:
now = datetime.now()
prio, deps_by_h, duedate = _priority_graph(troot)
_warn_duedate_reversals(duedate, deps_by_h)
eff, baseline, eff_due = _effective_from_graph(prio, deps_by_h, duedate, now)
rows = []
for p in tickets_in(troot):
if not is_open_name(os.path.basename(p)):
continue
try:
fm, flines, end = read_fm(p)
except (OSError, UnicodeDecodeError):
continue
if end < 0:
continue
h = ticket_hash(p, fm)
sq = squad_of(fm)
rows.append({
"path": p,
"hash": h,
"kind": (fm.get("kind") or "").strip().strip("\"'"),
"persona": persona_of(fm),
"birth": birth(p),
"assigned": is_assigned(fm),
"polling": is_polling(fm),
"session_id": (fm.get("session_id") or "").strip(),
# deps 미충족이면 큐에서 제외한다(pull 규약을 디스패처 층에서 강제).
# 없으면 세션이 착수를 거부하고 종료해 티켓이 진행중으로 유실된다(2026-07-28 05990d8e 실사고).
"unmet": deps_unmet(troot, deps_of(flines, end)),
"priority": prio.get(h, PRIORITY_DEFAULT),
"baseline": baseline.get(h, PRIORITY_DEFAULT),
"effective": eff.get(h, PRIORITY_DEFAULT),
# `squad:`가 풀린 리더 이름 - 없거나 못 읽으면 "" (§5-5 §개정, 호출자는 persona로 떨어진다)
"squad_persona": squad_leader(troot, sq, h) if sq else "",
})
rows.sort(key=lambda r: (-r["effective"], r["birth"], r["path"]))
return rows
def set_fm_keys(path, updates):
"""frontmatter 키를 갱신(없으면 닫는 --- 직전에 삽입). 리네임 없이 제자리 쓰기."""
fm, lines, end = read_fm(path)
if end < 0:
raise SystemExit("frontmatter 없음: " + path)
for key, val in updates.items():
idx = None
for i in range(1, end):
if re.match(r"^" + re.escape(key) + r":\s*", lines[i]):
idx = i
break
newline = "{}: {}".format(key, val) if val else "{}:".format(key)
if idx is None:
lines.insert(end, newline)
end += 1
else:
lines[idx] = newline
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
def set_deps(path, deps):
"""deps를 인라인 한 줄로 다시 쓴다. 블록 리스트(`- a`)면 children까지 걷어낸다 --
set_fm_keys는 `deps:` 첫 줄만 고쳐서 children이 고아로 남고 그 해시들이 조용히 사라진다."""
fm, lines, end = read_fm(path)
if end < 0:
raise SystemExit("frontmatter 없음: " + path)
out, i = [], 0
while i < end:
if re.match(r"^deps:", lines[i]):
i += 1
while i < end and re.match(r"^\s+-\s", lines[i]):
i += 1
continue
out.append(lines[i])
i += 1
out.append("deps: [{}]".format(", ".join(deps)))
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(out + lines[end:]))
HANDOFF_MAX = 3 # 이어받기 사슬 상한(DESIGN.md 결정 6). 원본 + 3장까지는 사람을 안 부른다.
def _check_handoff_cap(dst):
"""claim이 원자적 link에 **성공한 뒤**(결정 6 (3)) handoffs 상한을 판정한다.
넘겼으면 release로 백로그 이름을 되돌리고 ask_human으로 답변 대기를 건다 -
원자적 link 자체는 손대지 않는다. `awaiting`이 이미 걸려 있으면 건드리지 않는다
(결정 6 (4) - 없으면 사람이 답한 뒤 재claim 때 또 걸려 영구 루프가 된다).
"""
fm, lines, end = read_fm(dst)
if end < 0:
return
handoffs = int((fm.get("handoffs") or "0").strip() or 0)
if handoffs <= HANDOFF_MAX or (fm.get("awaiting") or "").strip():
return
h = ticket_hash(dst, fm)
attempts = int((fm.get("attempts") or "0").strip() or 0)
p = release(dst)
ask_human(p, h, attempts, "", handoff=True)
raise SystemExit("이어받기가 3회를 넘었습니다: " + h)
def claim(path):
"""<hash>.md -> <hash><진행중>.md 원자적 잡기. 이미 잡혀 있으면 실패.
성공한 뒤 handoffs 상한을 판정한다(`_check_handoff_cap`)."""
d, base = os.path.split(path)
stem = nfc(base)[:-3]
dst = os.path.join(d, stem + IN_PROGRESS + ".md")
try:
os.link(path, dst) # dst가 이미 있으면 EEXIST -> 잡기 실패(락)
os.utime(dst, None) # 하드링크는 원본 mtime을 물려받는다 - claim 시각으로 되찍는다
os.unlink(path)
_check_handoff_cap(dst)
return dst
except OSError as e:
if getattr(e, "errno", None) == errno.EEXIST:
raise SystemExit("이미 잡힘: " + dst)
# 하드링크 미지원 파일시스템(구글드라이브 등 FUSE·SMB) 폴백.
# os.rename은 쓰면 안 된다 - dst가 있어도 조용히 덮어쓰므로 락이 아니다. exists() 선검사는
# TOCTOU라 두 프로세스가 둘 다 통과해 같은 티켓을 잡고 한쪽 파일이 사라진다.
# O_CREAT|O_EXCL은 하드링크 없이도 원자적이라, 자리를 먼저 잡고 내용을 옮긴다.
try:
with open(path, "rb") as f:
data = f.read()
except FileNotFoundError: # 읽는 사이에 다른 쪽이 이겨서 원본을 치웠다
raise SystemExit("이미 잡힘: " + dst)
try:
fd = os.open(dst, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
except OSError as e:
if getattr(e, "errno", None) == errno.EEXIST:
raise SystemExit("이미 잡힘: " + dst)
raise
with os.fdopen(fd, "wb") as out:
out.write(data)
os.unlink(path)
_check_handoff_cap(dst)
return dst
def _release_suffix(path, suffix, report=False):
"""<hash><suffix>.md -> <hash>.md 되돌리기. 멱등이다(§엔진 수정 서른여섯 번째 승인
판정 3) - 열린 이름이 이미 있는데 이 파일이 이미 사라졌다면 남이 먼저 되돌린 자리이므로
성공으로 끝난다. 열린 이름과 이 파일이 둘 다 있으면(진짜 사본 둘) 종전대로 실패한다.
`report=True`면 `(dst, did_work)` 튜플을 반환한다 - `did_work=False`는 이 호출이
아무것도 안 썼다는 뜻이라, 호출자는 그 뒤 frontmatter를 쓰면 안 된다(진 쪽의 쓰기가
이긴 쪽의 결과를 덮으면 안 된다, 2026-07-31 `5f0498c9`)."""
d, base = os.path.split(path)
stem = nfc(base)[:-3]
if not stem.endswith(nfc(suffix)):
return (path, True) if report else path
dst = os.path.join(d, stem[:-len(nfc(suffix))] + ".md")
if os.path.exists(dst):
if not os.path.exists(path):
return (dst, False) if report else dst
raise SystemExit("복귀 대상이 이미 존재: " + dst)
os.rename(path, dst)
return (dst, True) if report else dst
def release(path, report=False):
"""<hash><진행중>.md -> <hash>.md 되돌리기(백로그 복귀). 기존 호출부(CLI 서브커맨드 -
`_check_handoff_cap` - 테스트)와 호환하는 `.wip` 전용 얇은 래퍼다."""
return _release_suffix(path, IN_PROGRESS, report)
REAP_GRACE_SEC = 180 # 디스패치 직후 프로세스 등록 지연을 피하는 유예
REAP_MAX_ATTEMPTS = 2 # 이 횟수까지만 자동 회수. 넘으면 사람 개입 대기(-진행중 유지)
# P360-2: REAP_GRACE_SEC을 넘긴 뒤에도, 그 티켓의 부모 워커가 `tick.sh`의 사후처리
# (kill -TERM -> sleep 5 -> kill -KILL -> wait -> 판정, 실측 약 30초)를 도는 동안은 리퍼가
# 앞지르지 않는다. 상한은 이 값 하나뿐이다 - 부모 락이 죽은 pid를 쥔 채 안 풀려도 그 즉시
# 리퍼가 가져가고, 살아있어도 이 상한을 넘으면 종전대로 가져간다(<뒤집는 조건>, 상한 없는
# 대기 금지).
REAP_POST_GRACE_SEC = 60
# P362-2 (§엔진 수정 서른세 번째 승인) - `tick.sh`의 FAIL 경로(`reap_release`) 전용 예산 둘.
# `REAP_MAX_ATTEMPTS`(리퍼 `reclaim`의 몫)와는 다른 값이다 - 사유가 갈리는 자리도 다르다.
REAP_FAIL_BUDGET_BAD_REQUEST = 2 # 짧다 - bad_request는 재시도해도 같은 자리에서 죽는 것이
# 이미 증명된 사인이다(실측 218건 중 197건이 티켓 다섯 장, 최다 49회 - 승인 표 1행). 리퍼와
# 같은 상한에서 바로 사람에게 올린다.
REAP_FAIL_BUDGET_OTHER = 10 # 길다 - 실측(승인 표): 예산을 10으로 걸어도 넘긴 티켓 49장
# 전부가 사람 개입 없이 재디스패치만으로 완료됐다(100%). 사람에게 안 올리고 백오프로 돌린다.
REAP_BACKOFF_SEC = 600 # 백오프 첫 항(누적 1회째) - 엔진 쿨다운(`tick.sh`
# CDOWN_W=300)의 두 배. 다른 엔진 불능 없이 이 티켓 하나만 쉬게 하면서 다른 티켓엔 방해가
# 안 되는 만큼만 기다린다. 둘째 항부터는 피보나치 배수로 자란다(§답변 대기 결정 3, P395-2) -
# 서른세 번째 승인이 걸었던 누적 5회 상한은 그 승인이 뒤집혀 없앤다. 상한 없는 대기가
# 방치인 것은 아무도 안 집을 때뿐인데, 피보나치 재시도는 대기가 얼마나 길어져도 티켓이
# 반드시 다시 뜬다.
# 손 클레임(대화형 세션) 판정용. 디스패처 세션과 달리 ps에 --session-id가 안 뜨므로
# session_id로는 생존을 볼 수 없다(실측 2026-07-29). pid + 트랜스크립트로 대신 본다.
# 시간은 판정이 아니라 점검 트리거다 — 경과만으로 회수하면 오래 걸리는 정상 세션을 죽인다
# (2026-07-28 확정, 그래서 디스패처도 ps 생존 대조를 쓴다).
MANUAL_GRACE_SEC = 1800 # 잡은 뒤 이만큼 지나기 전엔 아예 보지 않는다
MANUAL_IDLE_SEC = 3600 # pid는 살아있는데 트랜스크립트가 이만큼 조용하면 유휴 의심(보고만)
def live_session_ids():
"""지금 돌고 있는 claude -p 세션 id 집합. tick.sh가 --session-id로 띄우므로 ps로 보인다."""
try:
out = subprocess.run(["ps", "-eo", "command"], capture_output=True, text=True, timeout=10).stdout
except (OSError, subprocess.SubprocessError):
return None # ps를 못 읽으면 판단 불가 -> 호출자가 아무것도 회수하지 않는다
return set(re.findall(r"--session-id\s+(\S+)", out))
def pid_alive(pid):
"""pid 생존 여부. ps를 못 읽으면 None -> 호출자는 아무것도 회수하지 않는다."""
try:
r = subprocess.run(["ps", "-p", str(pid), "-o", "pid="],
capture_output=True, text=True, timeout=10)
except (OSError, subprocess.SubprocessError):
return None
return bool(r.stdout.strip())
def claude_ancestor_pid():
"""조상 중 claude(대화형 세션) 프로세스의 pid. 못 찾으면 None.
tickets.py는 세션 -> bash -> python3로 실행되므로 자기 pid는 세션이 아니다.
"""
pid = str(os.getppid())
for _ in range(8):
try:
r = subprocess.run(["ps", "-p", pid, "-o", "ppid=,comm="],
capture_output=True, text=True, timeout=10)
except (OSError, subprocess.SubprocessError):
return None
parts = r.stdout.strip().split(None, 1)
if len(parts) < 2:
return None
if os.path.basename(parts[1].strip()) == "claude":
return pid
pid = parts[0]
if pid in ("0", "1"):
return None
return None
def newest_transcript(within_sec=120):
"""방금 활동한 세션 트랜스크립트(jsonl) 경로. 애매하면 빈 문자열.
비면 reap은 pid만으로 판정한다(기능 저하일 뿐 오작동은 아니다).
"""
root = os.path.expanduser("~/.claude/projects")
newest, newest_m = "", 0
for p in glob.glob(os.path.join(root, "*", "*.jsonl")):
try:
m = os.path.getmtime(p)
except OSError:
continue
if m > newest_m:
newest, newest_m = p, m
if not newest:
return ""
import time
return newest if (time.time() - newest_m) <= within_sec else ""
def transcript_state(path):
"""트랜스크립트를 테일해 (무활동 경과초, 마지막 레코드 종류). 못 읽으면 (None, 사유)."""
try:
mtime = os.path.getmtime(path)
except OSError:
return None, "트랜스크립트 없음"
idle = (datetime.now(timezone.utc)
- datetime.fromtimestamp(mtime, timezone.utc)).total_seconds()
kind = "?"
try:
with open(path, "rb") as f:
f.seek(0, os.SEEK_END)
f.seek(max(0, f.tell() - 65536))
tail = f.read().decode("utf-8", "replace").strip().split("\n")
for line in reversed(tail):
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except ValueError:
continue
kind = str((rec.get("message") or {}).get("role") or rec.get("type") or "?")
break
except OSError:
pass
return idle, kind
def transcript_of(fm):
"""이 티켓 세션의 트랜스크립트 경로. fm `transcript:` 우선, 없으면 `session_id`로 찾는다.
디스패처가 붙인 `transcript:`는 reap이 지우기 전까지만 있고, 손 클레임 세션엔 아예 없다.
그래서 `session_id`로 `~/.claude/projects/*/<sid>.jsonl`도 본다. 못 찾으면 빈 문자열.
"""
tr = (fm.get("transcript") or "").strip().strip("\"'")
if tr and os.path.isfile(tr):
return tr
sid = (fm.get("session_id") or "").strip().strip("\"'")
# 티켓 파일은 사람도 고치는 입력이다. glob 메타문자·경로 구분자가 섞이면 큐 밖을 훑는다.
if not re.match(r"^[A-Za-z0-9._-]+$", sid or ""):
return ""
hits = glob.glob(os.path.expanduser("~/.claude/projects/*/{}.jsonl".format(sid)))
return hits[0] if hits else ""
def _rec_text(rec):
"""트랜스크립트 레코드에서 사람이 읽을 텍스트. 없으면 빈 문자열.
ponytail: 중첩 content는 str()로 뭉갠다. 읽히기만 하면 되는 인용이라 구조는 필요 없다.
"""
c = (rec.get("message") or {}).get("content")
if c is None:
c = rec.get("content") or rec.get("error") or rec.get("summary")
if isinstance(c, list):
c = " ".join(str(it.get("text") or it.get("content") or "") if isinstance(it, dict)
else str(it) for it in c)
return " ".join(str(c or "").split())
def transcript_tail(path):
"""트랜스크립트 끝에서 마지막 텍스트/에러 레코드 한 건(`[역할] 본문`) 전문. 못 읽으면 빈 문자열.
transcript_state와 같은 방식이다 -- 끝에서 64KB만 seek해 읽고 역순으로 json을 파싱한다.
상한은 이 함수의 일이 아니다(`ask_context`가 `_capped`로 자른다).
"""
try:
with open(path, "rb") as f:
f.seek(0, os.SEEK_END)
f.seek(max(0, f.tell() - 65536))
tail = f.read().decode("utf-8", "replace").strip().split("\n")
except OSError:
return ""
for line in reversed(tail):
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except ValueError:
continue
txt = _rec_text(rec)
# 큐 운영 알림(`<task-notification>` 등 봉투 태그)은 세션의 발화가 아니다 -
# 실측 29ec5f08: 마지막 레코드가 이거라 판단 재료가 0이었다. 건너뛰고 그 앞 발화를 찾는다.
if txt and not txt.startswith("<task-notification>"):
return "[{}] {}".format(
str((rec.get("message") or {}).get("role") or rec.get("type") or "?"), txt)
return ""
def in_progress(troot):
"""상태 접미사가 진행중인 티켓 경로들(NFC/NFD 무관)."""
return [p for p in tickets_in(troot)
if nfc(os.path.basename(p))[:-3].endswith(nfc(IN_PROGRESS))]
# 회수할 때 비우는 할당·생존 신호. 남겨 두면 열린 티켓이 '할당됨'으로 보여 select가 영구 제외한다.
# inbox도 여기 있어야 한다 - 죽은 세션의 FIFO 경로라 재클레임 뒤에도 그대로 남으면, 다음
# 디스패치가 실제로 setinbox를 부르기 전인데도 frontmatter만 보면 이미 참견 입구가 열려
# 있는 것처럼 보인다(§4-10 테스트가 이 값을 살아있는 신호로 읽어 검증한다 - 2026-09-02 재현).
REAP_CLEAR = ("session_id", "assigned_at", "owner", "pid", "claimed_at", "transcript", "inbox")
def _section(body, pat):
"""본문에서 `## <pat>` 절(같은 이름이 여럿이면 마지막 것)의 전문. 없으면 "".
h3 이하는 절 안에 남긴다(`^##\\s`는 `### `에 걸리지 않는다) -- 답변 화면도 같은 규칙이다.
상한은 이 함수의 일이 아니다(결정 13 - 블록은 무제한, Goal/로그는 `_capped`가 자른다).
"""
# 제목 매칭은 NFC로 한다(맥에서 온 본문은 `블록`이 NFD로 적혀 있을 수 있다). 인용은 원문 그대로.
starts = [i for i, l in enumerate(body) if re.match(r"^##\s*" + nfc(pat), nfc(l))]
if not starts:
return ""
out = []
for l in body[starts[-1] + 1:]:
if re.match(r"^##\s", l):
break
out.append(l)
return "\n".join(out).strip()
def _capped(text, limit):
"""text가 limit자를 넘으면 앞 limit자 + 잘림 표시 한 줄(결정 13 (2)). 안 넘으면 그대로."""
if len(text) <= limit:
return text
return "{}\n\n(전문 {}자 중 앞 {}자)".format(text[:limit], len(text), limit)
def _quote(text):
return "\n".join(("> " + l) if l.strip() else ">" for l in text.split("\n"))
def _answers_of(troot, lines, end):
"""deps 중 `kind: answer` 티켓의 본문 전문, 질문 N에 답변 N이 짝인 라운드 순서.
`awaiting`은 마지막 라운드 것만 가리켜 이전 라운드 답을 놓친다(PM PROFILE와 같은 이유) -
`deps`의 stem을 전부 열어야 다 보인다(요구 4f761c5a).
"""
out = []
for dep in deps_of(lines, end):
p = find_any(troot, dep)
if not p:
continue
dfm, dlines, dend = read_fm(p)
if (dfm.get("kind") or "").strip() != "answer":
continue
text = "\n".join(dlines[dend:]).strip()
m = re.search(r"답변\s*(\d+)", text)
out.append((int(m.group(1)) if m else 0, text))
out.sort(key=lambda t: t[0])
return [t for _, t in out]
def _log_tail(troot, h, limit=12):
"""`<troot>/workers/runner.log`에서 해시 `h`가 든 줄을 뒤에서부터 최대 `limit`줄.
파일이 없거나 못 읽으면(또는 `h`가 비어 있으면) 빈 리스트 - 호출부가 그 자리를
"찾지 못했습니다"로 채운다.
"""
if not h:
return []
pat = re.compile(r"\b" + re.escape(h) + r"\b")
try:
with open(os.path.join(troot, "workers", "runner.log"),
encoding="utf-8", errors="replace") as f:
hits = [l.rstrip("\n") for l in f if pat.search(l)]
except OSError:
return []
return hits[-limit:]
# 결정 17 (2) - 우선순위 그대로(한도 다음 순서). 값은 tick.sh가 실제로 찍는 문장의 부분
# 문자열이다 - 동사당 문장 꼴이 세 가지를 안 넘는 닫힌 어휘라(요구 39394728) 이 넷으로 갈린다.
_DEAD_REASON_NEEDLES = [
("요청 오류", "세션이 result is_error로 끝났다"),
("주입 실패", "주입 뒤 출력이 안 자랐다"),
("기동 실패", "프롬프트 주입+init을 못 봤다"),
("상한 초과", "초과 강제종료"),
]
# tick.sh의 log() -- `date '+%F %T'`로 찍은 "YYYY-MM-DD HH:MM:SS [워커] 문구".
_LOG_LINE = re.compile(r"^(\S+ \S+)\s+\[([^\]]*)\]\s?(.*)$")
_LIMIT_NOTE = re.compile(r"NOTE 엔진 불능 - \d+초 쿨다운")
# P400-1(§엔진 수정 서른여섯 번째 승인 §판정 1) - tick.sh가 FAIL 줄에 자기 손으로 가른
# DEATH_KIND를 `reason=`으로 찍는다(`tick.sh` 1806줄 부근). 있으면 아래 NOTE 상관 추정 없이
# 이 값으로 바로 한도를 가른다 - 옛 로그(마커 없음)만 상관 추정으로 떨어진다.
_REASON_TAG = re.compile(r"\breason=(\S+)")
def _parse_log_ts(s):
try:
return datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
def dead_reason(lines, h):
"""`runner.log` 줄 목록과 티켓 해시를 죽은 사유 하나로 가른다 -- 결정 17.
트랜스크립트는 안 읽는다(결정 17 (5)) - 엔진이 자기 손으로 쓴 판정 기록의 어휘가
닫혀 있어(머리말 동사 16종) `runner.log`만으로 전수 분류된다. 창은 이 해시의 마지막
`DISPATCH`에서 거슬러 올라간 최대 3개까지다 - 그 밖의 옛 시도는 지금 사유와 무관하다.
둘 이상 걸리면 결정 17 (2)의 우선순위(이 함수의 검사 순서)대로 첫 번째만 고른다.
"""
if not h or not lines:
return "알 수 없음"
parsed = []
for l in lines:
m = _LOG_LINE.match(l)
if m:
parsed.append((m.group(1), m.group(2), m.group(3)))
pat = re.compile(r"\b" + re.escape(h) + r"\b")
hash_idx = [i for i, (_, _, msg) in enumerate(parsed) if pat.search(msg)]
if not hash_idx:
return "알 수 없음"
dispatch_idx = [i for i in hash_idx if parsed[i][2].startswith("DISPATCH ")]
start = dispatch_idx[-3] if len(dispatch_idx) >= 3 else (dispatch_idx[0] if dispatch_idx else hash_idx[0])
window = parsed[start:]
window_hash = [entry for entry in window if pat.search(entry[2])]
if not window_hash:
return "알 수 없음"
# 새 형식 - FAIL 줄 자신이 reason=을 들고 있으면 그 값을 바로 쓴다. api_error가 한도고,
# bad_request·other는 아래 needle과 같은 "요청 오류"로 떨어진다(판정 1 - 그 갈래 처분은
# 안 건드린다). 마커가 하나라도 있으면 이 로그는 새 형식이니 옛 NOTE 상관은 안 돈다.
# 한도가 최우선이니(결정 17 (2)) 창 전체를 먼저 훑어 api_error부터 찾는다.
tags = [_REASON_TAG.search(msg) for _, _, msg in window_hash]
tags = [t.group(1) for t in tags if t]
if "api_error" in tags:
return "한도"
# P435 결정 2 - `tick.sh`가 붙인 reason=bg(밀린 백그라운드 작업을 기다리다 끝났다)는
# 사람이 쓸 답이 없다 - 일반 "요청 오류"로 뭉개지기 전에 먼저 가른다.
if "bg" in tags:
return "백그라운드 중단"
if tags:
return "요청 오류"
# 한도 - 워커에 안 묶인 전역 줄이라, 이 티켓이 걸린 줄과 같은 워커 · 120초 안일 때만
# 센다(창 전체에서 세면 사유가 한도로 쏠린다 - 실측: 안 좁히면 18건, 좁히면 5건). 이 검사만
# 창을 이 해시의 첫 등장까지 넓힌다 - 마지막 회차가 죽은 직후라 자기 쿨다운 노트를 부모가
# 아직 못 적었어도, 앞선 회차가 이미 적어 둔 노트가 창 안에 있으면 잡힌다. 마지막 줄
# 하나가 아니라 걸린 줄마다 검사해야 옛 회차의 노트도 그 회차의 실패 시각과 맞춰 잡힌다.
# 다른 사유 판정은 위 window/window_hash(마지막 3회차)를 그대로 쓴다.
full_window = parsed[hash_idx[0]:]
full_window_hash = [entry for entry in full_window if pat.search(entry[2])]
for ts, w, _ in full_window_hash:
dt = _parse_log_ts(ts)
if dt is None:
continue
for nts, nw, nmsg in full_window:
if nw != w or not _LIMIT_NOTE.search(nmsg):
continue
ndt = _parse_log_ts(nts)
if ndt is not None and abs((ndt - dt).total_seconds()) <= 120:
return "한도"
for name, needle in _DEAD_REASON_NEEDLES:
for _, _, msg in window_hash:
if needle in msg:
return name
if any(msg.startswith("DONE") for _, _, msg in window_hash):
return "무종료 마감"
return "알 수 없음"
def ask_context(fm, body, troot, handoff=False, block_fresh=True, answers=None):
"""자동 상신 질문에 붙일 판단 재료 -- 티켓 Goal · 이미 받은 답변 · 블록 · 엔진 판정 이력 ·
죽은 세션 로그 꼬리.
정형문("3회 죽었다")만으로는 사람이 답할 자료가 화면에 없었다(요구 11990127: jaso에서
3라운드가 "다시 시도해보세요"로 소모됐다). 인용이 화면에서 접혀 있으므로(결정 12 (5))
블록은 상한이 없다(결정 13 (1)) - Goal 600자 · 로그 1500자는 그대로고, 잘리면 `_capped`가
그 사실을 적는다(결정 13 (2)). 세션 로그는 티켓 파일 어디에도 없는 유일한 정보라 없으면
없다고 적는다.
`block_fresh`(fresh_block과 같은 판정, 호출자가 넘긴다)가 거짓이면 인용한 블록은 이미
지난 라운드에서 답한 것이다 - 제목을 갈라 "지금 답해야 할 블록"과 헛갈리지 않게 한다.
`answers`는 `_answers_of`가 모은, 이미 답한 라운드의 전문이다(요구 4f761c5a) - 없으면
절 자체를 안 붙인다.
엔진 판정 이력(DESIGN.md §세션이 120초 안에 못 뜬다 결정 4, 요구 361d973e)은 `runner.log`가
그 세션을 어떻게 끊었는가고, 트랜스크립트는 세션이 무엇을 했는가다 - 서로 다른 것을 담으므로
트랜스크립트 유무·`handoff`와 무관하게 둘 다 붙인다(`ask_human`의 네 갈래가 전부 이 한
자리를 지난다).
`handoff`(§미완으로 끝나는 세션 §개정 2 (2))는 판정이 claim 성공 직후라 할당 필드가
아직 안 쓰여 `session_id`가 구조적으로 없는 갈래다 - 죽은 세션 절만 아예 안 붙인다.
"""
out = ""
goal = _section(body, "Goal")
if goal:
out += "\n### 티켓 Goal\n\n{}\n".format(_quote(_capped(goal, 600)))
if answers:
out += "\n### 이미 받은 답변\n\n{}\n".format(
"\n\n".join(_quote(a) for a in answers))
blk = _section(body, "블록")
if blk:
out += "\n### {}\n\n{}\n".format(
"티켓 블록" if block_fresh else "이미 답한 블록", _quote(blk))
h = (fm.get("ticket") or "").strip().strip("\"'")
log = "\n".join(_log_tail(troot, h))
out += "\n### 엔진 판정 이력\n\n{}\n".format(
_quote(_capped(log, 1500) if log else "runner.log에서 판정 이력을 찾지 못했습니다"))
if handoff:
return out
tr = transcript_of(fm)
tail = transcript_tail(tr) if tr else ""
return out + "\n### 세션 마지막 기록\n\n{}\n".format(
_quote(_capped(tail, 1500) if tail else "트랜스크립트를 찾지 못했습니다"))
# 결정 12 (2) - 사유와 무관하게 한 벌 고정. 마커(**[기본]** 등)를 안 넣는다 -
# 결정 11 (8)이 안 자르기로 한 라벨에 그대로 실려 파서 갈래가 는다(default_answer가 대신 고른다).
_ASK_OPTIONS_BODY = ("이 티켓을 어떻게 할까요\n\n"
"- (a) 다시 시도한다 - 트리를 안 고치고 그대로 다시 보낸다\n"
"- (b) 내가 손보고 나서 다시 시도한다\n"
"- (c) 그만둔다 - 이 티켓을 닫는다\n"
"- (d) 아래 칸에 직접 쓴다\n")
def _ask_options(n):
"""고정 선택지 문항. 세션의 물음이 앞에 뜨면(결정 13 (3)) n이 2로 밀린다."""
return "\n### {}. {}".format(n, _ASK_OPTIONS_BODY)
_STALL_OPTIONS = ("- (a) 남은 `## Done when`을 사람이 판정해서 이 티켓을 닫는다\n"
"- (b) 남은 범위만 새 티켓으로 쪼개서 넘긴다\n"
"- (c) `## Done when`이 한 세션에 안 드는 크기다 - 이 티켓을 쪼갠다\n"
"- (d) 아래 칸에 직접 쓴다\n")
# 사유 -> (정형문에 적을 근거 절 - 결정 (6), `### 1.` 문항 전문 - 결정 표 그대로, 선택지,
# default_answer - 결정 (4)). `{n}`은 attempts(세션 회수)로 채운다.
# §답변 대기 결정 1 - 한도-요청 오류-기동 실패-상한 초과-백그라운드 중단(P435 결정 2) 다섯은
# 사람이 쓸 답이 없어 `reclaim`이 이 사전에 닿기 전에 백오프로 접는다(P395-2) - 무종료
# 마감만 여기 남는다. 새 문항을 만들지 않는다 - `reclaim`의 튜플에만 추가한다.
_DEAD_REASON_INFO = {
"무종료 마감": (
"세션 {n}회가 전부 끝까지 돌고도 이 티켓을 안 닫았습니다",
". 남은 것을 어떻게 할까요", _STALL_OPTIONS, "1.(a)"),
}
_DEAD_BOX = re.compile(r"^-\s*\[( |x|X)\]")
def _dead_box_note(body):
"""무종료 마감 문항 뒤에 붙는 `## Done when` 상자 집계 한 줄(결정 17)."""
boxes = [l.strip() for l in _section(body, "Done when").split("\n")
if _DEAD_BOX.match(l.strip())]
total = len(boxes)
unchecked = sum(1 for l in boxes if _DEAD_BOX.match(l).group(1) == " ")
if total and not unchecked:
return "상자는 다 찼고 rename만 안 됐습니다"
return "미체크 {}개 / 전체 {}개".format(unchecked, total)
def _log_lines(troot):
"""`<troot>/workers/runner.log`의 줄 전체(있으면). `dead_reason`은 워커에 안 묶인
전역 `NOTE 엔진 불능` 줄까지 봐야 하므로 `_log_tail`처럼 해시로 미리 거르지 않는다."""
try:
with open(os.path.join(troot, "workers", "runner.log"),
encoding="utf-8", errors="replace") as f:
return [l.rstrip("\n") for l in f]
except OSError:
return []
# 결정 11 (1)(3) 형식의 문항 머리 - `### <n[-n...]>. <물음>`.
_Q_HEAD = re.compile(r"^###\s*\d+(?:-\d+)*\.\s")
def _block_question(blk):
"""블록 절에 결정 11 형식(문항 + 목록)이 있으면 그 지점부터 절 끝까지. 없으면 ""."""
lines = blk.split("\n")
for i, l in enumerate(lines):
if _Q_HEAD.match(l.strip()):
return "\n".join(lines[i:]).strip()
return ""
def ask_human(path, h, attempts, why, blocked=False, killed=False, handoff=False):
"""자동 회수 상한을 넘겼거나 신선한 `## 블록`이 붙은 티켓을 답변 요청으로 올린다.