Merge branch 'feat/opencode-live-task-activity' into merge/opencode-live-task-activity-into-origin-main-20260606
This commit is contained in:
commit
c0a34600c7
79 changed files with 7362 additions and 680 deletions
|
|
@ -1,189 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="1000" viewBox="0 0 1400 1000">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="48%" r="70%">
|
||||
<stop offset="0%" stop-color="#111827"/>
|
||||
<stop offset="58%" stop-color="#08091a"/>
|
||||
<stop offset="100%" stop-color="#050510"/>
|
||||
</radialGradient>
|
||||
<filter id="glow" x="-80%" y="-80%" width="260%" height="260%">
|
||||
<feGaussianBlur stdDeviation="10" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<style>
|
||||
.title{font:700 30px Inter,Arial,sans-serif;fill:#f8fafc;letter-spacing:0}
|
||||
.sub{font:500 18px Inter,Arial,sans-serif;fill:#94a3b8;letter-spacing:0}
|
||||
.edge{fill:none;stroke:#1d4ed8;stroke-width:2;opacity:.42}
|
||||
.card{fill:#090b1d;stroke:#26314f;stroke-width:1.5}
|
||||
.lead{fill:#142006;stroke:#9bef13;stroke-width:2;filter:url(#glow)}
|
||||
.label{font:700 18px Inter,Arial,sans-serif;fill:#f8fafc;text-anchor:middle;letter-spacing:0}
|
||||
.small{font:500 14px Inter,Arial,sans-serif;fill:#94a3b8;text-anchor:middle;letter-spacing:0}
|
||||
.badge{font:700 12px Inter,Arial,sans-serif;fill:#020617;text-anchor:middle;letter-spacing:0}
|
||||
</style>
|
||||
<rect width="100%" height="100%" fill="url(#bg)"/>
|
||||
<circle cx="0" cy="0" r="0.60" fill="#dbeafe" opacity="0.25"/>
|
||||
<circle cx="97" cy="211" r="1.54" fill="#dbeafe" opacity="0.56"/>
|
||||
<circle cx="194" cy="422" r="1.49" fill="#dbeafe" opacity="0.32"/>
|
||||
<circle cx="291" cy="633" r="1.43" fill="#dbeafe" opacity="0.63"/>
|
||||
<circle cx="388" cy="844" r="1.38" fill="#dbeafe" opacity="0.39"/>
|
||||
<circle cx="485" cy="55" r="1.32" fill="#dbeafe" opacity="0.70"/>
|
||||
<circle cx="582" cy="266" r="1.27" fill="#dbeafe" opacity="0.46"/>
|
||||
<circle cx="679" cy="477" r="1.21" fill="#dbeafe" opacity="0.77"/>
|
||||
<circle cx="776" cy="688" r="1.16" fill="#dbeafe" opacity="0.53"/>
|
||||
<circle cx="873" cy="899" r="1.10" fill="#dbeafe" opacity="0.29"/>
|
||||
<circle cx="970" cy="110" r="1.04" fill="#dbeafe" opacity="0.60"/>
|
||||
<circle cx="1067" cy="321" r="0.99" fill="#dbeafe" opacity="0.36"/>
|
||||
<circle cx="1164" cy="532" r="0.93" fill="#dbeafe" opacity="0.67"/>
|
||||
<circle cx="1261" cy="743" r="0.88" fill="#dbeafe" opacity="0.43"/>
|
||||
<circle cx="1358" cy="954" r="0.82" fill="#dbeafe" opacity="0.74"/>
|
||||
<circle cx="55" cy="165" r="0.77" fill="#dbeafe" opacity="0.50"/>
|
||||
<circle cx="152" cy="376" r="0.71" fill="#dbeafe" opacity="0.26"/>
|
||||
<circle cx="249" cy="587" r="0.66" fill="#dbeafe" opacity="0.57"/>
|
||||
<circle cx="346" cy="798" r="0.60" fill="#dbeafe" opacity="0.33"/>
|
||||
<circle cx="443" cy="9" r="1.54" fill="#dbeafe" opacity="0.64"/>
|
||||
<circle cx="540" cy="220" r="1.49" fill="#dbeafe" opacity="0.40"/>
|
||||
<circle cx="637" cy="431" r="1.43" fill="#dbeafe" opacity="0.71"/>
|
||||
<circle cx="734" cy="642" r="1.38" fill="#dbeafe" opacity="0.47"/>
|
||||
<circle cx="831" cy="853" r="1.32" fill="#dbeafe" opacity="0.78"/>
|
||||
<circle cx="928" cy="64" r="1.27" fill="#dbeafe" opacity="0.54"/>
|
||||
<circle cx="1025" cy="275" r="1.21" fill="#dbeafe" opacity="0.30"/>
|
||||
<circle cx="1122" cy="486" r="1.16" fill="#dbeafe" opacity="0.61"/>
|
||||
<circle cx="1219" cy="697" r="1.10" fill="#dbeafe" opacity="0.37"/>
|
||||
<circle cx="1316" cy="908" r="1.04" fill="#dbeafe" opacity="0.68"/>
|
||||
<circle cx="13" cy="119" r="0.99" fill="#dbeafe" opacity="0.44"/>
|
||||
<circle cx="110" cy="330" r="0.93" fill="#dbeafe" opacity="0.75"/>
|
||||
<circle cx="207" cy="541" r="0.88" fill="#dbeafe" opacity="0.51"/>
|
||||
<circle cx="304" cy="752" r="0.82" fill="#dbeafe" opacity="0.27"/>
|
||||
<circle cx="401" cy="963" r="0.77" fill="#dbeafe" opacity="0.58"/>
|
||||
<circle cx="498" cy="174" r="0.71" fill="#dbeafe" opacity="0.34"/>
|
||||
<circle cx="595" cy="385" r="0.66" fill="#dbeafe" opacity="0.65"/>
|
||||
<circle cx="692" cy="596" r="0.60" fill="#dbeafe" opacity="0.41"/>
|
||||
<circle cx="789" cy="807" r="1.54" fill="#dbeafe" opacity="0.72"/>
|
||||
<circle cx="886" cy="18" r="1.49" fill="#dbeafe" opacity="0.48"/>
|
||||
<circle cx="983" cy="229" r="1.43" fill="#dbeafe" opacity="0.79"/>
|
||||
<circle cx="1080" cy="440" r="1.38" fill="#dbeafe" opacity="0.55"/>
|
||||
<circle cx="1177" cy="651" r="1.32" fill="#dbeafe" opacity="0.31"/>
|
||||
<circle cx="1274" cy="862" r="1.27" fill="#dbeafe" opacity="0.62"/>
|
||||
<circle cx="1371" cy="73" r="1.21" fill="#dbeafe" opacity="0.38"/>
|
||||
<circle cx="68" cy="284" r="1.16" fill="#dbeafe" opacity="0.69"/>
|
||||
<circle cx="165" cy="495" r="1.10" fill="#dbeafe" opacity="0.45"/>
|
||||
<circle cx="262" cy="706" r="1.04" fill="#dbeafe" opacity="0.76"/>
|
||||
<circle cx="359" cy="917" r="0.99" fill="#dbeafe" opacity="0.52"/>
|
||||
<circle cx="456" cy="128" r="0.93" fill="#dbeafe" opacity="0.28"/>
|
||||
<circle cx="553" cy="339" r="0.88" fill="#dbeafe" opacity="0.59"/>
|
||||
<circle cx="650" cy="550" r="0.82" fill="#dbeafe" opacity="0.35"/>
|
||||
<circle cx="747" cy="761" r="0.77" fill="#dbeafe" opacity="0.66"/>
|
||||
<circle cx="844" cy="972" r="0.71" fill="#dbeafe" opacity="0.42"/>
|
||||
<circle cx="941" cy="183" r="0.66" fill="#dbeafe" opacity="0.73"/>
|
||||
<circle cx="1038" cy="394" r="0.60" fill="#dbeafe" opacity="0.49"/>
|
||||
<circle cx="1135" cy="605" r="1.54" fill="#dbeafe" opacity="0.25"/>
|
||||
<circle cx="1232" cy="816" r="1.49" fill="#dbeafe" opacity="0.56"/>
|
||||
<circle cx="1329" cy="27" r="1.43" fill="#dbeafe" opacity="0.32"/>
|
||||
<circle cx="26" cy="238" r="1.38" fill="#dbeafe" opacity="0.63"/>
|
||||
<circle cx="123" cy="449" r="1.32" fill="#dbeafe" opacity="0.39"/>
|
||||
<circle cx="220" cy="660" r="1.27" fill="#dbeafe" opacity="0.70"/>
|
||||
<circle cx="317" cy="871" r="1.21" fill="#dbeafe" opacity="0.46"/>
|
||||
<circle cx="414" cy="82" r="1.16" fill="#dbeafe" opacity="0.77"/>
|
||||
<circle cx="511" cy="293" r="1.10" fill="#dbeafe" opacity="0.53"/>
|
||||
<circle cx="608" cy="504" r="1.04" fill="#dbeafe" opacity="0.29"/>
|
||||
<circle cx="705" cy="715" r="0.99" fill="#dbeafe" opacity="0.60"/>
|
||||
<circle cx="802" cy="926" r="0.93" fill="#dbeafe" opacity="0.36"/>
|
||||
<circle cx="899" cy="137" r="0.88" fill="#dbeafe" opacity="0.67"/>
|
||||
<circle cx="996" cy="348" r="0.82" fill="#dbeafe" opacity="0.43"/>
|
||||
<circle cx="1093" cy="559" r="0.77" fill="#dbeafe" opacity="0.74"/>
|
||||
<circle cx="1190" cy="770" r="0.71" fill="#dbeafe" opacity="0.50"/>
|
||||
<circle cx="1287" cy="981" r="0.66" fill="#dbeafe" opacity="0.26"/>
|
||||
<circle cx="1384" cy="192" r="0.60" fill="#dbeafe" opacity="0.57"/>
|
||||
<circle cx="81" cy="403" r="1.54" fill="#dbeafe" opacity="0.33"/>
|
||||
<circle cx="178" cy="614" r="1.49" fill="#dbeafe" opacity="0.64"/>
|
||||
<circle cx="275" cy="825" r="1.43" fill="#dbeafe" opacity="0.40"/>
|
||||
<circle cx="372" cy="36" r="1.38" fill="#dbeafe" opacity="0.71"/>
|
||||
<circle cx="469" cy="247" r="1.32" fill="#dbeafe" opacity="0.47"/>
|
||||
<circle cx="566" cy="458" r="1.27" fill="#dbeafe" opacity="0.78"/>
|
||||
<circle cx="663" cy="669" r="1.21" fill="#dbeafe" opacity="0.54"/>
|
||||
<circle cx="760" cy="880" r="1.16" fill="#dbeafe" opacity="0.30"/>
|
||||
<circle cx="857" cy="91" r="1.10" fill="#dbeafe" opacity="0.61"/>
|
||||
<circle cx="954" cy="302" r="1.04" fill="#dbeafe" opacity="0.37"/>
|
||||
<circle cx="1051" cy="513" r="0.99" fill="#dbeafe" opacity="0.68"/>
|
||||
<circle cx="1148" cy="724" r="0.93" fill="#dbeafe" opacity="0.44"/>
|
||||
<circle cx="1245" cy="935" r="0.88" fill="#dbeafe" opacity="0.75"/>
|
||||
<circle cx="1342" cy="146" r="0.82" fill="#dbeafe" opacity="0.51"/>
|
||||
<circle cx="39" cy="357" r="0.77" fill="#dbeafe" opacity="0.27"/>
|
||||
<circle cx="136" cy="568" r="0.71" fill="#dbeafe" opacity="0.58"/>
|
||||
<circle cx="233" cy="779" r="0.66" fill="#dbeafe" opacity="0.34"/>
|
||||
<circle cx="330" cy="990" r="0.60" fill="#dbeafe" opacity="0.65"/>
|
||||
<circle cx="427" cy="201" r="1.54" fill="#dbeafe" opacity="0.41"/>
|
||||
<circle cx="524" cy="412" r="1.49" fill="#dbeafe" opacity="0.72"/>
|
||||
<circle cx="621" cy="623" r="1.43" fill="#dbeafe" opacity="0.48"/>
|
||||
<circle cx="718" cy="834" r="1.38" fill="#dbeafe" opacity="0.79"/>
|
||||
<circle cx="815" cy="45" r="1.32" fill="#dbeafe" opacity="0.55"/>
|
||||
<circle cx="912" cy="256" r="1.27" fill="#dbeafe" opacity="0.31"/>
|
||||
<circle cx="1009" cy="467" r="1.21" fill="#dbeafe" opacity="0.62"/>
|
||||
<circle cx="1106" cy="678" r="1.16" fill="#dbeafe" opacity="0.38"/>
|
||||
<circle cx="1203" cy="889" r="1.10" fill="#dbeafe" opacity="0.69"/>
|
||||
<circle cx="1300" cy="100" r="1.04" fill="#dbeafe" opacity="0.45"/>
|
||||
<circle cx="1397" cy="311" r="0.99" fill="#dbeafe" opacity="0.76"/>
|
||||
<circle cx="94" cy="522" r="0.93" fill="#dbeafe" opacity="0.52"/>
|
||||
<circle cx="191" cy="733" r="0.88" fill="#dbeafe" opacity="0.28"/>
|
||||
<circle cx="288" cy="944" r="0.82" fill="#dbeafe" opacity="0.59"/>
|
||||
<circle cx="385" cy="155" r="0.77" fill="#dbeafe" opacity="0.35"/>
|
||||
<circle cx="482" cy="366" r="0.71" fill="#dbeafe" opacity="0.66"/>
|
||||
<circle cx="579" cy="577" r="0.66" fill="#dbeafe" opacity="0.42"/>
|
||||
<circle cx="676" cy="788" r="0.60" fill="#dbeafe" opacity="0.73"/>
|
||||
<circle cx="773" cy="999" r="1.54" fill="#dbeafe" opacity="0.49"/>
|
||||
<circle cx="870" cy="210" r="1.49" fill="#dbeafe" opacity="0.25"/>
|
||||
<circle cx="967" cy="421" r="1.43" fill="#dbeafe" opacity="0.56"/>
|
||||
<circle cx="1064" cy="632" r="1.38" fill="#dbeafe" opacity="0.32"/>
|
||||
<circle cx="1161" cy="843" r="1.32" fill="#dbeafe" opacity="0.63"/>
|
||||
<circle cx="1258" cy="54" r="1.27" fill="#dbeafe" opacity="0.39"/>
|
||||
<circle cx="1355" cy="265" r="1.21" fill="#dbeafe" opacity="0.70"/>
|
||||
<circle cx="52" cy="476" r="1.16" fill="#dbeafe" opacity="0.46"/>
|
||||
<circle cx="149" cy="687" r="1.10" fill="#dbeafe" opacity="0.77"/>
|
||||
<circle cx="246" cy="898" r="1.04" fill="#dbeafe" opacity="0.53"/>
|
||||
<circle cx="343" cy="109" r="0.99" fill="#dbeafe" opacity="0.29"/>
|
||||
<text x="70" y="76" class="title">4 participants - current radial layout</text>
|
||||
<text x="70" y="110" class="sub">Strict small-team preset: top / right / bottom / left around Lead</text>
|
||||
<path d="M 700 500 C 700 500, 700 235, 700 235" class="edge"/>
|
||||
<path d="M 700 500 C 895 500, 895 500, 1090 500" class="edge"/>
|
||||
<path d="M 700 500 C 700 500, 700 765, 700 765" class="edge"/>
|
||||
<path d="M 700 500 C 505 500, 505 500, 310 500" class="edge"/>
|
||||
<rect x="615" y="457" width="170" height="86" rx="16" class="lead"/>
|
||||
<text x="700" y="496" class="label">Lead</text>
|
||||
<text x="700" y="524" class="small">center reserved zone</text>
|
||||
|
||||
<rect x="570" y="160" width="260" height="150" rx="10" class="card"/>
|
||||
<circle cx="700" cy="187" r="20" fill="#38bdf8" opacity=".18" filter="url(#glow)"/>
|
||||
<circle cx="700" cy="187" r="14" fill="#38bdf8"/>
|
||||
<text x="700" y="227" class="label">Participant 1</text>
|
||||
<text x="700" y="255" class="small">top side</text>
|
||||
<rect x="658" y="278" width="84" height="24" rx="6" fill="#38bdf8"/>
|
||||
<text x="700" y="295" class="badge">slot 1</text>
|
||||
|
||||
|
||||
<rect x="960" y="425" width="260" height="150" rx="10" class="card"/>
|
||||
<circle cx="1090" cy="452" r="20" fill="#facc15" opacity=".18" filter="url(#glow)"/>
|
||||
<circle cx="1090" cy="452" r="14" fill="#facc15"/>
|
||||
<text x="1090" y="492" class="label">Participant 2</text>
|
||||
<text x="1090" y="520" class="small">right side</text>
|
||||
<rect x="1048" y="543" width="84" height="24" rx="6" fill="#facc15"/>
|
||||
<text x="1090" y="560" class="badge">slot 2</text>
|
||||
|
||||
|
||||
<rect x="570" y="690" width="260" height="150" rx="10" class="card"/>
|
||||
<circle cx="700" cy="717" r="20" fill="#ef4444" opacity=".18" filter="url(#glow)"/>
|
||||
<circle cx="700" cy="717" r="14" fill="#ef4444"/>
|
||||
<text x="700" y="757" class="label">Participant 3</text>
|
||||
<text x="700" y="785" class="small">bottom side</text>
|
||||
<rect x="658" y="808" width="84" height="24" rx="6" fill="#ef4444"/>
|
||||
<text x="700" y="825" class="badge">slot 3</text>
|
||||
|
||||
|
||||
<rect x="180" y="425" width="260" height="150" rx="10" class="card"/>
|
||||
<circle cx="310" cy="452" r="20" fill="#a78bfa" opacity=".18" filter="url(#glow)"/>
|
||||
<circle cx="310" cy="452" r="14" fill="#a78bfa"/>
|
||||
<text x="310" y="492" class="label">Participant 4</text>
|
||||
<text x="310" y="520" class="small">left side</text>
|
||||
<rect x="268" y="543" width="84" height="24" rx="6" fill="#a78bfa"/>
|
||||
<text x="310" y="560" class="badge">slot 4</text>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 12 KiB |
|
|
@ -1,125 +0,0 @@
|
|||
<svg width="1800" height="1050" viewBox="0 0 1800 1050" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="42%" r="75%">
|
||||
<stop offset="0%" stop-color="#111733"/>
|
||||
<stop offset="55%" stop-color="#090d20"/>
|
||||
<stop offset="100%" stop-color="#050717"/>
|
||||
</radialGradient>
|
||||
<filter id="glow" x="-80%" y="-80%" width="260%" height="260%">
|
||||
<feGaussianBlur stdDeviation="10" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.25 0 0 0 0 0.75 0 0 0 0 1 0 0 0 0.65 0"/>
|
||||
<feBlend in="SourceGraphic"/>
|
||||
</filter>
|
||||
<style>
|
||||
.panel-title { font: 700 28px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #e6f0ff; letter-spacing: 0; }
|
||||
.panel-subtitle { font: 500 15px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #8fa4c6; letter-spacing: 0; }
|
||||
.label { font: 700 13px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #eff6ff; letter-spacing: 0; }
|
||||
.role { font: 500 10px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #8ea0bd; letter-spacing: 0; }
|
||||
.hint { font: 600 12px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #8fa4c6; letter-spacing: 0; }
|
||||
.card { fill: rgba(11, 16, 36, 0.84); stroke: rgba(148, 163, 184, 0.22); stroke-width: 1; }
|
||||
.slot { fill: rgba(59, 130, 246, 0.035); stroke: rgba(125, 211, 252, 0.16); stroke-width: 1; stroke-dasharray: 5 7; }
|
||||
.edge { stroke: rgba(96, 165, 250, 0.18); stroke-width: 2; }
|
||||
.row-guide { stroke: rgba(148, 163, 184, 0.14); stroke-width: 1; stroke-dasharray: 6 10; }
|
||||
.divider { stroke: rgba(148, 163, 184, 0.16); stroke-width: 1; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<rect width="1800" height="1050" fill="url(#bg)"/>
|
||||
<g opacity="0.85">
|
||||
<circle cx="108" cy="102" r="1.5" fill="#94a3b8"/>
|
||||
<circle cx="238" cy="860" r="1.2" fill="#64748b"/>
|
||||
<circle cx="385" cy="145" r="1.3" fill="#94a3b8"/>
|
||||
<circle cx="520" cy="950" r="1.4" fill="#cbd5e1"/>
|
||||
<circle cx="690" cy="372" r="1.1" fill="#94a3b8"/>
|
||||
<circle cx="823" cy="787" r="1.2" fill="#64748b"/>
|
||||
<circle cx="982" cy="210" r="1.1" fill="#94a3b8"/>
|
||||
<circle cx="1112" cy="902" r="1.5" fill="#cbd5e1"/>
|
||||
<circle cx="1286" cy="118" r="1.2" fill="#94a3b8"/>
|
||||
<circle cx="1458" cy="730" r="1.3" fill="#64748b"/>
|
||||
<circle cx="1632" cy="340" r="1.5" fill="#cbd5e1"/>
|
||||
<circle cx="1748" cy="934" r="1.1" fill="#94a3b8"/>
|
||||
</g>
|
||||
|
||||
<line x1="900" y1="70" x2="900" y2="980" class="divider"/>
|
||||
|
||||
<text x="70" y="72" class="panel-title">8 participants</text>
|
||||
<text x="70" y="101" class="panel-subtitle">3 top / 2 at lead level / 3 bottom</text>
|
||||
<text x="970" y="72" class="panel-title">12 participants</text>
|
||||
<text x="970" y="101" class="panel-subtitle">4 top / 2 + lead + 2 middle / 4 bottom</text>
|
||||
|
||||
<g id="eight-layout">
|
||||
<line x1="110" y1="245" x2="790" y2="245" class="row-guide"/>
|
||||
<line x1="110" y1="525" x2="790" y2="525" class="row-guide"/>
|
||||
<line x1="110" y1="805" x2="790" y2="805" class="row-guide"/>
|
||||
<text x="118" y="232" class="hint">top row</text>
|
||||
<text x="118" y="512" class="hint">lead row</text>
|
||||
<text x="118" y="792" class="hint">bottom row</text>
|
||||
|
||||
<path d="M450 525 C370 430 305 330 245 245" class="edge"/>
|
||||
<path d="M450 525 C450 425 450 335 450 245" class="edge"/>
|
||||
<path d="M450 525 C530 430 595 330 655 245" class="edge"/>
|
||||
<path d="M450 525 C360 515 285 515 200 525" class="edge"/>
|
||||
<path d="M450 525 C540 515 615 515 700 525" class="edge"/>
|
||||
<path d="M450 525 C370 620 305 720 245 805" class="edge"/>
|
||||
<path d="M450 525 C450 625 450 715 450 805" class="edge"/>
|
||||
<path d="M450 525 C530 620 595 720 655 805" class="edge"/>
|
||||
|
||||
<g transform="translate(450 525)">
|
||||
<circle r="56" fill="rgba(132, 204, 22, 0.11)" filter="url(#glow)"/>
|
||||
<path d="M0 -35 L31 -17.5 L31 17.5 L0 35 L-31 17.5 L-31 -17.5 Z" fill="#1a2f0d" stroke="#a3e635" stroke-width="2"/>
|
||||
<circle r="17" fill="#84cc16"/>
|
||||
<text x="0" y="66" text-anchor="middle" class="label">lead</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(245 245)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#0ea5e9"/><text y="17" text-anchor="middle" class="label">alice</text><text y="34" text-anchor="middle" class="role">reviewer</text></g>
|
||||
<g transform="translate(450 245)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#22c55e"/><text y="17" text-anchor="middle" class="label">nova</text><text y="34" text-anchor="middle" class="role">developer</text></g>
|
||||
<g transform="translate(655 245)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#eab308"/><text y="17" text-anchor="middle" class="label">tom</text><text y="34" text-anchor="middle" class="role">developer</text></g>
|
||||
<g transform="translate(200 525)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#8b5cf6"/><text y="17" text-anchor="middle" class="label">jack</text><text y="34" text-anchor="middle" class="role">developer</text></g>
|
||||
<g transform="translate(700 525)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#06b6d4"/><text y="17" text-anchor="middle" class="label">atlas</text><text y="34" text-anchor="middle" class="role">assistant</text></g>
|
||||
<g transform="translate(245 805)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#ef4444"/><text y="17" text-anchor="middle" class="label">bob</text><text y="34" text-anchor="middle" class="role">developer</text></g>
|
||||
<g transform="translate(450 805)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#f97316"/><text y="17" text-anchor="middle" class="label">maya</text><text y="34" text-anchor="middle" class="role">qa</text></g>
|
||||
<g transform="translate(655 805)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#14b8a6"/><text y="17" text-anchor="middle" class="label">kai</text><text y="34" text-anchor="middle" class="role">ops</text></g>
|
||||
</g>
|
||||
|
||||
<g id="twelve-layout">
|
||||
<line x1="970" y1="245" x2="1730" y2="245" class="row-guide"/>
|
||||
<line x1="970" y1="525" x2="1730" y2="525" class="row-guide"/>
|
||||
<line x1="970" y1="805" x2="1730" y2="805" class="row-guide"/>
|
||||
<text x="978" y="232" class="hint">top row</text>
|
||||
<text x="978" y="512" class="hint">lead row</text>
|
||||
<text x="978" y="792" class="hint">bottom row</text>
|
||||
|
||||
<path d="M1350 525 C1245 425 1135 330 1030 245" class="edge"/>
|
||||
<path d="M1350 525 C1295 420 1270 335 1243 245" class="edge"/>
|
||||
<path d="M1350 525 C1405 420 1430 335 1457 245" class="edge"/>
|
||||
<path d="M1350 525 C1455 425 1565 330 1670 245" class="edge"/>
|
||||
<path d="M1350 525 C1235 515 1135 515 1030 525" class="edge"/>
|
||||
<path d="M1350 525 C1270 520 1235 520 1210 525" class="edge"/>
|
||||
<path d="M1350 525 C1430 520 1465 520 1490 525" class="edge"/>
|
||||
<path d="M1350 525 C1465 515 1565 515 1670 525" class="edge"/>
|
||||
<path d="M1350 525 C1245 625 1135 720 1030 805" class="edge"/>
|
||||
<path d="M1350 525 C1295 630 1270 715 1243 805" class="edge"/>
|
||||
<path d="M1350 525 C1405 630 1430 715 1457 805" class="edge"/>
|
||||
<path d="M1350 525 C1455 625 1565 720 1670 805" class="edge"/>
|
||||
|
||||
<g transform="translate(1350 525)">
|
||||
<circle r="56" fill="rgba(132, 204, 22, 0.11)" filter="url(#glow)"/>
|
||||
<path d="M0 -35 L31 -17.5 L31 17.5 L0 35 L-31 17.5 L-31 -17.5 Z" fill="#1a2f0d" stroke="#a3e635" stroke-width="2"/>
|
||||
<circle r="17" fill="#84cc16"/>
|
||||
<text x="0" y="66" text-anchor="middle" class="label">lead</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(1030 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#0ea5e9"/><text y="17" text-anchor="middle" class="label">alice</text></g>
|
||||
<g transform="translate(1243 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#22c55e"/><text y="17" text-anchor="middle" class="label">nova</text></g>
|
||||
<g transform="translate(1457 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#eab308"/><text y="17" text-anchor="middle" class="label">tom</text></g>
|
||||
<g transform="translate(1670 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#8b5cf6"/><text y="17" text-anchor="middle" class="label">jack</text></g>
|
||||
<g transform="translate(1030 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#06b6d4"/><text y="17" text-anchor="middle" class="label">atlas</text></g>
|
||||
<g transform="translate(1210 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#ef4444"/><text y="17" text-anchor="middle" class="label">bob</text></g>
|
||||
<g transform="translate(1490 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#f97316"/><text y="17" text-anchor="middle" class="label">maya</text></g>
|
||||
<g transform="translate(1670 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#14b8a6"/><text y="17" text-anchor="middle" class="label">kai</text></g>
|
||||
<g transform="translate(1030 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#a855f7"/><text y="17" text-anchor="middle" class="label">ivy</text></g>
|
||||
<g transform="translate(1243 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#f43f5e"/><text y="17" text-anchor="middle" class="label">rex</text></g>
|
||||
<g transform="translate(1457 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#38bdf8"/><text y="17" text-anchor="middle" class="label">zoe</text></g>
|
||||
<g transform="translate(1670 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#84cc16"/><text y="17" text-anchor="middle" class="label">sam</text></g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 11 KiB |
BIN
docs/screenshots/cover-frame.png
Normal file
BIN
docs/screenshots/cover-frame.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2 MiB |
BIN
docs/screenshots/screenshots-animated.webp
Normal file
BIN
docs/screenshots/screenshots-animated.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
|
|
@ -21,6 +21,7 @@
|
|||
},
|
||||
"main": "dist-electron/main/index.cjs",
|
||||
"scripts": {
|
||||
"preinstall": "node ./scripts/ci/enforce-pnpm-install.mjs",
|
||||
"dev": "node ./scripts/dev-with-runtime.mjs",
|
||||
"dev:mcp": "node ./scripts/dev-with-runtime.mjs --remoteDebuggingPort 9222",
|
||||
"dev:kill": "node bin/kill-dev.js",
|
||||
|
|
@ -453,6 +454,8 @@
|
|||
"@radix-ui/react-dismissable-layer@1.1.11": "patches/@radix-ui__react-dismissable-layer@1.1.11.patch",
|
||||
"@radix-ui/react-popper@1.2.8": "patches/@radix-ui__react-popper@1.2.8.patch",
|
||||
"@radix-ui/react-select@2.2.6": "patches/@radix-ui__react-select@2.2.6.patch",
|
||||
"@radix-ui/react-slot@1.2.3": "patches/@radix-ui__react-slot@1.2.3.patch",
|
||||
"@radix-ui/react-slot@1.2.4": "patches/@radix-ui__react-slot@1.2.4.patch",
|
||||
"@radix-ui/react-tooltip@1.2.8": "patches/@radix-ui__react-tooltip@1.2.8.patch",
|
||||
"@radix-ui/react-menu@2.1.16": "patches/@radix-ui__react-menu@2.1.16.patch",
|
||||
"@radix-ui/react-checkbox@1.3.3": "patches/@radix-ui__react-checkbox@1.3.3.patch"
|
||||
|
|
|
|||
|
|
@ -126,17 +126,51 @@ index dc37ac4a018a086c4244a09a67215dbaa9b4de65..fc80522666f91087ce1bce3a34844b17
|
|||
style: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -971,9 +1002,10 @@ var SelectItemText = React.forwardRef(
|
||||
@@ -864,10 +895,15 @@ var SelectItem = React.forwardRef(
|
||||
const contentContext = useSelectContentContext(ITEM_NAME, __scopeSelect);
|
||||
const isSelected = context.value === value;
|
||||
const [textValue, setTextValue] = React.useState(textValueProp ?? "");
|
||||
+ const textValueRef = React.useRef(textValueProp ?? "");
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
+ const itemRefCallback = React.useCallback(
|
||||
+ (node) => contentContext.itemRefCallback?.(node, value, disabled),
|
||||
+ [contentContext.itemRefCallback, value, disabled]
|
||||
+ );
|
||||
const composedRefs = (0, import_react_compose_refs.useComposedRefs)(
|
||||
forwardedRef,
|
||||
- (node) => contentContext.itemRefCallback?.(node, value, disabled)
|
||||
+ itemRefCallback
|
||||
);
|
||||
const textId = (0, import_react_id.useId)();
|
||||
const pointerTypeRef = React.useRef("touch");
|
||||
@@ -893,7 +931,10 @@ var SelectItem = React.forwardRef(
|
||||
textId,
|
||||
isSelected,
|
||||
onItemTextChange: React.useCallback((node) => {
|
||||
- setTextValue((prevTextValue) => prevTextValue || (node?.textContent ?? "").trim());
|
||||
+ const nextTextValue = (node?.textContent ?? "").trim();
|
||||
+ if (!nextTextValue || textValueRef.current) return;
|
||||
+ textValueRef.current = nextTextValue;
|
||||
+ setTextValue(nextTextValue);
|
||||
}, []),
|
||||
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
||||
Collection.ItemSlot,
|
||||
@@ -971,9 +1013,14 @@ var SelectItemText = React.forwardRef(
|
||||
const itemContext = useSelectItemContext(ITEM_TEXT_NAME, __scopeSelect);
|
||||
const nativeOptionsContext = useSelectNativeOptionsContext(ITEM_TEXT_NAME, __scopeSelect);
|
||||
const [itemTextNode, setItemTextNode] = React.useState(null);
|
||||
+ const setItemTextNodeRef = useGuardedNodeSetter(setItemTextNode);
|
||||
+ const itemTextRefCallback = React.useCallback(
|
||||
+ (node) => contentContext.itemTextRefCallback?.(node, itemContext.value, itemContext.disabled),
|
||||
+ [contentContext.itemTextRefCallback, itemContext.value, itemContext.disabled]
|
||||
+ );
|
||||
const composedRefs = (0, import_react_compose_refs.useComposedRefs)(
|
||||
forwardedRef,
|
||||
- (node) => setItemTextNode(node),
|
||||
+ setItemTextNodeRef,
|
||||
itemContext.onItemTextChange,
|
||||
(node) => contentContext.itemTextRefCallback?.(node, itemContext.value, itemContext.disabled)
|
||||
- (node) => contentContext.itemTextRefCallback?.(node, itemContext.value, itemContext.disabled)
|
||||
+ itemTextRefCallback
|
||||
);
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index f9b94f39dfddef678ef3086354f8d7413ae27e52..4a53ec0d65aa051b95e3e8ea4aff4fdd52a17406 100644
|
||||
|
|
@ -266,15 +300,49 @@ index f9b94f39dfddef678ef3086354f8d7413ae27e52..4a53ec0d65aa051b95e3e8ea4aff4fdd
|
|||
style: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -904,9 +935,10 @@ var SelectItemText = React.forwardRef(
|
||||
@@ -797,10 +828,15 @@ var SelectItem = React.forwardRef(
|
||||
const contentContext = useSelectContentContext(ITEM_NAME, __scopeSelect);
|
||||
const isSelected = context.value === value;
|
||||
const [textValue, setTextValue] = React.useState(textValueProp ?? "");
|
||||
+ const textValueRef = React.useRef(textValueProp ?? "");
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
+ const itemRefCallback = React.useCallback(
|
||||
+ (node) => contentContext.itemRefCallback?.(node, value, disabled),
|
||||
+ [contentContext.itemRefCallback, value, disabled]
|
||||
+ );
|
||||
const composedRefs = useComposedRefs(
|
||||
forwardedRef,
|
||||
- (node) => contentContext.itemRefCallback?.(node, value, disabled)
|
||||
+ itemRefCallback
|
||||
);
|
||||
const textId = useId();
|
||||
const pointerTypeRef = React.useRef("touch");
|
||||
@@ -826,7 +864,10 @@ var SelectItem = React.forwardRef(
|
||||
textId,
|
||||
isSelected,
|
||||
onItemTextChange: React.useCallback((node) => {
|
||||
- setTextValue((prevTextValue) => prevTextValue || (node?.textContent ?? "").trim());
|
||||
+ const nextTextValue = (node?.textContent ?? "").trim();
|
||||
+ if (!nextTextValue || textValueRef.current) return;
|
||||
+ textValueRef.current = nextTextValue;
|
||||
+ setTextValue(nextTextValue);
|
||||
}, []),
|
||||
children: /* @__PURE__ */ jsx(
|
||||
Collection.ItemSlot,
|
||||
@@ -904,9 +946,14 @@ var SelectItemText = React.forwardRef(
|
||||
const itemContext = useSelectItemContext(ITEM_TEXT_NAME, __scopeSelect);
|
||||
const nativeOptionsContext = useSelectNativeOptionsContext(ITEM_TEXT_NAME, __scopeSelect);
|
||||
const [itemTextNode, setItemTextNode] = React.useState(null);
|
||||
+ const setItemTextNodeRef = useGuardedNodeSetter(setItemTextNode);
|
||||
+ const itemTextRefCallback = React.useCallback(
|
||||
+ (node) => contentContext.itemTextRefCallback?.(node, itemContext.value, itemContext.disabled),
|
||||
+ [contentContext.itemTextRefCallback, itemContext.value, itemContext.disabled]
|
||||
+ );
|
||||
const composedRefs = useComposedRefs(
|
||||
forwardedRef,
|
||||
- (node) => setItemTextNode(node),
|
||||
+ setItemTextNodeRef,
|
||||
itemContext.onItemTextChange,
|
||||
(node) => contentContext.itemTextRefCallback?.(node, itemContext.value, itemContext.disabled)
|
||||
- (node) => contentContext.itemTextRefCallback?.(node, itemContext.value, itemContext.disabled)
|
||||
+ itemTextRefCallback
|
||||
);
|
||||
|
|
|
|||
44
patches/@radix-ui__react-slot@1.2.3.patch
Normal file
44
patches/@radix-ui__react-slot@1.2.3.patch
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 6a29e17b2246048ddb3da22732e2c81517bf81da..cecc799949dbbe10ba98d587fdad5d3a9e1d8cf1 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -70,11 +70,15 @@ var Slot = /* @__PURE__ */ createSlot("Slot");
|
||||
function createSlotClone(ownerName) {
|
||||
const SlotClone = React.forwardRef((props, forwardedRef) => {
|
||||
const { children, ...slotProps } = props;
|
||||
+ const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
|
||||
+ const composedRef = React.useMemo(
|
||||
+ () => forwardedRef && childrenRef ? (0, import_react_compose_refs.composeRefs)(forwardedRef, childrenRef) : forwardedRef || childrenRef || null,
|
||||
+ [forwardedRef, childrenRef]
|
||||
+ );
|
||||
if (React.isValidElement(children)) {
|
||||
- const childrenRef = getElementRef(children);
|
||||
const props2 = mergeProps(slotProps, children.props);
|
||||
if (children.type !== React.Fragment) {
|
||||
- props2.ref = forwardedRef ? (0, import_react_compose_refs.composeRefs)(forwardedRef, childrenRef) : childrenRef;
|
||||
+ props2.ref = composedRef;
|
||||
}
|
||||
return React.cloneElement(children, props2);
|
||||
}
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 0d5bcb5df0a670f429c157c5354c7b0499e9a599..4b4cbbf55bdfb4ee3f2a91aa02ff1ffef51d98cf 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -30,11 +30,15 @@ var Slot = /* @__PURE__ */ createSlot("Slot");
|
||||
function createSlotClone(ownerName) {
|
||||
const SlotClone = React.forwardRef((props, forwardedRef) => {
|
||||
const { children, ...slotProps } = props;
|
||||
+ const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
|
||||
+ const composedRef = React.useMemo(
|
||||
+ () => forwardedRef && childrenRef ? composeRefs(forwardedRef, childrenRef) : forwardedRef || childrenRef || null,
|
||||
+ [forwardedRef, childrenRef]
|
||||
+ );
|
||||
if (React.isValidElement(children)) {
|
||||
- const childrenRef = getElementRef(children);
|
||||
const props2 = mergeProps(slotProps, children.props);
|
||||
if (children.type !== React.Fragment) {
|
||||
- props2.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;
|
||||
+ props2.ref = composedRef;
|
||||
}
|
||||
return React.cloneElement(children, props2);
|
||||
}
|
||||
48
patches/@radix-ui__react-slot@1.2.4.patch
Normal file
48
patches/@radix-ui__react-slot@1.2.4.patch
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 997ad803d345479c6afedb38fbfa4fed36dbb69f..e3845eab81088ac07f86ce0758f8fbb51bf20f7e 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -87,13 +87,17 @@ function createSlotClone(ownerName) {
|
||||
if (isLazyComponent(children) && typeof use === "function") {
|
||||
children = use(children._payload);
|
||||
}
|
||||
+ const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
|
||||
+ const composedRef = React.useMemo(
|
||||
+ () => forwardedRef && childrenRef ? (0, import_react_compose_refs.composeRefs)(forwardedRef, childrenRef) : forwardedRef || childrenRef || null,
|
||||
+ [forwardedRef, childrenRef]
|
||||
+ );
|
||||
if (React.isValidElement(children)) {
|
||||
- const childrenRef = getElementRef(children);
|
||||
const props2 = mergeProps(slotProps, children.props);
|
||||
if (children.type !== React.Fragment) {
|
||||
- props2.ref = forwardedRef ? (0, import_react_compose_refs.composeRefs)(forwardedRef, childrenRef) : childrenRef;
|
||||
+ props2.ref = composedRef;
|
||||
}
|
||||
return React.cloneElement(children, props2);
|
||||
}
|
||||
return React.Children.count(children) > 1 ? React.Children.only(null) : null;
|
||||
});
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 6365c8003f76c8a41351ddc65a4e84ad8b6bf0d1..3e6146372c27ae6dc1a628264e49910b9f41f3f7 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -46,13 +46,17 @@ function createSlotClone(ownerName) {
|
||||
if (isLazyComponent(children) && typeof use === "function") {
|
||||
children = use(children._payload);
|
||||
}
|
||||
+ const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
|
||||
+ const composedRef = React.useMemo(
|
||||
+ () => forwardedRef && childrenRef ? composeRefs(forwardedRef, childrenRef) : forwardedRef || childrenRef || null,
|
||||
+ [forwardedRef, childrenRef]
|
||||
+ );
|
||||
if (React.isValidElement(children)) {
|
||||
- const childrenRef = getElementRef(children);
|
||||
const props2 = mergeProps(slotProps, children.props);
|
||||
if (children.type !== React.Fragment) {
|
||||
- props2.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;
|
||||
+ props2.ref = composedRef;
|
||||
}
|
||||
return React.cloneElement(children, props2);
|
||||
}
|
||||
return React.Children.count(children) > 1 ? React.Children.only(null) : null;
|
||||
});
|
||||
|
|
@ -68,8 +68,14 @@ patchedDependencies:
|
|||
hash: afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e
|
||||
path: patches/@radix-ui__react-presence@1.1.5.patch
|
||||
'@radix-ui/react-select@2.2.6':
|
||||
hash: eea50c1407feb65af64720d0aadd2b534ca7743474d9204e0fc1d6b93e5f31f4
|
||||
hash: 93cdd02c858fd8e3669eb902abbbd15644c2581c4c33aa63862fa351fefb4231
|
||||
path: patches/@radix-ui__react-select@2.2.6.patch
|
||||
'@radix-ui/react-slot@1.2.3':
|
||||
hash: cc153761e59f07565f64cf0883bb098c78bb27bed60b7363eaadb74ad4317504
|
||||
path: patches/@radix-ui__react-slot@1.2.3.patch
|
||||
'@radix-ui/react-slot@1.2.4':
|
||||
hash: 5c525e90054caa3bbfa5599b10f7650914e2085f54b773bd115ad0e41eeca30a
|
||||
path: patches/@radix-ui__react-slot@1.2.4.patch
|
||||
'@radix-ui/react-tooltip@1.2.8':
|
||||
hash: 92cb648a695f616d3b7222b90053cb36e162bab4303abf0fe39b517e1d9dd6b8
|
||||
path: patches/@radix-ui__react-tooltip@1.2.8.patch
|
||||
|
|
@ -209,10 +215,10 @@ importers:
|
|||
version: 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.2.6
|
||||
version: 2.2.6(patch_hash=eea50c1407feb65af64720d0aadd2b534ca7743474d9204e0fc1d6b93e5f31f4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
version: 2.2.6(patch_hash=93cdd02c858fd8e3669eb902abbbd15644c2581c4c33aa63862fa351fefb4231)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4(@types/react@19.2.14)(react@19.2.4)
|
||||
version: 1.2.4(patch_hash=5c525e90054caa3bbfa5599b10f7650914e2085f54b773bd115ad0e41eeca30a)(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.13
|
||||
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -14462,7 +14468,7 @@ snapshots:
|
|||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(patch_hash=cc153761e59f07565f64cf0883bb098c78bb27bed60b7363eaadb74ad4317504)(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
|
|
@ -14515,7 +14521,7 @@ snapshots:
|
|||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(patch_hash=cc153761e59f07565f64cf0883bb098c78bb27bed60b7363eaadb74ad4317504)(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
|
|
@ -14560,7 +14566,7 @@ snapshots:
|
|||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(patch_hash=cc153761e59f07565f64cf0883bb098c78bb27bed60b7363eaadb74ad4317504)(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.2.4
|
||||
|
|
@ -14670,7 +14676,7 @@ snapshots:
|
|||
'@radix-ui/react-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(patch_hash=cc153761e59f07565f64cf0883bb098c78bb27bed60b7363eaadb74ad4317504)(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.2.4
|
||||
|
|
@ -14693,7 +14699,7 @@ snapshots:
|
|||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(patch_hash=cc153761e59f07565f64cf0883bb098c78bb27bed60b7363eaadb74ad4317504)(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.2.4
|
||||
|
|
@ -14743,7 +14749,7 @@ snapshots:
|
|||
|
||||
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(patch_hash=cc153761e59f07565f64cf0883bb098c78bb27bed60b7363eaadb74ad4317504)(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
|
|
@ -14752,7 +14758,7 @@ snapshots:
|
|||
|
||||
'@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.4(patch_hash=5c525e90054caa3bbfa5599b10f7650914e2085f54b773bd115ad0e41eeca30a)(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
|
|
@ -14776,7 +14782,7 @@ snapshots:
|
|||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-select@2.2.6(patch_hash=eea50c1407feb65af64720d0aadd2b534ca7743474d9204e0fc1d6b93e5f31f4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
'@radix-ui/react-select@2.2.6(patch_hash=93cdd02c858fd8e3669eb902abbbd15644c2581c4c33aa63862fa351fefb4231)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
|
|
@ -14791,7 +14797,7 @@ snapshots:
|
|||
'@radix-ui/react-popper': 1.2.8(patch_hash=bab709aa37cbdb3023036cd85534ddb1400f2fccfe54bd6321fe405d5d199404)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(patch_hash=cc153761e59f07565f64cf0883bb098c78bb27bed60b7363eaadb74ad4317504)(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
|
|
@ -14805,14 +14811,14 @@ snapshots:
|
|||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)':
|
||||
'@radix-ui/react-slot@1.2.3(patch_hash=cc153761e59f07565f64cf0883bb098c78bb27bed60b7363eaadb74ad4317504)(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.4)':
|
||||
'@radix-ui/react-slot@1.2.4(patch_hash=5c525e90054caa3bbfa5599b10f7650914e2085f54b773bd115ad0e41eeca30a)(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
|
|
@ -14846,7 +14852,7 @@ snapshots:
|
|||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(patch_hash=cc153761e59f07565f64cf0883bb098c78bb27bed60b7363eaadb74ad4317504)(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
|
|
|
|||
13
scripts/ci/enforce-pnpm-install.mjs
Normal file
13
scripts/ci/enforce-pnpm-install.mjs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
const userAgent = process.env.npm_config_user_agent ?? '';
|
||||
|
||||
if (userAgent.startsWith('pnpm/')) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(
|
||||
[
|
||||
'Use pnpm install for this project.',
|
||||
'npm and yarn do not apply pnpm patchedDependencies, including the Radix React 19 patches.',
|
||||
].join('\n')
|
||||
);
|
||||
process.exit(1);
|
||||
|
|
@ -25,10 +25,19 @@ const patchChecks = [
|
|||
},
|
||||
{
|
||||
packageName: '@radix-ui/react-select',
|
||||
requiredMarkers: ['useGuardedNodeSetter', 'setContentRef', 'setItemTextNodeRef'],
|
||||
requiredMarkers: [
|
||||
'useGuardedNodeSetter',
|
||||
'setContentRef',
|
||||
'setItemTextNodeRef',
|
||||
'textValueRef',
|
||||
'nextTextValue',
|
||||
],
|
||||
forbiddenSnippets: [
|
||||
'(node) => setContent(node)',
|
||||
'(node) => setItemTextNode(node)',
|
||||
'forwardedRef,\n (node) => contentContext.itemRefCallback?.(node, value, disabled)',
|
||||
'itemContext.onItemTextChange,\n (node) => contentContext.itemTextRefCallback?.(node, itemContext.value, itemContext.disabled)',
|
||||
'setTextValue((prevTextValue) => prevTextValue || (node?.textContent ?? "").trim());',
|
||||
'onTriggerChange: setTrigger,',
|
||||
'onValueNodeChange: setValueNode,',
|
||||
'onViewportChange: setViewport,',
|
||||
|
|
@ -37,6 +46,23 @@ const patchChecks = [
|
|||
'setSelectedItemText(node);',
|
||||
],
|
||||
},
|
||||
{
|
||||
packageName: '@radix-ui/react-slot',
|
||||
requiredMarkers: ['composedRef', 'React.useMemo'],
|
||||
forbiddenSnippets: [
|
||||
'props2.ref = forwardedRef ? (0, import_react_compose_refs.composeRefs)(forwardedRef, childrenRef) : childrenRef;',
|
||||
'props2.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;',
|
||||
],
|
||||
},
|
||||
{
|
||||
packageName: '@radix-ui/react-slot',
|
||||
resolverFromPackage: '@radix-ui/react-select',
|
||||
requiredMarkers: ['composedRef', 'React.useMemo'],
|
||||
forbiddenSnippets: [
|
||||
'props2.ref = forwardedRef ? (0, import_react_compose_refs.composeRefs)(forwardedRef, childrenRef) : childrenRef;',
|
||||
'props2.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;',
|
||||
],
|
||||
},
|
||||
{
|
||||
packageName: '@radix-ui/react-popper',
|
||||
resolverFromPackage: '@radix-ui/react-select',
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ const requiredMarkers = [
|
|||
'setSelectedItemRef',
|
||||
'setSelectedItemTextRef',
|
||||
'setItemTextNodeRef',
|
||||
'textValueRef',
|
||||
'nextTextValue',
|
||||
'composedRef',
|
||||
'setControlRef',
|
||||
'setBubbleInputRef',
|
||||
];
|
||||
|
|
@ -35,6 +38,12 @@ const forbiddenSnippets = [
|
|||
'(node) => setContent(node)',
|
||||
'(node2) => setNode(node2)',
|
||||
'(node) => setItemTextNode(node)',
|
||||
'forwardedRef,\n (node) => contentContext.itemRefCallback?.(node, value, disabled)',
|
||||
'forwardedRef,\n (node2) => contentContext.itemRefCallback?.(node2, value, disabled)',
|
||||
'itemContext.onItemTextChange,\n (node) => contentContext.itemTextRefCallback?.(node, itemContext.value, itemContext.disabled)',
|
||||
'itemContext.onItemTextChange,\n (node2) => contentContext.itemTextRefCallback?.(node2, itemContext.value, itemContext.disabled)',
|
||||
'setTextValue((prevTextValue) => prevTextValue || (node?.textContent ?? "").trim());',
|
||||
'setTextValue((prevTextValue) => prevTextValue || (node2?.textContent ?? "").trim());',
|
||||
'onContentChange: setContent,',
|
||||
'onTriggerChange: setTrigger,',
|
||||
'onValueNodeChange: setValueNode,',
|
||||
|
|
@ -46,6 +55,7 @@ const forbiddenSnippets = [
|
|||
'useComposedRefs(forwardedRef, setBubbleInput)',
|
||||
'useComposedRefs)(forwardedRef, setControl)',
|
||||
'useComposedRefs)(forwardedRef, setBubbleInput)',
|
||||
'props2.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;',
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
|
|
|
|||
|
|
@ -168,6 +168,11 @@ export function mergeCodexProviderStatusWithSnapshot(
|
|||
}
|
||||
|
||||
const availableBackends = mergeCodexNativeBackendOption(provider, snapshot);
|
||||
const customProvider = provider.connection?.codex?.customProvider ?? null;
|
||||
const endpointLabel =
|
||||
customProvider?.active === true && customProvider.baseUrl.trim()
|
||||
? customProvider.baseUrl.trim()
|
||||
: 'codex exec --json';
|
||||
const baseConnection = provider.connection ?? {
|
||||
supportsOAuth: false,
|
||||
supportsApiKey: true,
|
||||
|
|
@ -203,7 +208,7 @@ export function mergeCodexProviderStatusWithSnapshot(
|
|||
backend: {
|
||||
kind: CODEX_NATIVE_BACKEND_ID,
|
||||
label: CODEX_NATIVE_LABEL,
|
||||
endpointLabel: 'codex exec --json',
|
||||
endpointLabel,
|
||||
projectId: provider.backend?.projectId ?? null,
|
||||
authMethodDetail: snapshot.effectiveAuthMode ?? null,
|
||||
},
|
||||
|
|
@ -227,6 +232,13 @@ export function mergeCodexProviderStatusWithSnapshot(
|
|||
localActiveChatgptAccountPresent: snapshot.localActiveChatgptAccountPresent,
|
||||
login: snapshot.login,
|
||||
rateLimits: snapshot.rateLimits,
|
||||
customProvider: customProvider ?? {
|
||||
enabled: false,
|
||||
active: false,
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
issueMessage: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { decideMemberWorkSyncStatus } from '../domain';
|
||||
|
||||
import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler';
|
||||
import { resolveMemberWorkSyncRuntimeActivity } from './MemberWorkSyncRuntimeActivity';
|
||||
|
||||
import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts';
|
||||
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
|
@ -17,13 +18,14 @@ export class MemberWorkSyncDiagnosticsReader {
|
|||
const source = await this.deps.agendaSource.loadAgenda(request);
|
||||
const agenda = finalizeMemberWorkSyncAgenda(this.deps, source);
|
||||
const nowIso = this.deps.clock.now().toISOString();
|
||||
const teamActive = this.deps.lifecycle
|
||||
? await this.deps.lifecycle.isTeamActive(agenda.teamName)
|
||||
: true;
|
||||
const runtimeActivity = await resolveMemberWorkSyncRuntimeActivity(this.deps, {
|
||||
teamName: agenda.teamName,
|
||||
memberName: agenda.memberName,
|
||||
});
|
||||
const decision = decideMemberWorkSyncStatus({
|
||||
agenda,
|
||||
nowIso,
|
||||
inactive: source.inactive || !teamActive,
|
||||
inactive: source.inactive || runtimeActivity.inactive,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -39,7 +41,7 @@ export class MemberWorkSyncDiagnosticsReader {
|
|||
evaluatedAt: nowIso,
|
||||
diagnostics: [
|
||||
...agenda.diagnostics,
|
||||
...(!teamActive ? ['team_runtime_inactive'] : []),
|
||||
...runtimeActivity.diagnostics,
|
||||
...decision.diagnostics,
|
||||
'status_snapshot_not_persisted',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { decideMemberWorkSyncStatus } from '../domain';
|
|||
import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit';
|
||||
import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy';
|
||||
import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler';
|
||||
import { resolveMemberWorkSyncRuntimeActivity } from './MemberWorkSyncRuntimeActivity';
|
||||
|
||||
import type {
|
||||
MemberWorkSyncAgenda,
|
||||
|
|
@ -14,6 +15,9 @@ import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './
|
|||
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
|
||||
const MEMBER_WORK_SYNC_RETRY_BASE_MINUTES = 10;
|
||||
const MEMBER_WORK_SYNC_RETRY_MAX_MINUTES = 60;
|
||||
const MEMBER_WORK_SYNC_NUDGE_DISPATCH_ITEM_TIMEOUT_MS = 2 * 60_000;
|
||||
const MEMBER_WORK_SYNC_NUDGE_DISPATCH_TEAM_TIMEOUT_MS = 2 * 60_000;
|
||||
const MEMBER_WORK_SYNC_NUDGE_CLAIM_TIMEOUT_MS = 30_000;
|
||||
const AGENDA_SYNC_STILL_STUCK_RECOVERY_INTENT_PREFIX = 'agenda-sync-still-stuck:';
|
||||
|
||||
export interface MemberWorkSyncNudgeDispatchSummary {
|
||||
|
|
@ -28,12 +32,32 @@ export interface MemberWorkSyncNudgeDispatchOptions {
|
|||
claimedBy: string;
|
||||
teamNames: string[];
|
||||
limit?: number;
|
||||
itemTimeoutMs?: number;
|
||||
teamTimeoutMs?: number;
|
||||
claimTimeoutMs?: number;
|
||||
}
|
||||
|
||||
function emptySummary(): MemberWorkSyncNudgeDispatchSummary {
|
||||
return { claimed: 0, delivered: 0, superseded: 0, retryable: 0, terminal: 0 };
|
||||
}
|
||||
|
||||
function addSummary(
|
||||
left: MemberWorkSyncNudgeDispatchSummary,
|
||||
right: MemberWorkSyncNudgeDispatchSummary
|
||||
): MemberWorkSyncNudgeDispatchSummary {
|
||||
return {
|
||||
claimed: left.claimed + right.claimed,
|
||||
delivered: left.delivered + right.delivered,
|
||||
superseded: left.superseded + right.superseded,
|
||||
retryable: left.retryable + right.retryable,
|
||||
terminal: left.terminal + right.terminal,
|
||||
};
|
||||
}
|
||||
|
||||
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
function addMinutes(iso: string, minutes: number): string {
|
||||
return new Date(Date.parse(iso) + minutes * 60_000).toISOString();
|
||||
}
|
||||
|
|
@ -116,6 +140,22 @@ function reviewPickupRequestIdsStillMatch(
|
|||
return payloadIds.length > 0 && payloadIds.every((id) => agendaIds.includes(id));
|
||||
}
|
||||
|
||||
interface MemberWorkSyncNudgeDispatchRun {
|
||||
cancelled: boolean;
|
||||
parent?: MemberWorkSyncNudgeDispatchRun;
|
||||
}
|
||||
|
||||
function isDispatchRunCancelled(run?: MemberWorkSyncNudgeDispatchRun): boolean {
|
||||
let current: MemberWorkSyncNudgeDispatchRun | undefined = run;
|
||||
while (current) {
|
||||
if (current.cancelled) {
|
||||
return true;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export class MemberWorkSyncNudgeDispatcher {
|
||||
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
|
||||
|
||||
|
|
@ -129,28 +169,275 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
}
|
||||
|
||||
const nowIso = this.deps.clock.now().toISOString();
|
||||
const summary = emptySummary();
|
||||
for (const teamName of [
|
||||
...new Set(options.teamNames.map((name) => name.trim()).filter(Boolean)),
|
||||
]) {
|
||||
const claimed = await outbox.claimDue({
|
||||
teamName,
|
||||
claimedBy: options.claimedBy,
|
||||
nowIso,
|
||||
limit: options.limit ?? 10,
|
||||
});
|
||||
summary.claimed += claimed.length;
|
||||
for (const item of claimed) {
|
||||
const result = await this.dispatchItem(item, nowIso);
|
||||
summary[result] += 1;
|
||||
const itemTimeoutMs = Math.max(
|
||||
1,
|
||||
options.itemTimeoutMs ?? MEMBER_WORK_SYNC_NUDGE_DISPATCH_ITEM_TIMEOUT_MS
|
||||
);
|
||||
const teamTimeoutMs = Math.max(
|
||||
1,
|
||||
options.teamTimeoutMs ?? MEMBER_WORK_SYNC_NUDGE_DISPATCH_TEAM_TIMEOUT_MS
|
||||
);
|
||||
const claimTimeoutMs = Math.max(
|
||||
1,
|
||||
options.claimTimeoutMs ?? MEMBER_WORK_SYNC_NUDGE_CLAIM_TIMEOUT_MS
|
||||
);
|
||||
const teamNames = [...new Set(options.teamNames.map((name) => name.trim()).filter(Boolean))];
|
||||
const summaries = await Promise.allSettled(
|
||||
teamNames.map((teamName) =>
|
||||
this.dispatchTeamWithTimeout(teamName, options, nowIso, {
|
||||
itemTimeoutMs,
|
||||
teamTimeoutMs,
|
||||
claimTimeoutMs,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
let summary = emptySummary();
|
||||
for (const [index, result] of summaries.entries()) {
|
||||
if (result.status === 'fulfilled') {
|
||||
summary = addSummary(summary, result.value);
|
||||
} else {
|
||||
this.deps.logger?.warn('member work sync team nudge dispatch failed', {
|
||||
teamName: teamNames[index],
|
||||
error: String(result.reason),
|
||||
});
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
private async dispatchTeamWithTimeout(
|
||||
teamName: string,
|
||||
options: MemberWorkSyncNudgeDispatchOptions,
|
||||
nowIso: string,
|
||||
timeouts: { itemTimeoutMs: number; teamTimeoutMs: number; claimTimeoutMs: number }
|
||||
): Promise<MemberWorkSyncNudgeDispatchSummary> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const run: MemberWorkSyncNudgeDispatchRun = { cancelled: false };
|
||||
const work = this.dispatchTeam(teamName, options, nowIso, timeouts, run);
|
||||
void work.catch(() => undefined);
|
||||
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
work,
|
||||
new Promise<'timeout'>((resolve) => {
|
||||
timeout = setTimeout(() => {
|
||||
run.cancelled = true;
|
||||
resolve('timeout');
|
||||
}, timeouts.teamTimeoutMs);
|
||||
unrefTimer(timeout);
|
||||
}),
|
||||
]);
|
||||
if (result !== 'timeout') {
|
||||
return result;
|
||||
}
|
||||
this.deps.logger?.warn('member work sync team nudge dispatch timed out', {
|
||||
teamName,
|
||||
timeoutMs: timeouts.teamTimeoutMs,
|
||||
});
|
||||
return emptySummary();
|
||||
} finally {
|
||||
run.cancelled = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatchTeam(
|
||||
teamName: string,
|
||||
options: MemberWorkSyncNudgeDispatchOptions,
|
||||
nowIso: string,
|
||||
timeouts: { itemTimeoutMs: number; claimTimeoutMs: number },
|
||||
run: MemberWorkSyncNudgeDispatchRun
|
||||
): Promise<MemberWorkSyncNudgeDispatchSummary> {
|
||||
const summary = emptySummary();
|
||||
const claimed = await this.claimDueWithTimeout(teamName, options, nowIso, timeouts, run);
|
||||
if (!claimed || isDispatchRunCancelled(run)) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
summary.claimed += claimed.length;
|
||||
for (const item of claimed) {
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
break;
|
||||
}
|
||||
const result = await this.dispatchItemWithTimeout(item, nowIso, timeouts.itemTimeoutMs, run);
|
||||
summary[result] += 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
private async claimDueWithTimeout(
|
||||
teamName: string,
|
||||
options: MemberWorkSyncNudgeDispatchOptions,
|
||||
nowIso: string,
|
||||
timeouts: { claimTimeoutMs: number },
|
||||
run: MemberWorkSyncNudgeDispatchRun
|
||||
): Promise<MemberWorkSyncOutboxItem[] | null> {
|
||||
const outbox = this.deps.outboxStore;
|
||||
if (!outbox) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const work = outbox.claimDue({
|
||||
teamName,
|
||||
claimedBy: options.claimedBy,
|
||||
nowIso,
|
||||
limit: options.limit ?? 10,
|
||||
});
|
||||
void work.catch(() => undefined);
|
||||
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
work,
|
||||
new Promise<'timeout'>((resolve) => {
|
||||
timeout = setTimeout(() => resolve('timeout'), timeouts.claimTimeoutMs);
|
||||
unrefTimer(timeout);
|
||||
}),
|
||||
]);
|
||||
if (result !== 'timeout') {
|
||||
return isDispatchRunCancelled(run) ? null : result;
|
||||
}
|
||||
this.deps.logger?.warn('member work sync nudge claim timed out', {
|
||||
teamName,
|
||||
timeoutMs: timeouts.claimTimeoutMs,
|
||||
});
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.deps.logger?.warn('member work sync nudge claim failed', {
|
||||
teamName,
|
||||
error: String(error),
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatchItemWithTimeout(
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
nowIso: string,
|
||||
timeoutMs: number,
|
||||
run: MemberWorkSyncNudgeDispatchRun
|
||||
): Promise<keyof Omit<MemberWorkSyncNudgeDispatchSummary, 'claimed'>> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const itemRun: MemberWorkSyncNudgeDispatchRun = { cancelled: false, parent: run };
|
||||
const work = this.dispatchItem(item, nowIso, itemRun);
|
||||
void work.catch(() => undefined);
|
||||
|
||||
try {
|
||||
const result = await Promise.race<
|
||||
keyof Omit<MemberWorkSyncNudgeDispatchSummary, 'claimed'> | 'timeout'
|
||||
>([
|
||||
work,
|
||||
new Promise<'timeout'>((resolve) => {
|
||||
timeout = setTimeout(() => {
|
||||
itemRun.cancelled = true;
|
||||
resolve('timeout');
|
||||
}, timeoutMs);
|
||||
unrefTimer(timeout);
|
||||
}),
|
||||
]);
|
||||
if (result !== 'timeout') {
|
||||
return result;
|
||||
}
|
||||
await this.tryMarkDispatchItemRetryable(
|
||||
item,
|
||||
nowIso,
|
||||
`nudge dispatch item timed out after ${timeoutMs}ms`,
|
||||
timeoutMs,
|
||||
run
|
||||
);
|
||||
return 'retryable';
|
||||
} catch (error) {
|
||||
await this.tryMarkDispatchItemRetryable(item, nowIso, String(error), timeoutMs, run);
|
||||
return 'retryable';
|
||||
} finally {
|
||||
itemRun.cancelled = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async tryMarkDispatchItemRetryable(
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
nowIso: string,
|
||||
error: string,
|
||||
timeoutMs: number,
|
||||
run?: MemberWorkSyncNudgeDispatchRun
|
||||
): Promise<void> {
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return;
|
||||
}
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const markTimeoutMs = Math.min(Math.max(1, timeoutMs), 5_000);
|
||||
const work = this.markDispatchItemRetryable(item, nowIso, error, run);
|
||||
void work.catch(() => undefined);
|
||||
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
work.then(() => 'marked' as const),
|
||||
new Promise<'timeout'>((resolve) => {
|
||||
timeout = setTimeout(() => resolve('timeout'), markTimeoutMs);
|
||||
unrefTimer(timeout);
|
||||
}),
|
||||
]);
|
||||
if (result === 'timeout') {
|
||||
this.deps.logger?.warn('member work sync nudge retry mark timed out', {
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
outboxId: item.id,
|
||||
timeoutMs: markTimeoutMs,
|
||||
error,
|
||||
});
|
||||
}
|
||||
} catch (markError) {
|
||||
this.deps.logger?.warn('member work sync nudge retry mark failed', {
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
outboxId: item.id,
|
||||
error: String(markError),
|
||||
});
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async markDispatchItemRetryable(
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
nowIso: string,
|
||||
error: string,
|
||||
run?: MemberWorkSyncNudgeDispatchRun
|
||||
): Promise<void> {
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return;
|
||||
}
|
||||
await this.deps.outboxStore?.markFailed({
|
||||
teamName: item.teamName,
|
||||
id: item.id,
|
||||
attemptGeneration: item.attemptGeneration,
|
||||
error,
|
||||
retryable: true,
|
||||
nowIso,
|
||||
nextAttemptAt: nextRetryAt(item, nowIso),
|
||||
});
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return;
|
||||
}
|
||||
await this.appendDispatchAudit(item, 'nudge_retryable', error);
|
||||
}
|
||||
|
||||
private async dispatchItem(
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
nowIso: string
|
||||
nowIso: string,
|
||||
run: MemberWorkSyncNudgeDispatchRun
|
||||
): Promise<keyof Omit<MemberWorkSyncNudgeDispatchSummary, 'claimed'>> {
|
||||
const outbox = this.deps.outboxStore;
|
||||
const inbox = this.deps.inboxNudge;
|
||||
|
|
@ -158,7 +445,13 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
return 'terminal';
|
||||
}
|
||||
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
const revalidation = await this.revalidate(item, nowIso);
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
if (!revalidation.ok) {
|
||||
if (revalidation.retryable) {
|
||||
await outbox.markFailed({
|
||||
|
|
@ -170,6 +463,9 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
nowIso,
|
||||
nextAttemptAt: revalidation.nextAttemptAt ?? nextRetryAt(item, nowIso),
|
||||
});
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
await this.appendDispatchAudit(
|
||||
item,
|
||||
reasonToAuditEvent(revalidation.reason),
|
||||
|
|
@ -178,8 +474,8 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
return 'retryable';
|
||||
}
|
||||
if (revalidation.reason.startsWith('review_pickup_delivery_unavailable:')) {
|
||||
await this.markReviewPickupDeliveryUnavailable(item, nowIso, revalidation.reason);
|
||||
return 'superseded';
|
||||
await this.markReviewPickupDeliveryUnavailable(item, nowIso, revalidation.reason, run);
|
||||
return isDispatchRunCancelled(run) ? 'retryable' : 'superseded';
|
||||
}
|
||||
await outbox.markSuperseded({
|
||||
teamName: item.teamName,
|
||||
|
|
@ -187,11 +483,17 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
reason: revalidation.reason,
|
||||
nowIso,
|
||||
});
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
await this.appendDispatchAudit(item, 'nudge_superseded', revalidation.reason);
|
||||
return 'superseded';
|
||||
}
|
||||
|
||||
try {
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
const inserted = await inbox.insertIfAbsent({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
|
|
@ -200,6 +502,9 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
payload: item.payload,
|
||||
timestamp: nowIso,
|
||||
});
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
if (inserted.conflict) {
|
||||
await outbox.markFailed({
|
||||
teamName: item.teamName,
|
||||
|
|
@ -209,6 +514,9 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
retryable: false,
|
||||
nowIso,
|
||||
});
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
await this.appendDispatchAudit(item, 'nudge_skipped', 'inbox_payload_conflict');
|
||||
return 'terminal';
|
||||
}
|
||||
|
|
@ -218,7 +526,8 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
inserted.messageId,
|
||||
inserted.inserted,
|
||||
revalidation.providerId,
|
||||
nowIso
|
||||
nowIso,
|
||||
run
|
||||
);
|
||||
}
|
||||
await outbox.markDelivered({
|
||||
|
|
@ -228,15 +537,25 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
deliveredMessageId: inserted.messageId,
|
||||
nowIso,
|
||||
});
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
await this.appendDispatchAudit(item, 'nudge_delivered', 'inbox_inserted');
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
await this.scheduleDeliveryWake(
|
||||
item,
|
||||
inserted.messageId,
|
||||
inserted.inserted,
|
||||
revalidation.providerId
|
||||
revalidation.providerId,
|
||||
run
|
||||
);
|
||||
return 'delivered';
|
||||
return isDispatchRunCancelled(run) ? 'retryable' : 'delivered';
|
||||
} catch (error) {
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
await outbox.markFailed({
|
||||
teamName: item.teamName,
|
||||
id: item.id,
|
||||
|
|
@ -246,6 +565,9 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
nowIso,
|
||||
nextAttemptAt: nextRetryAt(item, nowIso),
|
||||
});
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
await this.appendDispatchAudit(item, 'nudge_retryable', String(error));
|
||||
return 'retryable';
|
||||
}
|
||||
|
|
@ -256,7 +578,8 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
messageId: string,
|
||||
inserted: boolean,
|
||||
providerId: MemberWorkSyncStatus['providerId'] | undefined,
|
||||
nowIso: string
|
||||
nowIso: string,
|
||||
run: MemberWorkSyncNudgeDispatchRun
|
||||
): Promise<keyof Omit<MemberWorkSyncNudgeDispatchSummary, 'claimed'>> {
|
||||
const outbox = this.deps.outboxStore;
|
||||
const delivery = this.deps.reviewPickupDelivery;
|
||||
|
|
@ -264,11 +587,15 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
await this.markReviewPickupDeliveryUnavailable(
|
||||
item,
|
||||
nowIso,
|
||||
'review_pickup_delivery_port_unavailable'
|
||||
'review_pickup_delivery_port_unavailable',
|
||||
run
|
||||
);
|
||||
return 'superseded';
|
||||
return isDispatchRunCancelled(run) ? 'retryable' : 'superseded';
|
||||
}
|
||||
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
const outcome = await delivery.deliver({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
|
|
@ -278,6 +605,9 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
inserted,
|
||||
nowIso,
|
||||
});
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
|
||||
if (outcome.ok) {
|
||||
await outbox.markDelivered({
|
||||
|
|
@ -289,7 +619,13 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
deliveryDiagnostics: outcome.diagnostics,
|
||||
nowIso,
|
||||
});
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
await this.appendDispatchAudit(item, 'review_pickup_member_nudge_delivered', outcome.state);
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
await this.appendDispatchAudit(item, 'nudge_delivered', `review_pickup:${outcome.state}`);
|
||||
return 'delivered';
|
||||
}
|
||||
|
|
@ -304,13 +640,16 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
nowIso,
|
||||
nextAttemptAt: outcome.retryAfterIso ?? nextRetryAt(item, nowIso),
|
||||
});
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
await this.appendDispatchAudit(item, 'review_pickup_wake_failed_retryable', outcome.message);
|
||||
return 'retryable';
|
||||
}
|
||||
|
||||
if (outcome.reason === 'capability_absent') {
|
||||
await this.markReviewPickupDeliveryUnavailable(item, nowIso, outcome.message);
|
||||
return 'superseded';
|
||||
await this.markReviewPickupDeliveryUnavailable(item, nowIso, outcome.message, run);
|
||||
return isDispatchRunCancelled(run) ? 'retryable' : 'superseded';
|
||||
}
|
||||
|
||||
await outbox.markFailed({
|
||||
|
|
@ -321,6 +660,9 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
retryable: false,
|
||||
nowIso,
|
||||
});
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return 'retryable';
|
||||
}
|
||||
await this.appendDispatchAudit(item, 'nudge_skipped', outcome.message);
|
||||
return 'terminal';
|
||||
}
|
||||
|
|
@ -328,26 +670,40 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
private async markReviewPickupDeliveryUnavailable(
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
nowIso: string,
|
||||
reason: string
|
||||
reason: string,
|
||||
run?: MemberWorkSyncNudgeDispatchRun
|
||||
): Promise<void> {
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return;
|
||||
}
|
||||
await this.deps.outboxStore?.markSuperseded({
|
||||
teamName: item.teamName,
|
||||
id: item.id,
|
||||
reason,
|
||||
nowIso,
|
||||
});
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return;
|
||||
}
|
||||
await this.appendDispatchAudit(item, 'review_pickup_delivery_unavailable', reason);
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return;
|
||||
}
|
||||
await this.appendDispatchAudit(item, 'review_pickup_escalated', reason);
|
||||
await this.notifyReviewPickupEscalation(item, nowIso, reason);
|
||||
if (isDispatchRunCancelled(run)) {
|
||||
return;
|
||||
}
|
||||
await this.notifyReviewPickupEscalation(item, nowIso, reason, run);
|
||||
}
|
||||
|
||||
private async notifyReviewPickupEscalation(
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
nowIso: string,
|
||||
reason: string
|
||||
reason: string,
|
||||
run?: MemberWorkSyncNudgeDispatchRun
|
||||
): Promise<void> {
|
||||
const escalation = this.deps.reviewPickupEscalation;
|
||||
if (!escalation) {
|
||||
if (!escalation || isDispatchRunCancelled(run)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -395,12 +751,16 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
| { ok: true; providerId?: MemberWorkSyncStatus['providerId'] }
|
||||
| { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string }
|
||||
> {
|
||||
const teamActive = this.deps.lifecycle
|
||||
? await this.deps.lifecycle.isTeamActive(item.teamName)
|
||||
: true;
|
||||
if (!teamActive) {
|
||||
const runtimeActivity = await resolveMemberWorkSyncRuntimeActivity(this.deps, {
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
});
|
||||
if (!runtimeActivity.teamActive) {
|
||||
return { ok: false, reason: 'team_inactive', retryable: false };
|
||||
}
|
||||
if (!runtimeActivity.memberActive) {
|
||||
return { ok: false, reason: 'member_runtime_inactive', retryable: false };
|
||||
}
|
||||
|
||||
const previous = await this.deps.statusStore.read({
|
||||
teamName: item.teamName,
|
||||
|
|
@ -424,7 +784,7 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
agenda,
|
||||
latestAcceptedReport: previous.report?.accepted ? previous.report : null,
|
||||
nowIso,
|
||||
inactive: source.inactive || !teamActive,
|
||||
inactive: source.inactive || runtimeActivity.inactive,
|
||||
});
|
||||
const providerId = source.providerId ?? previous.providerId;
|
||||
const { report: _previousReport, ...previousWithoutReport } = previous;
|
||||
|
|
@ -533,21 +893,46 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
}
|
||||
|
||||
const taskIds = item.payload.taskRefs.map((taskRef) => taskRef.taskId);
|
||||
if (
|
||||
this.deps.watchdogCooldown &&
|
||||
(await this.deps.watchdogCooldown.hasRecentNudge({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
taskIds,
|
||||
nowIso,
|
||||
}))
|
||||
) {
|
||||
return { ok: false, reason: 'watchdog_cooldown_active', retryable: true };
|
||||
const watchdogCooldown = await this.resolveWatchdogCooldown(item, taskIds, nowIso);
|
||||
if (watchdogCooldown.active) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'watchdog_cooldown_active',
|
||||
retryable: true,
|
||||
...(watchdogCooldown.retryAfterIso
|
||||
? { nextAttemptAt: watchdogCooldown.retryAfterIso }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, ...(providerId ? { providerId } : {}) };
|
||||
}
|
||||
|
||||
private async resolveWatchdogCooldown(
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
taskIds: string[],
|
||||
nowIso: string
|
||||
): Promise<{ active: boolean; retryAfterIso?: string }> {
|
||||
const watchdogCooldown = this.deps.watchdogCooldown;
|
||||
if (!watchdogCooldown) {
|
||||
return { active: false };
|
||||
}
|
||||
const input = {
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
taskIds,
|
||||
nowIso,
|
||||
};
|
||||
if (watchdogCooldown.getRecentNudgeCooldown) {
|
||||
const result = await watchdogCooldown.getRecentNudgeCooldown(input);
|
||||
return {
|
||||
active: result.active,
|
||||
...(result.retryAfterIso ? { retryAfterIso: result.retryAfterIso } : {}),
|
||||
};
|
||||
}
|
||||
return { active: await watchdogCooldown.hasRecentNudge(input) };
|
||||
}
|
||||
|
||||
private async revalidateProofMissingRecovery(
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
nowIso: string
|
||||
|
|
@ -578,9 +963,10 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
item: MemberWorkSyncOutboxItem,
|
||||
messageId: string,
|
||||
inserted: boolean,
|
||||
providerId?: MemberWorkSyncStatus['providerId']
|
||||
providerId?: MemberWorkSyncStatus['providerId'],
|
||||
run?: MemberWorkSyncNudgeDispatchRun
|
||||
): Promise<void> {
|
||||
if (!this.deps.nudgeDeliveryWake) {
|
||||
if (!this.deps.nudgeDeliveryWake || isDispatchRunCancelled(run)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,29 @@ function isTurnSettledReconcile(status: MemberWorkSyncStatus): boolean {
|
|||
return status.shadow?.triggerReasons?.includes('turn_settled') === true;
|
||||
}
|
||||
|
||||
function parseTime(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const time = Date.parse(value);
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
|
||||
function hasActiveAcceptedWorkLease(status: MemberWorkSyncStatus): boolean {
|
||||
const report = status.report;
|
||||
if (
|
||||
report?.accepted !== true ||
|
||||
report.agendaFingerprint !== status.agenda.fingerprint ||
|
||||
(report.state !== 'still_working' && report.state !== 'blocked')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const evaluatedAtMs = parseTime(status.evaluatedAt);
|
||||
const expiresAtMs = parseTime(report.expiresAt);
|
||||
return evaluatedAtMs != null && expiresAtMs != null && expiresAtMs > evaluatedAtMs;
|
||||
}
|
||||
|
||||
function shouldPlanStatusOnlyRecovery(input: {
|
||||
status: MemberWorkSyncStatus;
|
||||
baseInput: MemberWorkSyncOutboxEnsureInput;
|
||||
|
|
@ -68,7 +91,7 @@ function shouldPlanStatusOnlyRecovery(input: {
|
|||
input.baseInput.payload.workSyncIntent === 'agenda_sync' &&
|
||||
input.baseInput.payload.workSyncIntentKey === undefined &&
|
||||
input.existingItemStatus === 'delivered' &&
|
||||
input.status.report?.accepted !== true
|
||||
!hasActiveAcceptedWorkLease(input.status)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -84,18 +107,10 @@ function shouldPlanAgendaSyncRefreshRecovery(input: {
|
|||
input.baseInput.payload.workSyncIntentKey === undefined &&
|
||||
input.existingItem.status === 'delivered' &&
|
||||
input.existingItem.agendaFingerprint === input.baseInput.agendaFingerprint &&
|
||||
input.status.report?.accepted !== true
|
||||
!hasActiveAcceptedWorkLease(input.status)
|
||||
);
|
||||
}
|
||||
|
||||
function parseTime(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const time = Date.parse(value);
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
|
||||
function isDeliveredStillStuckRecoveryReason(reason: MemberWorkSyncNudgeActivationReason): boolean {
|
||||
return (
|
||||
reason === 'shadow_ready' ||
|
||||
|
|
@ -125,7 +140,7 @@ function shouldPlanDeliveredStillStuckRecovery(input: {
|
|||
input.baseInput.payload.workSyncIntentKey !== undefined ||
|
||||
!recoverableExistingItem ||
|
||||
input.existingItem.agendaFingerprint !== input.baseInput.agendaFingerprint ||
|
||||
input.status.report?.accepted === true ||
|
||||
hasActiveAcceptedWorkLease(input.status) ||
|
||||
!isDeliveredStillStuckRecoveryReason(input.activationReason)
|
||||
) {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ function statusForResult(input: {
|
|||
if (input.accepted) {
|
||||
return 'accepted';
|
||||
}
|
||||
if (input.code === 'member_inactive' || input.code === 'team_runtime_inactive') {
|
||||
if (
|
||||
input.code === 'member_inactive' ||
|
||||
input.code === 'team_runtime_inactive' ||
|
||||
input.code === 'member_runtime_inactive'
|
||||
) {
|
||||
return 'superseded';
|
||||
}
|
||||
return 'rejected';
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
|
||||
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
|
||||
import { MemberWorkSyncNudgeOutboxPlanner } from './MemberWorkSyncNudgeOutboxPlanner';
|
||||
import { resolveMemberWorkSyncRuntimeActivity } from './MemberWorkSyncRuntimeActivity';
|
||||
|
||||
import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts';
|
||||
import type { MemberWorkSyncAgendaSourceResult, MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
|
@ -14,6 +15,7 @@ import type { MemberWorkSyncAgendaSourceResult, MemberWorkSyncUseCaseDeps } from
|
|||
export interface MemberWorkSyncReconcileContext {
|
||||
reconciledBy?: 'request' | 'queue';
|
||||
triggerReasons?: string[];
|
||||
isCancelled?: () => boolean;
|
||||
recovery?: {
|
||||
kind: 'proof_missing';
|
||||
intentKey: string;
|
||||
|
|
@ -22,6 +24,19 @@ export interface MemberWorkSyncReconcileContext {
|
|||
};
|
||||
}
|
||||
|
||||
export class MemberWorkSyncReconcileCancelledError extends Error {
|
||||
constructor() {
|
||||
super('member work sync reconcile cancelled');
|
||||
this.name = 'MemberWorkSyncReconcileCancelledError';
|
||||
}
|
||||
}
|
||||
|
||||
function assertReconcileNotCancelled(context: MemberWorkSyncReconcileContext): void {
|
||||
if (context.isCancelled?.()) {
|
||||
throw new MemberWorkSyncReconcileCancelledError();
|
||||
}
|
||||
}
|
||||
|
||||
export function finalizeMemberWorkSyncAgenda(
|
||||
deps: MemberWorkSyncUseCaseDeps,
|
||||
source: MemberWorkSyncAgendaSourceResult
|
||||
|
|
@ -61,6 +76,7 @@ export class MemberWorkSyncReconciler {
|
|||
...(context.triggerReasons?.length ? { triggerReasons: context.triggerReasons } : {}),
|
||||
});
|
||||
const source = await this.deps.agendaSource.loadAgenda(request);
|
||||
assertReconcileNotCancelled(context);
|
||||
const agenda = finalizeMemberWorkSyncAgenda(this.deps, source);
|
||||
await appendMemberWorkSyncAudit(this.deps, {
|
||||
teamName: agenda.teamName,
|
||||
|
|
@ -72,21 +88,24 @@ export class MemberWorkSyncReconciler {
|
|||
...(source.providerId ? { providerId: source.providerId } : {}),
|
||||
diagnostics: agenda.diagnostics,
|
||||
});
|
||||
assertReconcileNotCancelled(context);
|
||||
const previous = await this.deps.statusStore.read(request);
|
||||
const nowIso = this.deps.clock.now().toISOString();
|
||||
const teamActive = this.deps.lifecycle
|
||||
? await this.deps.lifecycle.isTeamActive(agenda.teamName)
|
||||
: true;
|
||||
const runtimeActivity = await resolveMemberWorkSyncRuntimeActivity(this.deps, {
|
||||
teamName: agenda.teamName,
|
||||
memberName: agenda.memberName,
|
||||
});
|
||||
assertReconcileNotCancelled(context);
|
||||
const decision = decideMemberWorkSyncStatus({
|
||||
agenda,
|
||||
latestAcceptedReport: previous?.report?.accepted ? previous.report : null,
|
||||
nowIso,
|
||||
inactive: source.inactive || !teamActive,
|
||||
inactive: source.inactive || runtimeActivity.inactive,
|
||||
});
|
||||
await appendMemberWorkSyncAudit(this.deps, {
|
||||
teamName: agenda.teamName,
|
||||
memberName: agenda.memberName,
|
||||
event: source.inactive || !teamActive ? 'team_inactive' : 'decision_made',
|
||||
event: source.inactive || runtimeActivity.inactive ? 'team_inactive' : 'decision_made',
|
||||
source: 'reconciler',
|
||||
agendaFingerprint: agenda.fingerprint,
|
||||
state: decision.state,
|
||||
|
|
@ -95,6 +114,7 @@ export class MemberWorkSyncReconciler {
|
|||
diagnostics: decision.diagnostics,
|
||||
});
|
||||
|
||||
assertReconcileNotCancelled(context);
|
||||
const status = await attachMemberWorkSyncReportToken(this.deps, {
|
||||
teamName: agenda.teamName,
|
||||
memberName: agenda.memberName,
|
||||
|
|
@ -125,15 +145,13 @@ export class MemberWorkSyncReconciler {
|
|||
: {}),
|
||||
},
|
||||
evaluatedAt: nowIso,
|
||||
diagnostics: [
|
||||
...agenda.diagnostics,
|
||||
...(!teamActive ? ['team_runtime_inactive'] : []),
|
||||
...decision.diagnostics,
|
||||
],
|
||||
diagnostics: [...agenda.diagnostics, ...runtimeActivity.diagnostics, ...decision.diagnostics],
|
||||
...(source.providerId ? { providerId: source.providerId } : {}),
|
||||
});
|
||||
|
||||
assertReconcileNotCancelled(context);
|
||||
await this.deps.statusStore.write(status);
|
||||
assertReconcileNotCancelled(context);
|
||||
await this.planNudgeOutbox(status);
|
||||
return status;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
finalizeMemberWorkSyncAgenda,
|
||||
MemberWorkSyncReconciler,
|
||||
} from './MemberWorkSyncReconciler';
|
||||
import { resolveMemberWorkSyncRuntimeActivity } from './MemberWorkSyncRuntimeActivity';
|
||||
|
||||
import type {
|
||||
MemberWorkSyncReport,
|
||||
|
|
@ -42,10 +43,11 @@ export class MemberWorkSyncReporter {
|
|||
const source = await this.deps.agendaSource.loadAgenda(request);
|
||||
const agenda = finalizeMemberWorkSyncAgenda(this.deps, source);
|
||||
const nowIso = this.deps.clock.now().toISOString();
|
||||
const teamActive = this.deps.lifecycle
|
||||
? await this.deps.lifecycle.isTeamActive(agenda.teamName)
|
||||
: true;
|
||||
if (!teamActive) {
|
||||
const runtimeActivity = await resolveMemberWorkSyncRuntimeActivity(this.deps, {
|
||||
teamName: agenda.teamName,
|
||||
memberName: agenda.memberName,
|
||||
});
|
||||
if (!runtimeActivity.teamActive) {
|
||||
const status = await this.reconciler.execute(request);
|
||||
const rejectedStatus = await this.recordRejectedReport(
|
||||
status,
|
||||
|
|
@ -59,6 +61,21 @@ export class MemberWorkSyncReporter {
|
|||
status: rejectedStatus,
|
||||
};
|
||||
}
|
||||
if (!runtimeActivity.memberActive) {
|
||||
const status = await this.reconciler.execute(request);
|
||||
const rejectedStatus = await this.recordRejectedReport(
|
||||
status,
|
||||
request,
|
||||
'member_runtime_inactive'
|
||||
);
|
||||
return {
|
||||
accepted: false,
|
||||
code: 'member_runtime_inactive',
|
||||
message:
|
||||
'Member runtime is not active. Restart this teammate before reporting work sync state.',
|
||||
status: rejectedStatus,
|
||||
};
|
||||
}
|
||||
const tokenValidation = this.deps.reportToken
|
||||
? await this.deps.reportToken.verify({
|
||||
token: request.reportToken,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
||||
export interface MemberWorkSyncRuntimeActivity {
|
||||
teamActive: boolean;
|
||||
memberActive: boolean;
|
||||
inactive: boolean;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export async function resolveMemberWorkSyncRuntimeActivity(
|
||||
deps: Pick<MemberWorkSyncUseCaseDeps, 'lifecycle'>,
|
||||
input: { teamName: string; memberName: string }
|
||||
): Promise<MemberWorkSyncRuntimeActivity> {
|
||||
if (!deps.lifecycle) {
|
||||
return { teamActive: true, memberActive: true, inactive: false, diagnostics: [] };
|
||||
}
|
||||
|
||||
const teamActive = await deps.lifecycle.isTeamActive(input.teamName);
|
||||
if (!teamActive) {
|
||||
return {
|
||||
teamActive: false,
|
||||
memberActive: false,
|
||||
inactive: true,
|
||||
diagnostics: ['team_runtime_inactive'],
|
||||
};
|
||||
}
|
||||
|
||||
const memberActive = deps.lifecycle.isMemberActive
|
||||
? await deps.lifecycle.isMemberActive(input)
|
||||
: true;
|
||||
if (!memberActive) {
|
||||
return {
|
||||
teamActive: true,
|
||||
memberActive: false,
|
||||
inactive: true,
|
||||
diagnostics: ['member_runtime_inactive'],
|
||||
};
|
||||
}
|
||||
|
||||
return { teamActive: true, memberActive: true, inactive: false, diagnostics: [] };
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ export * from './MemberWorkSyncNudgeOutboxPlanner';
|
|||
export * from './MemberWorkSyncPendingReportIntentReplayer';
|
||||
export * from './MemberWorkSyncReconciler';
|
||||
export * from './MemberWorkSyncReporter';
|
||||
export * from './MemberWorkSyncRuntimeActivity';
|
||||
export * from './MemberWorkSyncTargetedRecoveryPolicy';
|
||||
export type * from './ports';
|
||||
export * from './RuntimeTurnSettledIngestor';
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export interface MemberWorkSyncReportTokenPort {
|
|||
|
||||
export interface MemberWorkSyncLifecyclePort {
|
||||
isTeamActive(teamName: string): Promise<boolean> | boolean;
|
||||
isMemberActive?(input: { teamName: string; memberName: string }): Promise<boolean> | boolean;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncLoggerPort {
|
||||
|
|
@ -71,6 +72,7 @@ export type MemberWorkSyncAuditEventName =
|
|||
| 'turn_settled_ignored'
|
||||
| 'queue_enqueued'
|
||||
| 'queue_coalesced'
|
||||
| 'queue_retry_scheduled'
|
||||
| 'queue_reconciled'
|
||||
| 'queue_dropped'
|
||||
| 'reconcile_started'
|
||||
|
|
@ -197,6 +199,12 @@ export interface MemberWorkSyncWatchdogCooldownPort {
|
|||
taskIds: string[];
|
||||
nowIso: string;
|
||||
}): Promise<boolean>;
|
||||
getRecentNudgeCooldown?(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
taskIds: string[];
|
||||
nowIso: string;
|
||||
}): Promise<{ active: boolean; retryAfterIso?: string }>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncBusySignalPort {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export class MemberWorkSyncTeamChangeRouter {
|
|||
noteTeamChange(event: TeamChangeEvent): void {
|
||||
if (event.type === 'lead-activity' && event.detail === 'offline') {
|
||||
this.queue.dropTeam(event.teamName);
|
||||
void this.enqueueTeam(event.teamName, 'runtime_activity', 0).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,13 @@ const DEFAULT_WATCHDOG_COOLDOWN_MS = 10 * 60_000;
|
|||
|
||||
interface StallJournalEntry {
|
||||
taskId: string;
|
||||
memberName?: string;
|
||||
state: string;
|
||||
alertedAt?: string;
|
||||
}
|
||||
|
||||
type WatchdogCooldownResult = { active: boolean; retryAfterIso?: string };
|
||||
|
||||
function parseTime(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
|
|
@ -19,6 +22,10 @@ function parseTime(value: string | undefined): number | null {
|
|||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
|
||||
function normalizeMemberName(value: string | undefined): string {
|
||||
return value?.trim().toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
export class TeamTaskStallJournalWorkSyncCooldown implements MemberWorkSyncWatchdogCooldownPort {
|
||||
constructor(
|
||||
private readonly teamsBasePath: string,
|
||||
|
|
@ -31,9 +38,18 @@ export class TeamTaskStallJournalWorkSyncCooldown implements MemberWorkSyncWatch
|
|||
taskIds: string[];
|
||||
nowIso: string;
|
||||
}): Promise<boolean> {
|
||||
return (await this.getRecentNudgeCooldown(input)).active;
|
||||
}
|
||||
|
||||
async getRecentNudgeCooldown(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
taskIds: string[];
|
||||
nowIso: string;
|
||||
}): Promise<WatchdogCooldownResult> {
|
||||
const taskIds = new Set(input.taskIds);
|
||||
if (taskIds.size === 0) {
|
||||
return false;
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -43,19 +59,34 @@ export class TeamTaskStallJournalWorkSyncCooldown implements MemberWorkSyncWatch
|
|||
);
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return false;
|
||||
return { active: false };
|
||||
}
|
||||
const now = parseTime(input.nowIso) ?? Date.now();
|
||||
return parsed.some((entry): boolean => {
|
||||
const expectedMemberName = normalizeMemberName(input.memberName);
|
||||
let retryAfterMs: number | null = null;
|
||||
for (const entry of parsed) {
|
||||
const row = entry as Partial<StallJournalEntry>;
|
||||
if (row.state !== 'alerted' || !row.taskId || !taskIds.has(row.taskId)) {
|
||||
return false;
|
||||
continue;
|
||||
}
|
||||
const rowMemberName = normalizeMemberName(row.memberName);
|
||||
if (rowMemberName && rowMemberName !== expectedMemberName) {
|
||||
continue;
|
||||
}
|
||||
const alertedAt = parseTime(row.alertedAt);
|
||||
return alertedAt != null && now - alertedAt <= this.cooldownMs;
|
||||
});
|
||||
if (alertedAt == null || alertedAt > now || now - alertedAt >= this.cooldownMs) {
|
||||
continue;
|
||||
}
|
||||
const entryRetryAfterMs = alertedAt + this.cooldownMs;
|
||||
retryAfterMs =
|
||||
retryAfterMs == null ? entryRetryAfterMs : Math.max(retryAfterMs, entryRetryAfterMs);
|
||||
}
|
||||
if (retryAfterMs == null) {
|
||||
return { active: false };
|
||||
}
|
||||
return { active: true, retryAfterIso: new Date(retryAfterMs).toISOString() };
|
||||
} catch {
|
||||
return false;
|
||||
return { active: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
hasUncertainWorkSyncRuntimeActivity,
|
||||
hasWorkSyncActiveRuntime,
|
||||
isRuntimeEntryActiveForWorkSync,
|
||||
isRuntimeMemberActiveForWorkSync,
|
||||
isRuntimeMemberActivityUncertainForWorkSync,
|
||||
} from '../memberWorkSyncTeamActivity';
|
||||
|
||||
import type { TeamAgentRuntimeEntry, TeamAgentRuntimeSnapshot } from '@shared/types';
|
||||
|
|
@ -39,14 +42,49 @@ describe('member work sync team activity', () => {
|
|||
});
|
||||
|
||||
it('treats a confirmed bootstrap runtime entry as active', () => {
|
||||
for (const pidSource of ['agent_process_table', 'opencode_bridge'] as const) {
|
||||
expect(
|
||||
isRuntimeEntryActiveForWorkSync(
|
||||
createRuntimeEntry({
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
pidSource,
|
||||
runtimeLastSeenAt: '2026-05-18T19:44:47.000Z',
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not treat bootstrap-only confirmation as active runtime evidence', () => {
|
||||
for (const pidSource of [
|
||||
undefined,
|
||||
'runtime_bootstrap',
|
||||
'persisted_metadata',
|
||||
'tmux_child',
|
||||
'tmux_pane',
|
||||
] as const) {
|
||||
expect(
|
||||
isRuntimeEntryActiveForWorkSync(
|
||||
createRuntimeEntry({
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
...(pidSource ? { pidSource } : {}),
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not count lead runtime entries as work-sync active teammates', () => {
|
||||
expect(
|
||||
isRuntimeEntryActiveForWorkSync(
|
||||
createRuntimeEntry({
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
runtimeLastSeenAt: '2026-05-18T19:44:47.000Z',
|
||||
memberName: 'team-lead',
|
||||
backendType: 'lead',
|
||||
livenessKind: undefined,
|
||||
pidSource: 'lead_process',
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat inactive liveness diagnostics as active by themselves', () => {
|
||||
|
|
@ -77,6 +115,12 @@ describe('member work sync team activity', () => {
|
|||
expect(
|
||||
hasWorkSyncActiveRuntime(
|
||||
createRuntimeSnapshot({
|
||||
'team-lead': createRuntimeEntry({
|
||||
memberName: 'team-lead',
|
||||
backendType: 'lead',
|
||||
livenessKind: undefined,
|
||||
pidSource: 'lead_process',
|
||||
}),
|
||||
alice: createRuntimeEntry({ alive: false, livenessKind: 'stale_metadata' }),
|
||||
bob: createRuntimeEntry({ memberName: 'bob', livenessKind: 'runtime_process' }),
|
||||
})
|
||||
|
|
@ -88,6 +132,12 @@ describe('member work sync team activity', () => {
|
|||
expect(
|
||||
hasWorkSyncActiveRuntime(
|
||||
createRuntimeSnapshot({
|
||||
'team-lead': createRuntimeEntry({
|
||||
memberName: 'team-lead',
|
||||
backendType: 'lead',
|
||||
livenessKind: undefined,
|
||||
pidSource: 'lead_process',
|
||||
}),
|
||||
alice: createRuntimeEntry({ alive: false, livenessKind: 'stale_metadata' }),
|
||||
bob: createRuntimeEntry({
|
||||
memberName: 'bob',
|
||||
|
|
@ -99,6 +149,50 @@ describe('member work sync team activity', () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it('checks active runtime evidence for a specific teammate', () => {
|
||||
const snapshot = createRuntimeSnapshot({
|
||||
alice: createRuntimeEntry({ memberName: 'alice', livenessKind: 'runtime_process' }),
|
||||
bob: createRuntimeEntry({ memberName: 'bob', alive: false, livenessKind: 'stale_metadata' }),
|
||||
});
|
||||
|
||||
expect(isRuntimeMemberActiveForWorkSync(snapshot, 'ALICE')).toBe(true);
|
||||
expect(isRuntimeMemberActiveForWorkSync(snapshot, 'bob')).toBe(false);
|
||||
expect(isRuntimeMemberActiveForWorkSync(snapshot, 'team-lead')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats process table unavailability as uncertain runtime activity', () => {
|
||||
const snapshot = createRuntimeSnapshot({
|
||||
alice: createRuntimeEntry({
|
||||
memberName: 'alice',
|
||||
alive: false,
|
||||
livenessKind: 'registered_only',
|
||||
runtimeDiagnostic: 'runtime pid could not be verified because process table unavailable',
|
||||
}),
|
||||
bob: createRuntimeEntry({ memberName: 'bob', alive: false, livenessKind: 'stale_metadata' }),
|
||||
});
|
||||
|
||||
expect(hasWorkSyncActiveRuntime(snapshot)).toBe(false);
|
||||
expect(hasUncertainWorkSyncRuntimeActivity(snapshot)).toBe(true);
|
||||
expect(isRuntimeMemberActivityUncertainForWorkSync(snapshot, 'alice')).toBe(true);
|
||||
expect(isRuntimeMemberActivityUncertainForWorkSync(snapshot, 'bob')).toBe(false);
|
||||
});
|
||||
|
||||
it('recognizes process table is unavailable diagnostics as uncertain runtime activity', () => {
|
||||
const snapshot = createRuntimeSnapshot({
|
||||
alice: createRuntimeEntry({
|
||||
memberName: 'alice',
|
||||
alive: false,
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
pidSource: 'runtime_bootstrap',
|
||||
runtimeDiagnostic: 'runtime pid could not be verified because process table is unavailable',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(hasWorkSyncActiveRuntime(snapshot)).toBe(false);
|
||||
expect(hasUncertainWorkSyncRuntimeActivity(snapshot)).toBe(true);
|
||||
expect(isRuntimeMemberActivityUncertainForWorkSync(snapshot, 'alice')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles missing snapshots as inactive', () => {
|
||||
expect(hasWorkSyncActiveRuntime(null)).toBe(false);
|
||||
expect(hasWorkSyncActiveRuntime(undefined)).toBe(false);
|
||||
|
|
|
|||
|
|
@ -228,6 +228,7 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
kanbanManager: TeamKanbanManager;
|
||||
membersMetaStore: TeamMembersMetaStore;
|
||||
isTeamActive?: (teamName: string) => Promise<boolean> | boolean;
|
||||
isMemberActive?: (input: { teamName: string; memberName: string }) => Promise<boolean> | boolean;
|
||||
canDispatchNudges?: (teamName: string) => Promise<boolean> | boolean;
|
||||
listLifecycleActiveTeamNames?: () => Promise<string[]>;
|
||||
queueQuietWindowMs?: number;
|
||||
|
|
@ -312,7 +313,14 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
...(deps.reviewPickupEscalation ? { reviewPickupEscalation: deps.reviewPickupEscalation } : {}),
|
||||
reportToken,
|
||||
auditJournal,
|
||||
...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}),
|
||||
...(deps.isTeamActive
|
||||
? {
|
||||
lifecycle: {
|
||||
isTeamActive: deps.isTeamActive,
|
||||
...(deps.isMemberActive ? { isMemberActive: deps.isMemberActive } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
logger: deps.logger,
|
||||
};
|
||||
const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps);
|
||||
|
|
@ -328,6 +336,16 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
retryable: 0,
|
||||
terminal: 0,
|
||||
});
|
||||
const addNudgeDispatchSummaries = (
|
||||
left: MemberWorkSyncNudgeDispatchSummary,
|
||||
right: MemberWorkSyncNudgeDispatchSummary
|
||||
): MemberWorkSyncNudgeDispatchSummary => ({
|
||||
claimed: left.claimed + right.claimed,
|
||||
delivered: left.delivered + right.delivered,
|
||||
superseded: left.superseded + right.superseded,
|
||||
retryable: left.retryable + right.retryable,
|
||||
terminal: left.terminal + right.terminal,
|
||||
});
|
||||
const filterNudgeDispatchReadyTeamNames = async (teamNames: string[]): Promise<string[]> => {
|
||||
const uniqueTeamNames = [...new Set(teamNames.map((name) => name.trim()).filter(Boolean))];
|
||||
if (!deps.canDispatchNudges) {
|
||||
|
|
@ -401,22 +419,30 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
if (readyTeamNames.length === 0) {
|
||||
return emptyNudgeDispatchSummary();
|
||||
}
|
||||
const dispatchReadyNudges = () =>
|
||||
nudgeDispatcher.dispatchDue({
|
||||
teamNames: readyTeamNames,
|
||||
claimedBy,
|
||||
});
|
||||
const initialSummary = await dispatchReadyNudges();
|
||||
if (options.refreshBackgroundStaleStatuses !== false) {
|
||||
await refreshBackgroundStaleStatuses(readyTeamNames);
|
||||
return addNudgeDispatchSummaries(initialSummary, await dispatchReadyNudges());
|
||||
}
|
||||
return nudgeDispatcher.dispatchDue({
|
||||
teamNames: readyTeamNames,
|
||||
claimedBy,
|
||||
});
|
||||
return initialSummary;
|
||||
};
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
reconcile: async (request, context: MemberWorkSyncReconcileContext) => {
|
||||
await reconciler.execute(request, context);
|
||||
if (context.isCancelled?.()) {
|
||||
return;
|
||||
}
|
||||
await dispatchNudgesForReadyTeams([request.teamName], `member-work-sync:${process.pid}`, {
|
||||
refreshBackgroundStaleStatuses: false,
|
||||
});
|
||||
},
|
||||
isTeamActive: deps.isTeamActive ?? (() => true),
|
||||
reconcileInactiveTeams: true,
|
||||
...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}),
|
||||
auditJournal,
|
||||
logger: deps.logger,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
import type { TeamAgentRuntimeEntry, TeamAgentRuntimeSnapshot } from '@shared/types';
|
||||
import { mentionsProcessTableUnavailable } from '@shared/utils/teamLaunchFailureReason';
|
||||
|
||||
import { normalizeMemberName } from '../../core/domain';
|
||||
|
||||
import type {
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamAgentRuntimePidSource,
|
||||
TeamAgentRuntimeSnapshot,
|
||||
} from '@shared/types';
|
||||
|
||||
type RuntimeLivenessKind = NonNullable<TeamAgentRuntimeEntry['livenessKind']>;
|
||||
|
||||
const WORK_SYNC_RESERVED_MEMBER_NAMES = new Set(['team-lead', 'user']);
|
||||
|
||||
const WORK_SYNC_INACTIVE_LIVENESS_KINDS = new Set<RuntimeLivenessKind>([
|
||||
'permission_blocked',
|
||||
'runtime_process_candidate',
|
||||
|
|
@ -11,20 +21,107 @@ const WORK_SYNC_INACTIVE_LIVENESS_KINDS = new Set<RuntimeLivenessKind>([
|
|||
'not_found',
|
||||
]);
|
||||
|
||||
const WORK_SYNC_BOOTSTRAP_ONLY_PID_SOURCES = new Set<TeamAgentRuntimePidSource>([
|
||||
'runtime_bootstrap',
|
||||
'persisted_metadata',
|
||||
]);
|
||||
|
||||
const WORK_SYNC_CONFIRMED_BOOTSTRAP_ACTIVE_PID_SOURCES = new Set<TeamAgentRuntimePidSource>([
|
||||
'agent_process_table',
|
||||
'opencode_bridge',
|
||||
]);
|
||||
|
||||
export function isRuntimeEntryActiveForWorkSync(
|
||||
entry: Pick<TeamAgentRuntimeEntry, 'alive' | 'livenessKind'> | null | undefined
|
||||
entry:
|
||||
| Pick<
|
||||
TeamAgentRuntimeEntry,
|
||||
'alive' | 'backendType' | 'livenessKind' | 'memberName' | 'pidSource'
|
||||
>
|
||||
| null
|
||||
| undefined
|
||||
): boolean {
|
||||
if (entry?.alive !== true) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
entry.backendType === 'lead' ||
|
||||
WORK_SYNC_RESERVED_MEMBER_NAMES.has(entry.memberName.trim().toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
entry.livenessKind === 'confirmed_bootstrap' &&
|
||||
(!entry.pidSource ||
|
||||
WORK_SYNC_BOOTSTRAP_ONLY_PID_SOURCES.has(entry.pidSource) ||
|
||||
!WORK_SYNC_CONFIRMED_BOOTSTRAP_ACTIVE_PID_SOURCES.has(entry.pidSource))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!entry.livenessKind) {
|
||||
return true;
|
||||
}
|
||||
return !WORK_SYNC_INACTIVE_LIVENESS_KINDS.has(entry.livenessKind);
|
||||
}
|
||||
|
||||
function isRuntimeEntryRelevantForWorkSync(
|
||||
entry: Pick<TeamAgentRuntimeEntry, 'backendType' | 'memberName'>
|
||||
): boolean {
|
||||
return (
|
||||
entry.backendType !== 'lead' &&
|
||||
!WORK_SYNC_RESERVED_MEMBER_NAMES.has(entry.memberName.trim().toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
function runtimeEntryMentionsProcessTableUnavailable(
|
||||
entry: Pick<TeamAgentRuntimeEntry, 'diagnostics' | 'runtimeDiagnostic'>
|
||||
): boolean {
|
||||
return [entry.runtimeDiagnostic, ...(entry.diagnostics ?? [])].some((message) =>
|
||||
mentionsProcessTableUnavailable(message)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasUncertainWorkSyncRuntimeActivity(
|
||||
snapshot: Pick<TeamAgentRuntimeSnapshot, 'members'> | null | undefined
|
||||
): boolean {
|
||||
return Object.values(snapshot?.members ?? {}).some(
|
||||
(entry) =>
|
||||
isRuntimeEntryRelevantForWorkSync(entry) && runtimeEntryMentionsProcessTableUnavailable(entry)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasWorkSyncActiveRuntime(
|
||||
snapshot: Pick<TeamAgentRuntimeSnapshot, 'members'> | null | undefined
|
||||
): boolean {
|
||||
return Object.values(snapshot?.members ?? {}).some(isRuntimeEntryActiveForWorkSync);
|
||||
}
|
||||
|
||||
export function isRuntimeMemberActiveForWorkSync(
|
||||
snapshot: Pick<TeamAgentRuntimeSnapshot, 'members'> | null | undefined,
|
||||
memberName: string
|
||||
): boolean {
|
||||
const normalizedMemberName = normalizeMemberName(memberName);
|
||||
if (!normalizedMemberName) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(snapshot?.members ?? {}).some(
|
||||
(entry) =>
|
||||
normalizeMemberName(entry.memberName) === normalizedMemberName &&
|
||||
isRuntimeEntryActiveForWorkSync(entry)
|
||||
);
|
||||
}
|
||||
|
||||
export function isRuntimeMemberActivityUncertainForWorkSync(
|
||||
snapshot: Pick<TeamAgentRuntimeSnapshot, 'members'> | null | undefined,
|
||||
memberName: string
|
||||
): boolean {
|
||||
const normalizedMemberName = normalizeMemberName(memberName);
|
||||
if (!normalizedMemberName) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(snapshot?.members ?? {}).some(
|
||||
(entry) =>
|
||||
normalizeMemberName(entry.memberName) === normalizedMemberName &&
|
||||
isRuntimeEntryRelevantForWorkSync(entry) &&
|
||||
runtimeEntryMentionsProcessTableUnavailable(entry)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ export {
|
|||
createMemberWorkSyncFeature,
|
||||
} from './composition/createMemberWorkSyncFeature';
|
||||
export {
|
||||
hasUncertainWorkSyncRuntimeActivity,
|
||||
hasWorkSyncActiveRuntime,
|
||||
isRuntimeEntryActiveForWorkSync,
|
||||
isRuntimeMemberActiveForWorkSync,
|
||||
isRuntimeMemberActivityUncertainForWorkSync,
|
||||
} from './composition/memberWorkSyncTeamActivity';
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ interface OutboxIndexFile {
|
|||
|
||||
type OutboxIndexRoute = OutboxIndexFile['items'][string];
|
||||
type OutboxDueRoute = [string, OutboxIndexRoute];
|
||||
const MEMBER_WORK_SYNC_OUTBOX_CLAIM_STALE_MS = 5 * 60 * 1000;
|
||||
|
||||
export interface JsonMemberWorkSyncStoreDeps {
|
||||
auditJournal?: MemberWorkSyncAuditJournalPort;
|
||||
|
|
@ -117,8 +118,12 @@ export interface JsonMemberWorkSyncStoreDeps {
|
|||
now?: () => Date;
|
||||
}
|
||||
|
||||
function normalizeMemberKey(memberName: string): string {
|
||||
return memberName.trim().toLowerCase();
|
||||
function normalizeMemberKey(memberName: unknown): string {
|
||||
return typeof memberName === 'string' ? memberName.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
function normalizeTeamKey(teamName: unknown): string {
|
||||
return typeof teamName === 'string' ? teamName.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
function emptyMetricsIndex(): MetricsIndexFile {
|
||||
|
|
@ -242,6 +247,46 @@ function canReviveOutboxItem(status: MemberWorkSyncOutboxItem['status']): boolea
|
|||
return status === 'superseded' || (!isOutboxTerminal(status) && status !== 'pending');
|
||||
}
|
||||
|
||||
function isReportIntentOwnedBy(
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
intent: MemberWorkSyncReportIntent
|
||||
): boolean {
|
||||
return (
|
||||
normalizeTeamKey(intent.teamName) === normalizeTeamKey(teamName) &&
|
||||
normalizeMemberKey(intent.memberName) === normalizeMemberKey(memberName)
|
||||
);
|
||||
}
|
||||
|
||||
function isOutboxItemOwnedBy(
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
item: MemberWorkSyncOutboxItem
|
||||
): boolean {
|
||||
return (
|
||||
normalizeTeamKey(item.teamName) === normalizeTeamKey(teamName) &&
|
||||
normalizeMemberKey(item.memberName) === normalizeMemberKey(memberName)
|
||||
);
|
||||
}
|
||||
|
||||
function parseIsoMs(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const ms = Date.parse(value);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
function isStaleClaim(claimedAt: string | undefined, nowIso: string): boolean {
|
||||
const claimedAtMs = parseIsoMs(claimedAt);
|
||||
const nowMs = parseIsoMs(nowIso);
|
||||
return (
|
||||
claimedAtMs != null &&
|
||||
nowMs != null &&
|
||||
(claimedAtMs > nowMs || nowMs - claimedAtMs >= MEMBER_WORK_SYNC_OUTBOX_CLAIM_STALE_MS)
|
||||
);
|
||||
}
|
||||
|
||||
function applyOptionalNextAttemptAt(
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
nextAttemptAt: string | undefined
|
||||
|
|
@ -253,14 +298,36 @@ function applyOptionalNextAttemptAt(
|
|||
delete item.nextAttemptAt;
|
||||
}
|
||||
|
||||
function isNextAttemptDue(nextAttemptAt: string | undefined, nowIso: string): boolean {
|
||||
if (!nextAttemptAt) {
|
||||
return true;
|
||||
}
|
||||
const nextAttemptAtMs = parseIsoMs(nextAttemptAt);
|
||||
if (nextAttemptAtMs == null) {
|
||||
return true;
|
||||
}
|
||||
const nowMs = parseIsoMs(nowIso);
|
||||
return nowMs != null && nextAttemptAtMs <= nowMs;
|
||||
}
|
||||
|
||||
function canClaimOutboxItem(item: MemberWorkSyncOutboxItem, nowIso: string): boolean {
|
||||
if (item.status === 'claimed') {
|
||||
return isStaleClaim(item.claimedAt ?? item.updatedAt, nowIso);
|
||||
}
|
||||
if (item.status !== 'pending' && item.status !== 'failed_retryable') {
|
||||
return false;
|
||||
}
|
||||
if (!item.nextAttemptAt) {
|
||||
return true;
|
||||
return isNextAttemptDue(item.nextAttemptAt, nowIso);
|
||||
}
|
||||
|
||||
function canClaimOutboxRoute(route: OutboxIndexRoute, nowIso: string): boolean {
|
||||
if (route.status === 'claimed') {
|
||||
return isStaleClaim(route.updatedAt, nowIso);
|
||||
}
|
||||
return item.nextAttemptAt <= nowIso;
|
||||
return (
|
||||
(route.status === 'pending' || route.status === 'failed_retryable') &&
|
||||
isNextAttemptDue(route.nextAttemptAt, nowIso)
|
||||
);
|
||||
}
|
||||
|
||||
function getDueOutboxRoutes(
|
||||
|
|
@ -269,8 +336,7 @@ function getDueOutboxRoutes(
|
|||
limit: number
|
||||
): OutboxDueRoute[] {
|
||||
return Object.entries(index.items)
|
||||
.filter(([, route]) => route.status === 'pending' || route.status === 'failed_retryable')
|
||||
.filter(([, route]) => !route.nextAttemptAt || route.nextAttemptAt <= nowIso)
|
||||
.filter(([, route]) => canClaimOutboxRoute(route, nowIso))
|
||||
.sort((left, right) => {
|
||||
const leftTime = left[1].nextAttemptAt ?? left[1].updatedAt;
|
||||
const rightTime = right[1].nextAttemptAt ?? right[1].updatedAt;
|
||||
|
|
@ -623,10 +689,10 @@ export class JsonMemberWorkSyncStore
|
|||
staleIndex = true;
|
||||
}
|
||||
}
|
||||
const missingIndexedPending = staleIndex
|
||||
const unindexedOrStaleIndexedPending = staleIndex
|
||||
? false
|
||||
: await this.hasMissingIndexedPendingReport(teamName, index);
|
||||
if (staleIndex || missingIndexedPending) {
|
||||
: await this.hasUnindexedOrStaleIndexedPendingReport(teamName, index);
|
||||
if (staleIndex || unindexedOrStaleIndexedPending) {
|
||||
await this.enqueue(teamName, async () => {
|
||||
await withFileLock(this.paths.getPendingReportsIndexPath(teamName), async () => {
|
||||
index = await this.repairPendingReportsIndex(teamName);
|
||||
|
|
@ -666,29 +732,58 @@ export class JsonMemberWorkSyncStore
|
|||
if (!route) {
|
||||
return;
|
||||
}
|
||||
await withFileLock(
|
||||
this.paths.getMemberReportsPath(teamName, route.memberName),
|
||||
async () => {
|
||||
const reports = await this.readMemberReportsFile(teamName, route.memberName);
|
||||
const current = reports.intents[id];
|
||||
if (current?.status !== 'pending') {
|
||||
return;
|
||||
const updateRoute = async (
|
||||
targetRoute: PendingReportsIndexFile['items'][string]
|
||||
): Promise<boolean> => {
|
||||
let staleRoute = false;
|
||||
await withFileLock(
|
||||
this.paths.getMemberReportsPath(teamName, targetRoute.memberName),
|
||||
async () => {
|
||||
const reports = await this.readMemberReportsFile(teamName, targetRoute.memberName);
|
||||
const current = reports.intents[id];
|
||||
if (!current) {
|
||||
delete index.items[id];
|
||||
staleRoute = true;
|
||||
return;
|
||||
}
|
||||
if (!isReportIntentOwnedBy(teamName, targetRoute.memberName, current)) {
|
||||
delete index.items[id];
|
||||
staleRoute = true;
|
||||
return;
|
||||
}
|
||||
if (current.status !== 'pending') {
|
||||
return;
|
||||
}
|
||||
const next: MemberWorkSyncReportIntent = {
|
||||
...current,
|
||||
status: result.status,
|
||||
resultCode: result.resultCode,
|
||||
processedAt: result.processedAt,
|
||||
};
|
||||
reports.intents[id] = next;
|
||||
await this.writeMemberReportsFile(teamName, targetRoute.memberName, reports);
|
||||
index.items[id] = toPendingReportIndexItem(
|
||||
next,
|
||||
this.paths.getMemberKey(next.memberName)
|
||||
);
|
||||
await this.writePendingReportsIndexFile(teamName, index);
|
||||
}
|
||||
reports.intents[id] = {
|
||||
...current,
|
||||
status: result.status,
|
||||
resultCode: result.resultCode,
|
||||
processedAt: result.processedAt,
|
||||
};
|
||||
await this.writeMemberReportsFile(teamName, route.memberName, reports);
|
||||
index.items[id] = {
|
||||
...route,
|
||||
status: result.status,
|
||||
processedAt: result.processedAt,
|
||||
};
|
||||
await this.writePendingReportsIndexFile(teamName, index);
|
||||
);
|
||||
return staleRoute;
|
||||
};
|
||||
|
||||
let staleRoute = await updateRoute(route);
|
||||
if (staleRoute) {
|
||||
index = await this.repairPendingReportsIndex(teamName);
|
||||
const repairedRoute = index.items[id];
|
||||
if (!repairedRoute) {
|
||||
return;
|
||||
}
|
||||
);
|
||||
staleRoute = await updateRoute(repairedRoute);
|
||||
if (staleRoute) {
|
||||
await this.repairPendingReportsIndex(teamName);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -801,45 +896,67 @@ export class JsonMemberWorkSyncStore
|
|||
}
|
||||
let dueRoutes = getDueOutboxRoutes(index, input.nowIso, input.limit);
|
||||
if (
|
||||
dueRoutes.length > 0 &&
|
||||
dueRoutes.length < Math.max(0, input.limit) &&
|
||||
(await this.hasMissingIndexedDueOutboxItem(input.teamName, index, input.nowIso))
|
||||
(await this.hasUnindexedOrStaleIndexedDueOutboxItem(input.teamName, index, input.nowIso))
|
||||
) {
|
||||
index = await this.repairOutboxIndex(input.teamName);
|
||||
dueRoutes = getDueOutboxRoutes(index, input.nowIso, input.limit);
|
||||
}
|
||||
|
||||
let staleIndex = false;
|
||||
for (const [id, route] of dueRoutes) {
|
||||
await withFileLock(
|
||||
this.paths.getMemberOutboxPath(input.teamName, route.memberName),
|
||||
async () => {
|
||||
const outbox = await this.readMemberOutboxFile(input.teamName, route.memberName);
|
||||
const item = outbox.items[id];
|
||||
if (!item || !canClaimOutboxItem(item, input.nowIso)) {
|
||||
delete index.items[id];
|
||||
staleIndex = true;
|
||||
return;
|
||||
}
|
||||
const next: MemberWorkSyncOutboxItem = {
|
||||
...item,
|
||||
status: 'claimed',
|
||||
attemptGeneration: item.attemptGeneration + 1,
|
||||
claimedBy: input.claimedBy,
|
||||
claimedAt: input.nowIso,
|
||||
updatedAt: input.nowIso,
|
||||
};
|
||||
delete next.lastError;
|
||||
outbox.items[id] = next;
|
||||
await this.writeMemberOutboxFile(input.teamName, route.memberName, outbox);
|
||||
index.items[id] = toOutboxIndexItem(next, route.memberKey);
|
||||
claimed.push(next);
|
||||
const claimRoutes = async (routes: OutboxDueRoute[]): Promise<boolean> => {
|
||||
let staleIndex = false;
|
||||
for (const [id, route] of routes) {
|
||||
if (claimed.length >= Math.max(0, input.limit)) {
|
||||
break;
|
||||
}
|
||||
);
|
||||
}
|
||||
await withFileLock(
|
||||
this.paths.getMemberOutboxPath(input.teamName, route.memberName),
|
||||
async () => {
|
||||
const outbox = await this.readMemberOutboxFile(input.teamName, route.memberName);
|
||||
const item = outbox.items[id];
|
||||
if (!item || !canClaimOutboxItem(item, input.nowIso)) {
|
||||
delete index.items[id];
|
||||
staleIndex = true;
|
||||
return;
|
||||
}
|
||||
const memberKey = this.paths.getMemberKey(item.memberName);
|
||||
if (!isOutboxItemOwnedBy(input.teamName, route.memberName, item)) {
|
||||
delete index.items[id];
|
||||
staleIndex = true;
|
||||
return;
|
||||
}
|
||||
const next: MemberWorkSyncOutboxItem = {
|
||||
...item,
|
||||
status: 'claimed',
|
||||
attemptGeneration: item.attemptGeneration + 1,
|
||||
claimedBy: input.claimedBy,
|
||||
claimedAt: input.nowIso,
|
||||
updatedAt: input.nowIso,
|
||||
};
|
||||
delete next.nextAttemptAt;
|
||||
delete next.lastError;
|
||||
outbox.items[id] = next;
|
||||
await this.writeMemberOutboxFile(input.teamName, route.memberName, outbox);
|
||||
index.items[id] = toOutboxIndexItem(next, memberKey);
|
||||
claimed.push(next);
|
||||
}
|
||||
);
|
||||
}
|
||||
return staleIndex;
|
||||
};
|
||||
|
||||
let staleIndex = await claimRoutes(dueRoutes);
|
||||
if (staleIndex) {
|
||||
index = await this.repairOutboxIndex(input.teamName);
|
||||
const remainingLimit = Math.max(0, input.limit) - claimed.length;
|
||||
dueRoutes =
|
||||
remainingLimit > 0 ? getDueOutboxRoutes(index, input.nowIso, remainingLimit) : [];
|
||||
staleIndex = dueRoutes.length > 0 ? await claimRoutes(dueRoutes) : false;
|
||||
if (staleIndex) {
|
||||
await this.repairOutboxIndex(input.teamName);
|
||||
} else if (dueRoutes.length > 0) {
|
||||
await this.writeOutboxIndexFile(input.teamName, index);
|
||||
}
|
||||
} else if (dueRoutes.length > 0) {
|
||||
await this.writeOutboxIndexFile(input.teamName, index);
|
||||
}
|
||||
|
|
@ -850,7 +967,7 @@ export class JsonMemberWorkSyncStore
|
|||
|
||||
async markDelivered(input: MemberWorkSyncOutboxMarkDeliveredInput): Promise<void> {
|
||||
await this.updateOutboxItem(input.teamName, input.id, (current) => {
|
||||
if (current?.attemptGeneration !== input.attemptGeneration) {
|
||||
if (current?.attemptGeneration !== input.attemptGeneration || current.status !== 'claimed') {
|
||||
return current;
|
||||
}
|
||||
const next: MemberWorkSyncOutboxItem = {
|
||||
|
|
@ -885,7 +1002,10 @@ export class JsonMemberWorkSyncStore
|
|||
|
||||
async markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise<void> {
|
||||
await this.updateOutboxItem(input.teamName, input.id, (current) => {
|
||||
if (current?.attemptGeneration !== input.attemptGeneration) {
|
||||
if (
|
||||
current?.attemptGeneration !== input.attemptGeneration ||
|
||||
isOutboxTerminal(current.status)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
const next: MemberWorkSyncOutboxItem = {
|
||||
|
|
@ -996,7 +1116,8 @@ export class JsonMemberWorkSyncStore
|
|||
(item) =>
|
||||
item.payload.workSyncIntentKey === intentKey &&
|
||||
item.updatedAt >= input.sinceIso &&
|
||||
item.status !== 'failed_terminal'
|
||||
item.status !== 'failed_terminal' &&
|
||||
item.status !== 'superseded'
|
||||
)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
const latest = matches[0];
|
||||
|
|
@ -1171,17 +1292,48 @@ export class JsonMemberWorkSyncStore
|
|||
if (!route) {
|
||||
return;
|
||||
}
|
||||
await withFileLock(this.paths.getMemberOutboxPath(teamName, route.memberName), async () => {
|
||||
const outbox = await this.readMemberOutboxFile(teamName, route.memberName);
|
||||
const next = updater(outbox.items[id]);
|
||||
if (!next) {
|
||||
const updateRoute = async (targetRoute: OutboxIndexRoute): Promise<boolean> => {
|
||||
let staleRoute = false;
|
||||
await withFileLock(
|
||||
this.paths.getMemberOutboxPath(teamName, targetRoute.memberName),
|
||||
async () => {
|
||||
const outbox = await this.readMemberOutboxFile(teamName, targetRoute.memberName);
|
||||
const current = outbox.items[id];
|
||||
if (!current) {
|
||||
delete index.items[id];
|
||||
staleRoute = true;
|
||||
return;
|
||||
}
|
||||
if (!isOutboxItemOwnedBy(teamName, targetRoute.memberName, current)) {
|
||||
delete index.items[id];
|
||||
staleRoute = true;
|
||||
return;
|
||||
}
|
||||
const next = updater(current);
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
outbox.items[id] = next;
|
||||
await this.writeMemberOutboxFile(teamName, targetRoute.memberName, outbox);
|
||||
index.items[id] = toOutboxIndexItem(next, this.paths.getMemberKey(next.memberName));
|
||||
await this.writeOutboxIndexFile(teamName, index);
|
||||
}
|
||||
);
|
||||
return staleRoute;
|
||||
};
|
||||
|
||||
let staleRoute = await updateRoute(route);
|
||||
if (staleRoute) {
|
||||
index = await this.repairOutboxIndex(teamName);
|
||||
const repairedRoute = index.items[id];
|
||||
if (!repairedRoute) {
|
||||
return;
|
||||
}
|
||||
outbox.items[id] = next;
|
||||
await this.writeMemberOutboxFile(teamName, route.memberName, outbox);
|
||||
index.items[id] = toOutboxIndexItem(next, route.memberKey);
|
||||
await this.writeOutboxIndexFile(teamName, index);
|
||||
});
|
||||
staleRoute = await updateRoute(repairedRoute);
|
||||
if (staleRoute) {
|
||||
await this.repairOutboxIndex(teamName);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1251,11 +1403,17 @@ export class JsonMemberWorkSyncStore
|
|||
for (const { memberName, reports } of await this.scanMemberReports(teamName)) {
|
||||
const memberKey = this.paths.getMemberKey(memberName);
|
||||
for (const intent of Object.values(reports.intents)) {
|
||||
if (!isReportIntentOwnedBy(teamName, memberName, intent)) {
|
||||
continue;
|
||||
}
|
||||
index.items[intent.id] = toPendingReportIndexItem(intent, memberKey);
|
||||
repairedMembers.add(intent.memberName);
|
||||
}
|
||||
}
|
||||
for (const intent of Object.values((await this.readLegacyPendingFile(teamName)).intents)) {
|
||||
if (!isReportIntentOwnedBy(teamName, intent.memberName, intent)) {
|
||||
continue;
|
||||
}
|
||||
const memberKey = this.paths.getMemberKey(intent.memberName);
|
||||
if (!index.items[intent.id]) {
|
||||
await withFileLock(
|
||||
|
|
@ -1300,11 +1458,17 @@ export class JsonMemberWorkSyncStore
|
|||
for (const { memberName, outbox } of await this.scanMemberOutboxes(teamName)) {
|
||||
const memberKey = this.paths.getMemberKey(memberName);
|
||||
for (const item of Object.values(outbox.items)) {
|
||||
if (!isOutboxItemOwnedBy(teamName, memberName, item)) {
|
||||
continue;
|
||||
}
|
||||
index.items[item.id] = toOutboxIndexItem(item, memberKey);
|
||||
repairedMembers.add(item.memberName);
|
||||
}
|
||||
}
|
||||
for (const item of Object.values((await this.readLegacyOutboxFile(teamName)).items)) {
|
||||
if (!isOutboxItemOwnedBy(teamName, item.memberName, item)) {
|
||||
continue;
|
||||
}
|
||||
const memberKey = this.paths.getMemberKey(item.memberName);
|
||||
if (!index.items[item.id]) {
|
||||
await withFileLock(this.paths.getMemberOutboxPath(teamName, item.memberName), async () => {
|
||||
|
|
@ -1382,26 +1546,54 @@ export class JsonMemberWorkSyncStore
|
|||
return reports;
|
||||
}
|
||||
|
||||
private async hasMissingIndexedPendingReport(
|
||||
private async hasUnindexedOrStaleIndexedPendingReport(
|
||||
teamName: string,
|
||||
index: PendingReportsIndexFile
|
||||
): Promise<boolean> {
|
||||
const indexedIds = new Set(Object.keys(index.items));
|
||||
for (const { reports } of await this.scanMemberReports(teamName)) {
|
||||
const routes = index.items;
|
||||
for (const { memberName, reports } of await this.scanMemberReports(teamName)) {
|
||||
for (const intent of Object.values(reports.intents)) {
|
||||
if (intent.status === 'pending' && !indexedIds.has(intent.id)) {
|
||||
if (!isReportIntentOwnedBy(teamName, memberName, intent)) {
|
||||
continue;
|
||||
}
|
||||
const route = routes[intent.id];
|
||||
if (
|
||||
intent.status === 'pending' &&
|
||||
!this.isCurrentPendingReportRoute(teamName, route, intent)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const intent of Object.values((await this.readLegacyPendingFile(teamName)).intents)) {
|
||||
if (intent.status === 'pending' && !indexedIds.has(intent.id)) {
|
||||
if (!isReportIntentOwnedBy(teamName, intent.memberName, intent)) {
|
||||
continue;
|
||||
}
|
||||
const route = routes[intent.id];
|
||||
if (
|
||||
intent.status === 'pending' &&
|
||||
!this.isCurrentPendingReportRoute(teamName, route, intent)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private isCurrentPendingReportRoute(
|
||||
teamName: string,
|
||||
route: PendingReportsIndexFile['items'][string] | undefined,
|
||||
intent: MemberWorkSyncReportIntent
|
||||
): boolean {
|
||||
return (
|
||||
!!route &&
|
||||
normalizeTeamKey(intent.teamName) === normalizeTeamKey(teamName) &&
|
||||
route.status === 'pending' &&
|
||||
normalizeMemberKey(route.memberName) === normalizeMemberKey(intent.memberName) &&
|
||||
route.memberKey === this.paths.getMemberKey(intent.memberName)
|
||||
);
|
||||
}
|
||||
|
||||
private async scanMemberOutboxes(
|
||||
teamName: string
|
||||
): Promise<{ memberName: string; outbox: MemberOutboxFile }[]> {
|
||||
|
|
@ -1412,27 +1604,56 @@ export class JsonMemberWorkSyncStore
|
|||
return outboxes;
|
||||
}
|
||||
|
||||
private async hasMissingIndexedDueOutboxItem(
|
||||
private async hasUnindexedOrStaleIndexedDueOutboxItem(
|
||||
teamName: string,
|
||||
index: OutboxIndexFile,
|
||||
nowIso: string
|
||||
): Promise<boolean> {
|
||||
const indexedIds = new Set(Object.keys(index.items));
|
||||
for (const { outbox } of await this.scanMemberOutboxes(teamName)) {
|
||||
const routes = index.items;
|
||||
for (const { memberName, outbox } of await this.scanMemberOutboxes(teamName)) {
|
||||
for (const item of Object.values(outbox.items)) {
|
||||
if (canClaimOutboxItem(item, nowIso) && !indexedIds.has(item.id)) {
|
||||
if (!isOutboxItemOwnedBy(teamName, memberName, item)) {
|
||||
continue;
|
||||
}
|
||||
const route = routes[item.id];
|
||||
if (
|
||||
canClaimOutboxItem(item, nowIso) &&
|
||||
!this.isCurrentDueOutboxRoute(teamName, route, item, nowIso)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of Object.values((await this.readLegacyOutboxFile(teamName)).items)) {
|
||||
if (canClaimOutboxItem(item, nowIso) && !indexedIds.has(item.id)) {
|
||||
if (!isOutboxItemOwnedBy(teamName, item.memberName, item)) {
|
||||
continue;
|
||||
}
|
||||
const route = routes[item.id];
|
||||
if (
|
||||
canClaimOutboxItem(item, nowIso) &&
|
||||
!this.isCurrentDueOutboxRoute(teamName, route, item, nowIso)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private isCurrentDueOutboxRoute(
|
||||
teamName: string,
|
||||
route: OutboxIndexRoute | undefined,
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
nowIso: string
|
||||
): boolean {
|
||||
return (
|
||||
!!route &&
|
||||
normalizeTeamKey(item.teamName) === normalizeTeamKey(teamName) &&
|
||||
normalizeMemberKey(route.memberName) === normalizeMemberKey(item.memberName) &&
|
||||
route.memberKey === this.paths.getMemberKey(item.memberName) &&
|
||||
canClaimOutboxRoute(route, nowIso)
|
||||
);
|
||||
}
|
||||
|
||||
private async appendAudit(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ interface QueueItem {
|
|||
maxRunAt: number;
|
||||
triggerReasons: Set<MemberWorkSyncTriggerReason>;
|
||||
triggerReasonCounts: Map<MemberWorkSyncTriggerReason, number>;
|
||||
retryCount: number;
|
||||
recovery?: MemberWorkSyncReconcileContext['recovery'];
|
||||
}
|
||||
|
||||
|
|
@ -84,9 +85,13 @@ export interface MemberWorkSyncEventQueueDeps {
|
|||
context: MemberWorkSyncReconcileContext
|
||||
): Promise<void>;
|
||||
isTeamActive(teamName: string): Promise<boolean> | boolean;
|
||||
reconcileInactiveTeams?: boolean;
|
||||
quietWindowMs?: number;
|
||||
triggerTiming?: Partial<Record<MemberWorkSyncTriggerReason, Partial<TriggerTimingPolicy>>>;
|
||||
concurrency?: number;
|
||||
retryDelayMs?: number;
|
||||
reconcileTimeoutMs?: number;
|
||||
maxRetryAttempts?: number;
|
||||
now?: () => number;
|
||||
nowIso?: () => string;
|
||||
auditJournal?: MemberWorkSyncAuditJournalPort;
|
||||
|
|
@ -101,12 +106,17 @@ function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
|
|||
timer.unref?.();
|
||||
}
|
||||
|
||||
const DEFAULT_RECONCILE_TIMEOUT_MS = 2 * 60_000;
|
||||
|
||||
export class MemberWorkSyncEventQueue {
|
||||
private readonly items = new Map<string, QueueItem>();
|
||||
private readonly running = new Map<string, RunningItem>();
|
||||
private readonly inFlight = new Set<Promise<void>>();
|
||||
private readonly quietWindowMs: number;
|
||||
private readonly concurrency: number;
|
||||
private readonly retryDelayMs: number;
|
||||
private readonly reconcileTimeoutMs: number;
|
||||
private readonly maxRetryAttempts: number;
|
||||
private readonly now: () => number;
|
||||
private readonly nowIso: () => string;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
|
@ -122,6 +132,9 @@ export class MemberWorkSyncEventQueue {
|
|||
constructor(private readonly deps: MemberWorkSyncEventQueueDeps) {
|
||||
this.quietWindowMs = deps.quietWindowMs ?? 90_000;
|
||||
this.concurrency = Math.max(1, deps.concurrency ?? 2);
|
||||
this.retryDelayMs = Math.max(0, deps.retryDelayMs ?? 30_000);
|
||||
this.reconcileTimeoutMs = Math.max(1, deps.reconcileTimeoutMs ?? DEFAULT_RECONCILE_TIMEOUT_MS);
|
||||
this.maxRetryAttempts = Math.max(0, deps.maxRetryAttempts ?? 3);
|
||||
this.now = deps.now ?? Date.now;
|
||||
this.nowIso = deps.nowIso ?? (() => new Date().toISOString());
|
||||
}
|
||||
|
|
@ -209,6 +222,7 @@ export class MemberWorkSyncEventQueue {
|
|||
? Math.min(existing.runAt, runAt)
|
||||
: Math.min(Math.max(existing.runAt, runAt), existing.maxRunAt);
|
||||
incrementReasonCount(existing.triggerReasonCounts, input.triggerReason);
|
||||
existing.retryCount = 0;
|
||||
this.counters.coalesced += 1;
|
||||
this.appendAudit({
|
||||
teamName,
|
||||
|
|
@ -230,6 +244,7 @@ export class MemberWorkSyncEventQueue {
|
|||
maxRunAt: now + timing.maxCoalesceWaitMs,
|
||||
triggerReasons: new Set([input.triggerReason]),
|
||||
triggerReasonCounts: new Map([[input.triggerReason, 1]]),
|
||||
retryCount: 0,
|
||||
...(input.recovery ? { recovery: input.recovery } : {}),
|
||||
});
|
||||
this.counters.enqueued += 1;
|
||||
|
|
@ -366,8 +381,10 @@ export class MemberWorkSyncEventQueue {
|
|||
};
|
||||
this.running.set(key, running);
|
||||
|
||||
let failed = false;
|
||||
const promise = this.executeItem(key, item, running)
|
||||
.catch((error: unknown) => {
|
||||
failed = true;
|
||||
this.counters.failed += 1;
|
||||
this.deps.logger?.warn('member work sync queue reconcile failed', {
|
||||
teamName: item.teamName,
|
||||
|
|
@ -380,6 +397,8 @@ export class MemberWorkSyncEventQueue {
|
|||
this.inFlight.delete(promise);
|
||||
if (running.rerunRequested && !this.stopped) {
|
||||
this.enqueueFollowUp(item, running);
|
||||
} else if (failed && !this.stopped) {
|
||||
this.enqueueRetryAfterFailure(key, item, running);
|
||||
}
|
||||
this.pump();
|
||||
});
|
||||
|
|
@ -387,6 +406,53 @@ export class MemberWorkSyncEventQueue {
|
|||
this.inFlight.add(promise);
|
||||
}
|
||||
|
||||
private enqueueRetryAfterFailure(key: string, item: QueueItem, running: RunningItem): void {
|
||||
if (item.retryCount >= this.maxRetryAttempts) {
|
||||
this.counters.dropped += 1;
|
||||
this.appendAudit({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
event: 'queue_dropped',
|
||||
source: 'event_queue',
|
||||
reason: 'reconcile_failed_max_retries',
|
||||
triggerReasons: [...running.triggerReasons].sort(),
|
||||
metadata: {
|
||||
retryCount: item.retryCount,
|
||||
maxRetryAttempts: this.maxRetryAttempts,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.now();
|
||||
const retryCount = item.retryCount + 1;
|
||||
const recovery = running.recovery ?? item.recovery;
|
||||
this.items.set(key, {
|
||||
...item,
|
||||
lastQueuedAt: now,
|
||||
runAt: now + this.retryDelayMs,
|
||||
maxRunAt: now + this.retryDelayMs,
|
||||
triggerReasons: new Set(running.triggerReasons),
|
||||
triggerReasonCounts: new Map(item.triggerReasonCounts),
|
||||
retryCount,
|
||||
...(recovery ? { recovery } : {}),
|
||||
});
|
||||
this.appendAudit({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
event: 'queue_retry_scheduled',
|
||||
source: 'event_queue',
|
||||
reason: 'reconcile_failed',
|
||||
triggerReasons: [...running.triggerReasons].sort(),
|
||||
metadata: {
|
||||
retryCount,
|
||||
retryDelayMs: this.retryDelayMs,
|
||||
maxRetryAttempts: this.maxRetryAttempts,
|
||||
},
|
||||
});
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
private enqueueFollowUp(item: QueueItem, running: RunningItem): void {
|
||||
const reasons = [...running.triggerReasons].sort();
|
||||
const recovery = running.recovery ?? item.recovery;
|
||||
|
|
@ -415,7 +481,7 @@ export class MemberWorkSyncEventQueue {
|
|||
}
|
||||
|
||||
private async executeItem(_key: string, item: QueueItem, running: RunningItem): Promise<void> {
|
||||
if (!(await this.deps.isTeamActive(item.teamName))) {
|
||||
if (!this.deps.reconcileInactiveTeams && !(await this.deps.isTeamActive(item.teamName))) {
|
||||
this.counters.dropped += 1;
|
||||
this.appendAudit({
|
||||
teamName: item.teamName,
|
||||
|
|
@ -428,7 +494,7 @@ export class MemberWorkSyncEventQueue {
|
|||
}
|
||||
|
||||
const recovery = running.recovery ?? item.recovery;
|
||||
await this.deps.reconcile(
|
||||
await this.runReconcileWithTimeout(
|
||||
{ teamName: item.teamName, memberName: item.memberName },
|
||||
{
|
||||
reconciledBy: 'queue',
|
||||
|
|
@ -446,6 +512,39 @@ export class MemberWorkSyncEventQueue {
|
|||
});
|
||||
}
|
||||
|
||||
private async runReconcileWithTimeout(
|
||||
input: { teamName: string; memberName: string },
|
||||
context: MemberWorkSyncReconcileContext
|
||||
): Promise<void> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let timedOut = false;
|
||||
const reconcilePromise = this.deps.reconcile(input, {
|
||||
...context,
|
||||
isCancelled: () => timedOut || context.isCancelled?.() === true,
|
||||
});
|
||||
void reconcilePromise.catch(() => undefined);
|
||||
try {
|
||||
await Promise.race([
|
||||
reconcilePromise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
reject(
|
||||
new Error(
|
||||
`member work sync queue reconcile timed out after ${this.reconcileTimeoutMs}ms`
|
||||
)
|
||||
);
|
||||
}, this.reconcileTimeoutMs);
|
||||
unrefTimer(timeout);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private appendAudit(input: Omit<MemberWorkSyncAuditEvent, 'timestamp'>): void {
|
||||
if (!this.deps.auditJournal) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
} from '../../core/application';
|
||||
|
||||
const DEFAULT_NUDGE_DISPATCH_INTERVAL_MS = 60_000;
|
||||
const DEFAULT_NUDGE_DISPATCH_TIMEOUT_MS = 2 * 60_000;
|
||||
|
||||
function uniqueNonEmpty(values: string[]): string[] {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
||||
|
|
@ -17,17 +18,23 @@ export interface MemberWorkSyncNudgeDispatchSchedulerDeps {
|
|||
listLifecycleActiveTeamNames(): Promise<string[]>;
|
||||
dispatchDue(teamNames: string[]): Promise<MemberWorkSyncNudgeDispatchSummary>;
|
||||
intervalMs?: number;
|
||||
dispatchTimeoutMs?: number;
|
||||
logger?: MemberWorkSyncLoggerPort;
|
||||
}
|
||||
|
||||
export class MemberWorkSyncNudgeDispatchScheduler {
|
||||
private readonly intervalMs: number;
|
||||
private readonly dispatchTimeoutMs: number;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private running: Promise<void> | null = null;
|
||||
private stopped = false;
|
||||
|
||||
constructor(private readonly deps: MemberWorkSyncNudgeDispatchSchedulerDeps) {
|
||||
this.intervalMs = Math.max(10_000, deps.intervalMs ?? DEFAULT_NUDGE_DISPATCH_INTERVAL_MS);
|
||||
this.dispatchTimeoutMs = Math.max(
|
||||
1,
|
||||
deps.dispatchTimeoutMs ?? DEFAULT_NUDGE_DISPATCH_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
|
|
@ -84,11 +91,11 @@ export class MemberWorkSyncNudgeDispatchScheduler {
|
|||
|
||||
private async dispatchOnce(): Promise<void> {
|
||||
try {
|
||||
const teamNames = uniqueNonEmpty(await this.deps.listLifecycleActiveTeamNames());
|
||||
const teamNames = uniqueNonEmpty(await this.listLifecycleActiveTeamNamesWithTimeout());
|
||||
if (teamNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
const summary = await this.deps.dispatchDue(teamNames);
|
||||
const summary = await this.runDispatchDueWithTimeout(teamNames);
|
||||
if (summary.claimed > 0 || summary.delivered > 0 || summary.retryable > 0) {
|
||||
this.deps.logger?.debug('member work sync scheduled nudge dispatch completed', {
|
||||
teamCount: teamNames.length,
|
||||
|
|
@ -101,4 +108,56 @@ export class MemberWorkSyncNudgeDispatchScheduler {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async runDispatchDueWithTimeout(
|
||||
teamNames: string[]
|
||||
): Promise<MemberWorkSyncNudgeDispatchSummary> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const work = this.deps.dispatchDue(teamNames);
|
||||
void work.catch(() => undefined);
|
||||
try {
|
||||
return await Promise.race([
|
||||
work,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`member work sync scheduled nudge dispatch timed out after ${this.dispatchTimeoutMs}ms`
|
||||
)
|
||||
);
|
||||
}, this.dispatchTimeoutMs);
|
||||
unrefTimer(timeout);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async listLifecycleActiveTeamNamesWithTimeout(): Promise<string[]> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const work = this.deps.listLifecycleActiveTeamNames();
|
||||
void work.catch(() => undefined);
|
||||
try {
|
||||
return await Promise.race([
|
||||
work,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`member work sync scheduled nudge team listing timed out after ${this.dispatchTimeoutMs}ms`
|
||||
)
|
||||
);
|
||||
}, this.dispatchTimeoutMs);
|
||||
unrefTimer(timeout);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ function parseIsoMs(value: string | undefined, fallbackMs: number): number {
|
|||
return Number.isFinite(parsed) ? parsed : fallbackMs;
|
||||
}
|
||||
|
||||
function parseEventIsoMs(value: string | undefined, nowMs: number): number {
|
||||
return Math.min(parseIsoMs(value, nowMs), nowMs);
|
||||
}
|
||||
|
||||
function addMsIso(baseIso: string, ms: number): string {
|
||||
return new Date(Date.parse(baseIso) + ms).toISOString();
|
||||
}
|
||||
|
|
@ -136,7 +140,7 @@ export class MemberWorkSyncToolActivityBusySignal implements MemberWorkSyncBusyS
|
|||
return;
|
||||
}
|
||||
const state = this.getOrCreateState(teamName, memberName);
|
||||
const startedAtMs = parseIsoMs(startedAt, Date.now());
|
||||
const startedAtMs = parseEventIsoMs(startedAt, Date.now());
|
||||
state.activeToolStartedAtByToolId.set(normalizedToolUseId, new Date(startedAtMs).toISOString());
|
||||
state.recentBusyUntilByToolId.delete(normalizedToolUseId);
|
||||
}
|
||||
|
|
@ -151,7 +155,7 @@ export class MemberWorkSyncToolActivityBusySignal implements MemberWorkSyncBusyS
|
|||
if (!memberName.trim() || !normalizedToolUseId) {
|
||||
return;
|
||||
}
|
||||
const finishedAtMs = parseIsoMs(finishedAt, Date.now());
|
||||
const finishedAtMs = parseEventIsoMs(finishedAt, Date.now());
|
||||
const busyUntilIso = new Date(finishedAtMs + this.busyGraceMs).toISOString();
|
||||
const state = this.getOrCreateState(teamName, memberName);
|
||||
state.activeToolStartedAtByToolId.delete(normalizedToolUseId);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
export interface RuntimeTurnSettledDrainSchedulerDeps {
|
||||
drain(): Promise<RuntimeTurnSettledDrainSummary>;
|
||||
intervalMs?: number;
|
||||
drainTimeoutMs?: number;
|
||||
logger?: MemberWorkSyncLoggerPort;
|
||||
}
|
||||
|
||||
|
|
@ -13,14 +14,21 @@ function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
|
|||
timer.unref?.();
|
||||
}
|
||||
|
||||
const DEFAULT_RUNTIME_TURN_SETTLED_DRAIN_TIMEOUT_MS = 2 * 60_000;
|
||||
|
||||
export class RuntimeTurnSettledDrainScheduler {
|
||||
private readonly intervalMs: number;
|
||||
private readonly drainTimeoutMs: number;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private running = false;
|
||||
private disposed = false;
|
||||
|
||||
constructor(private readonly deps: RuntimeTurnSettledDrainSchedulerDeps) {
|
||||
this.intervalMs = Math.max(1_000, deps.intervalMs ?? 15_000);
|
||||
this.drainTimeoutMs = Math.max(
|
||||
1,
|
||||
deps.drainTimeoutMs ?? DEFAULT_RUNTIME_TURN_SETTLED_DRAIN_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
|
|
@ -37,7 +45,7 @@ export class RuntimeTurnSettledDrainScheduler {
|
|||
|
||||
this.running = true;
|
||||
try {
|
||||
return await this.deps.drain();
|
||||
return await this.runDrainWithTimeout();
|
||||
} catch (error) {
|
||||
this.deps.logger?.warn('runtime turn settled scheduled drain failed', {
|
||||
error: String(error),
|
||||
|
|
@ -66,4 +74,25 @@ export class RuntimeTurnSettledDrainScheduler {
|
|||
}, delayMs);
|
||||
unrefTimer(this.timer);
|
||||
}
|
||||
|
||||
private async runDrainWithTimeout(): Promise<RuntimeTurnSettledDrainSummary> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
this.deps.drain(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(
|
||||
new Error(`runtime turn settled drain timed out after ${this.drainTimeoutMs}ms`)
|
||||
);
|
||||
}, this.drainTimeoutMs);
|
||||
unrefTimer(timeout);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,10 @@ import {
|
|||
import {
|
||||
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
||||
createMemberWorkSyncFeature,
|
||||
hasUncertainWorkSyncRuntimeActivity,
|
||||
hasWorkSyncActiveRuntime,
|
||||
isRuntimeMemberActivityUncertainForWorkSync,
|
||||
isRuntimeMemberActiveForWorkSync,
|
||||
type MemberWorkSyncFeatureFacade,
|
||||
registerMemberWorkSyncIpc,
|
||||
removeMemberWorkSyncIpc,
|
||||
|
|
@ -1820,7 +1823,10 @@ async function initializeServices(): Promise<void> {
|
|||
teammateToolTracker = new TeammateToolTracker(
|
||||
teamMemberLogsFinder,
|
||||
teamLogSourceTracker,
|
||||
forwardTeamChange
|
||||
(event) => {
|
||||
forwardTeamChange(event);
|
||||
memberWorkSyncFeature?.noteTeamChange(event);
|
||||
}
|
||||
);
|
||||
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
|
||||
const teamChangeEmitter = (event: TeamChangeEvent): void => {
|
||||
|
|
@ -1878,41 +1884,140 @@ async function initializeServices(): Promise<void> {
|
|||
});
|
||||
runtimeProviderManagementFeature = createRuntimeProviderManagementFeature();
|
||||
const memberWorkSyncLogger = createLogger('Feature:MemberWorkSync');
|
||||
const hasMemberWorkSyncRuntimeActivity = async (teamName: string): Promise<boolean> => {
|
||||
const getMemberWorkSyncRuntimeSnapshot = async (input: {
|
||||
teamName: string;
|
||||
memberName?: string;
|
||||
}) => {
|
||||
const timeoutMs = 15_000;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const snapshot = teamProvisioningService.getTeamAgentRuntimeSnapshot(input.teamName);
|
||||
void snapshot.catch(() => undefined);
|
||||
try {
|
||||
const snapshot = await teamProvisioningService.getTeamAgentRuntimeSnapshot(teamName);
|
||||
return hasWorkSyncActiveRuntime(snapshot);
|
||||
return await Promise.race([
|
||||
snapshot,
|
||||
new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
memberWorkSyncLogger.warn('member work sync runtime snapshot timed out', {
|
||||
teamName: input.teamName,
|
||||
...(input.memberName ? { memberName: input.memberName } : {}),
|
||||
timeoutMs,
|
||||
});
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
};
|
||||
const getMemberWorkSyncRuntimeActivity = async (teamName: string): Promise<boolean | null> => {
|
||||
try {
|
||||
const snapshot = await getMemberWorkSyncRuntimeSnapshot({ teamName });
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
const active = hasWorkSyncActiveRuntime(snapshot);
|
||||
if (!active && hasUncertainWorkSyncRuntimeActivity(snapshot)) {
|
||||
return null;
|
||||
}
|
||||
return active;
|
||||
} catch (error) {
|
||||
memberWorkSyncLogger.warn('member work sync runtime activity check failed', {
|
||||
teamName,
|
||||
error: String(error),
|
||||
});
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const getMemberWorkSyncMemberRuntimeActivity = async (input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
}): Promise<boolean | null> => {
|
||||
try {
|
||||
const snapshot = await getMemberWorkSyncRuntimeSnapshot(input);
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
const active = isRuntimeMemberActiveForWorkSync(snapshot, input.memberName);
|
||||
if (!active && isRuntimeMemberActivityUncertainForWorkSync(snapshot, input.memberName)) {
|
||||
return null;
|
||||
}
|
||||
return active;
|
||||
} catch (error) {
|
||||
memberWorkSyncLogger.warn('member work sync member runtime activity check failed', {
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
error: String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const isTeamActiveForMemberWorkSync = async (teamName: string): Promise<boolean> => {
|
||||
if (
|
||||
const runtimeActive = await getMemberWorkSyncRuntimeActivity(teamName);
|
||||
if (runtimeActive != null) {
|
||||
return runtimeActive;
|
||||
}
|
||||
return (
|
||||
teamProvisioningService.isTeamAlive(teamName) ||
|
||||
teamProvisioningService.hasProvisioningRun(teamName)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return hasMemberWorkSyncRuntimeActivity(teamName);
|
||||
);
|
||||
};
|
||||
const canDispatchMemberWorkSyncNudges = async (teamName: string): Promise<boolean> => {
|
||||
if (teamProvisioningService.isTeamAlive(teamName)) {
|
||||
return true;
|
||||
const runtimeActive = await getMemberWorkSyncRuntimeActivity(teamName);
|
||||
if (runtimeActive != null) {
|
||||
return runtimeActive;
|
||||
}
|
||||
return hasMemberWorkSyncRuntimeActivity(teamName);
|
||||
return teamProvisioningService.isTeamAlive(teamName);
|
||||
};
|
||||
const isMemberActiveForMemberWorkSync = async (input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
}): Promise<boolean> => {
|
||||
const runtimeActive = await getMemberWorkSyncMemberRuntimeActivity(input);
|
||||
if (runtimeActive != null) {
|
||||
return runtimeActive;
|
||||
}
|
||||
return (
|
||||
teamProvisioningService.isTeamAlive(input.teamName) ||
|
||||
teamProvisioningService.hasProvisioningRun(input.teamName)
|
||||
);
|
||||
};
|
||||
const listMemberWorkSyncLifecycleActiveTeamNames = async (): Promise<string[]> => {
|
||||
const teams = (await teamDataService.listTeams()).filter((team) => !team.deletedAt);
|
||||
const activeChecks = await Promise.allSettled(
|
||||
teams.map(async (team) => {
|
||||
try {
|
||||
return {
|
||||
teamName: team.teamName,
|
||||
active: await isTeamActiveForMemberWorkSync(team.teamName),
|
||||
};
|
||||
} catch (error) {
|
||||
memberWorkSyncLogger.warn('member work sync lifecycle team activity check failed', {
|
||||
teamName: team.teamName,
|
||||
error: String(error),
|
||||
});
|
||||
return {
|
||||
teamName: team.teamName,
|
||||
active:
|
||||
teamProvisioningService.isTeamAlive(team.teamName) ||
|
||||
teamProvisioningService.hasProvisioningRun(team.teamName),
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
const activeTeamNames: string[] = [];
|
||||
for (const team of await teamDataService.listTeams()) {
|
||||
if (team.deletedAt) {
|
||||
for (const check of activeChecks) {
|
||||
if (check.status === 'rejected') {
|
||||
memberWorkSyncLogger.warn('member work sync lifecycle team activity check failed', {
|
||||
error: String(check.reason),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (await isTeamActiveForMemberWorkSync(team.teamName)) {
|
||||
activeTeamNames.push(team.teamName);
|
||||
if (check.value.active) {
|
||||
activeTeamNames.push(check.value.teamName);
|
||||
}
|
||||
}
|
||||
return activeTeamNames;
|
||||
|
|
@ -1924,6 +2029,7 @@ async function initializeServices(): Promise<void> {
|
|||
kanbanManager: new TeamKanbanManager(),
|
||||
membersMetaStore: new TeamMembersMetaStore(),
|
||||
isTeamActive: isTeamActiveForMemberWorkSync,
|
||||
isMemberActive: isMemberActiveForMemberWorkSync,
|
||||
canDispatchNudges: canDispatchMemberWorkSyncNudges,
|
||||
listLifecycleActiveTeamNames: listMemberWorkSyncLifecycleActiveTeamNames,
|
||||
extraBusySignals: [
|
||||
|
|
@ -2146,17 +2252,8 @@ async function initializeServices(): Promise<void> {
|
|||
return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now();
|
||||
});
|
||||
scheduleStartupTask(() => {
|
||||
void teamDataService
|
||||
.listTeams()
|
||||
.then(async (teams) => {
|
||||
const lifecycleActiveTeamNames = teams
|
||||
.filter(
|
||||
(team) =>
|
||||
!team.deletedAt &&
|
||||
(teamProvisioningService.isTeamAlive(team.teamName) ||
|
||||
teamProvisioningService.hasProvisioningRun(team.teamName))
|
||||
)
|
||||
.map((team) => team.teamName);
|
||||
void listMemberWorkSyncLifecycleActiveTeamNames()
|
||||
.then(async (lifecycleActiveTeamNames) => {
|
||||
await memberWorkSyncFeature?.replayPendingReports(lifecycleActiveTeamNames);
|
||||
await memberWorkSyncFeature?.enqueueStartupScan(lifecycleActiveTeamNames);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ const VALID_SECTIONS = new Set<ConfigSection>([
|
|||
'ssh',
|
||||
]);
|
||||
const MAX_SNOOZE_MINUTES = 24 * 60;
|
||||
const CODEX_CUSTOM_PROVIDER_MODEL_MAX_LENGTH = 200;
|
||||
const FIRST_PARTY_ANTHROPIC_HOSTS = new Set(['api.anthropic.com', 'api-staging.anthropic.com']);
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
|
|
@ -66,6 +67,16 @@ function isFiniteNumber(value: unknown): value is number {
|
|||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 31 || code === 127) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateAnthropicCompatibleBaseUrl(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
|
|
@ -90,6 +101,47 @@ function validateAnthropicCompatibleBaseUrl(value: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function validateCodexCustomProviderBaseUrl(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return 'providerConnections.codex.customProvider.baseUrl must use http:// or https://';
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
return 'providerConnections.codex.customProvider.baseUrl must not include credentials';
|
||||
}
|
||||
if (url.search || url.hash) {
|
||||
return 'providerConnections.codex.customProvider.baseUrl must not include query or fragment';
|
||||
}
|
||||
} catch {
|
||||
return 'providerConnections.codex.customProvider.baseUrl must be a valid URL';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateCodexCustomProviderModel(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.length > CODEX_CUSTOM_PROVIDER_MODEL_MAX_LENGTH) {
|
||||
return `providerConnections.codex.customProvider.model must be ${CODEX_CUSTOM_PROVIDER_MODEL_MAX_LENGTH} characters or fewer`;
|
||||
}
|
||||
|
||||
if (hasControlCharacter(trimmed)) {
|
||||
return 'providerConnections.codex.customProvider.model must not include control characters';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValidTrigger(trigger: unknown): trigger is NotificationTrigger {
|
||||
if (!isPlainObject(trigger)) {
|
||||
return false;
|
||||
|
|
@ -652,6 +704,83 @@ function validateProviderConnectionsSection(
|
|||
continue;
|
||||
}
|
||||
|
||||
if (connectionKey === 'customProvider') {
|
||||
if (!isPlainObject(connectionValue)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerConnections.codex.customProvider must be an object',
|
||||
};
|
||||
}
|
||||
|
||||
const customProvider: Partial<ProviderConnectionsConfig['codex']['customProvider']> = {};
|
||||
for (const [customKey, customValue] of Object.entries(connectionValue)) {
|
||||
if (customKey !== 'enabled' && customKey !== 'baseUrl' && customKey !== 'model') {
|
||||
return {
|
||||
valid: false,
|
||||
error: `providerConnections.codex.customProvider.${customKey} is not a valid setting`,
|
||||
};
|
||||
}
|
||||
|
||||
if (customKey === 'enabled') {
|
||||
if (typeof customValue !== 'boolean') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerConnections.codex.customProvider.enabled must be a boolean',
|
||||
};
|
||||
}
|
||||
customProvider.enabled = customValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (customKey === 'baseUrl') {
|
||||
if (typeof customValue !== 'string') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerConnections.codex.customProvider.baseUrl must be a string',
|
||||
};
|
||||
}
|
||||
|
||||
const error = validateCodexCustomProviderBaseUrl(customValue);
|
||||
if (error) {
|
||||
return { valid: false, error };
|
||||
}
|
||||
customProvider.baseUrl = customValue.trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof customValue !== 'string') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerConnections.codex.customProvider.model must be a string',
|
||||
};
|
||||
}
|
||||
|
||||
const error = validateCodexCustomProviderModel(customValue);
|
||||
if (error) {
|
||||
return { valid: false, error };
|
||||
}
|
||||
customProvider.model = customValue.trim();
|
||||
}
|
||||
|
||||
if (customProvider.enabled === true && !customProvider.baseUrl?.trim()) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerConnections.codex.customProvider.baseUrl is required when enabled',
|
||||
};
|
||||
}
|
||||
|
||||
if (customProvider.enabled === true && !customProvider.model?.trim()) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerConnections.codex.customProvider.model is required when enabled',
|
||||
};
|
||||
}
|
||||
|
||||
codexUpdate.customProvider =
|
||||
customProvider as ProviderConnectionsConfig['codex']['customProvider'];
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: `providerConnections.codex.${connectionKey} is not a valid setting`,
|
||||
|
|
|
|||
|
|
@ -282,6 +282,12 @@ export interface AnthropicCompatibleEndpointConfig {
|
|||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface CodexCustomProviderConfig {
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface ProviderConnectionsConfig {
|
||||
anthropic: {
|
||||
authMode: ProviderConnectionAuthMode;
|
||||
|
|
@ -290,6 +296,7 @@ export interface ProviderConnectionsConfig {
|
|||
};
|
||||
codex: {
|
||||
preferredAuthMode: CodexAccountAuthMode;
|
||||
customProvider: CodexCustomProviderConfig;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -392,6 +399,11 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
},
|
||||
codex: {
|
||||
preferredAuthMode: 'auto',
|
||||
customProvider: {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
|
|
@ -455,7 +467,8 @@ function normalizeConfiguredClaudeRootPath(value: unknown): string | null {
|
|||
|
||||
function normalizeCodexPreferredAuthMode(
|
||||
currentValue: unknown,
|
||||
legacyValue?: unknown
|
||||
legacyValue?: unknown,
|
||||
fallback: CodexAccountAuthMode = DEFAULT_CONFIG.providerConnections.codex.preferredAuthMode
|
||||
): CodexAccountAuthMode {
|
||||
const candidate = currentValue ?? legacyValue;
|
||||
|
||||
|
|
@ -467,7 +480,7 @@ function normalizeCodexPreferredAuthMode(
|
|||
return 'chatgpt';
|
||||
}
|
||||
|
||||
return DEFAULT_CONFIG.providerConnections.codex.preferredAuthMode;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeAnthropicCompatibleEndpointConfig(
|
||||
|
|
@ -486,6 +499,22 @@ function normalizeAnthropicCompatibleEndpointConfig(
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeCodexCustomProviderConfig(
|
||||
value: unknown,
|
||||
fallback: CodexCustomProviderConfig = DEFAULT_CONFIG.providerConnections.codex.customProvider
|
||||
): CodexCustomProviderConfig {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { ...fallback };
|
||||
}
|
||||
|
||||
const raw = value as Partial<CodexCustomProviderConfig>;
|
||||
return {
|
||||
enabled: typeof raw.enabled === 'boolean' ? raw.enabled : fallback.enabled,
|
||||
baseUrl: typeof raw.baseUrl === 'string' ? raw.baseUrl.trim() : fallback.baseUrl,
|
||||
model: typeof raw.model === 'string' ? raw.model.trim() : fallback.model,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldPersistNormalizedConfig(loaded: Partial<AppConfig>, normalized: AppConfig): boolean {
|
||||
return JSON.stringify(loaded) !== JSON.stringify(normalized);
|
||||
}
|
||||
|
|
@ -673,6 +702,9 @@ export class ConfigManager {
|
|||
loaded.providerConnections?.codex?.preferredAuthMode,
|
||||
(loaded.providerConnections?.codex as { authMode?: unknown } | undefined)?.authMode
|
||||
),
|
||||
customProvider: normalizeCodexCustomProviderConfig(
|
||||
loaded.providerConnections?.codex?.customProvider
|
||||
),
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
|
|
@ -789,11 +821,14 @@ export class ConfigManager {
|
|||
),
|
||||
},
|
||||
codex: {
|
||||
...this.config.providerConnections.codex,
|
||||
...(connectionUpdate.codex ?? {}),
|
||||
preferredAuthMode: normalizeCodexPreferredAuthMode(
|
||||
connectionUpdate.codex?.preferredAuthMode,
|
||||
(connectionUpdate.codex as { authMode?: unknown } | undefined)?.authMode
|
||||
(connectionUpdate.codex as { authMode?: unknown } | undefined)?.authMode,
|
||||
this.config.providerConnections.codex.preferredAuthMode
|
||||
),
|
||||
customProvider: normalizeCodexCustomProviderConfig(
|
||||
connectionUpdate.codex?.customProvider,
|
||||
this.config.providerConnections.codex.customProvider
|
||||
),
|
||||
},
|
||||
} as unknown as Partial<AppConfig[K]>;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import {
|
|||
import { ApiKeyService } from '../extensions/apikeys/ApiKeyService';
|
||||
import { ConfigManager } from '../infrastructure/ConfigManager';
|
||||
|
||||
import type { AnthropicCompatibleEndpointConfig } from '../infrastructure/ConfigManager';
|
||||
import type {
|
||||
AnthropicCompatibleEndpointConfig,
|
||||
CodexCustomProviderConfig,
|
||||
} from '../infrastructure/ConfigManager';
|
||||
import type {
|
||||
CodexAccountAuthMode,
|
||||
CodexAccountSnapshotDto,
|
||||
|
|
@ -27,6 +30,7 @@ import type {
|
|||
CliProviderAuthMode,
|
||||
CliProviderConnectionInfo,
|
||||
CliProviderId,
|
||||
CliProviderModelCatalog,
|
||||
CliProviderReasoningEffort,
|
||||
CliProviderStatus,
|
||||
} from '@shared/types';
|
||||
|
|
@ -84,6 +88,9 @@ const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY';
|
|||
const CODEX_CLI_PATH_ENV_VAR = 'CODEX_CLI_PATH';
|
||||
const CODEX_HOME_ENV_VAR = 'CODEX_HOME';
|
||||
const CODEX_FORCED_LOGIN_METHOD_ENV_VAR = 'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD';
|
||||
const CODEX_CUSTOM_PROVIDER_ID = 'agent_teams_custom';
|
||||
const CODEX_CUSTOM_PROVIDER_NAME = 'Agent Teams Custom';
|
||||
const CODEX_CUSTOM_PROVIDER_SETTINGS_KEY = 'agent_teams_custom_provider';
|
||||
const CODEX_NATIVE_BACKEND_ID = 'codex-native';
|
||||
const CODEX_LOGIN_STATUS_TIMEOUT_MS = 5_000;
|
||||
const ANTHROPIC_API_KEY_VERIFY_TIMEOUT_MS = 10_000;
|
||||
|
|
@ -276,15 +283,127 @@ function isCodexExecBinary(binaryPath?: string | null): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function tomlString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function buildCodexCustomProviderConfigOverrides(config: CodexCustomProviderConfig): string[] {
|
||||
return [
|
||||
`model_provider=${tomlString(CODEX_CUSTOM_PROVIDER_ID)}`,
|
||||
`model_providers.${CODEX_CUSTOM_PROVIDER_ID}.name=${tomlString(CODEX_CUSTOM_PROVIDER_NAME)}`,
|
||||
`model_providers.${CODEX_CUSTOM_PROVIDER_ID}.base_url=${tomlString(config.baseUrl.trim())}`,
|
||||
`model_providers.${CODEX_CUSTOM_PROVIDER_ID}.wire_api="responses"`,
|
||||
`model_providers.${CODEX_CUSTOM_PROVIDER_ID}.env_key=${tomlString(CODEX_NATIVE_API_KEY_ENV_VAR)}`,
|
||||
];
|
||||
}
|
||||
|
||||
function buildCodexLaunchArgs(
|
||||
binaryPath: string | null | undefined,
|
||||
loginMethod: 'chatgpt' | 'api',
|
||||
configOverrides: readonly string[] = []
|
||||
): string[] {
|
||||
if (isCodexExecBinary(binaryPath)) {
|
||||
return [
|
||||
'-c',
|
||||
`forced_login_method="${loginMethod}"`,
|
||||
...configOverrides.flatMap((override) => ['-c', override]),
|
||||
];
|
||||
}
|
||||
|
||||
const codexSettings: Record<string, unknown> = { forced_login_method: loginMethod };
|
||||
if (configOverrides.length > 0) {
|
||||
codexSettings[CODEX_CUSTOM_PROVIDER_SETTINGS_KEY] = {
|
||||
config_overrides: [...configOverrides],
|
||||
};
|
||||
}
|
||||
|
||||
return ['--settings', JSON.stringify({ codex: codexSettings })];
|
||||
}
|
||||
|
||||
function buildCodexForcedLoginLaunchArgs(
|
||||
binaryPath: string | null | undefined,
|
||||
loginMethod: 'chatgpt' | 'api'
|
||||
): string[] {
|
||||
if (isCodexExecBinary(binaryPath)) {
|
||||
return ['-c', `forced_login_method="${loginMethod}"`];
|
||||
return buildCodexLaunchArgs(binaryPath, loginMethod);
|
||||
}
|
||||
|
||||
function isCodexCustomProviderBaseUrlUsable(baseUrl: string): boolean {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ['--settings', JSON.stringify({ codex: { forced_login_method: loginMethod } })];
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
return (
|
||||
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||
!url.username &&
|
||||
!url.password &&
|
||||
!url.search &&
|
||||
!url.hash
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isCodexCustomProviderModelUsable(model: string): boolean {
|
||||
const trimmed = model.trim();
|
||||
if (trimmed.length === 0 || trimmed.length > 200) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < trimmed.length; index += 1) {
|
||||
const code = trimmed.charCodeAt(index);
|
||||
if (code <= 31 || code === 127) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function createCodexCustomProviderCatalog(
|
||||
config: CodexCustomProviderConfig
|
||||
): CliProviderModelCatalog {
|
||||
const model = config.model.trim();
|
||||
const now = new Date();
|
||||
const staleAt = new Date(now.getTime() + 10 * 60_000);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
providerId: 'codex',
|
||||
source: 'static-fallback',
|
||||
status: 'ready',
|
||||
fetchedAt: now.toISOString(),
|
||||
staleAt: staleAt.toISOString(),
|
||||
defaultModelId: model,
|
||||
defaultLaunchModel: model,
|
||||
models: [
|
||||
{
|
||||
id: model,
|
||||
launchModel: model,
|
||||
displayName: model,
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high'],
|
||||
defaultReasoningEffort: 'medium',
|
||||
supportsFastMode: false,
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: false,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'static-fallback',
|
||||
badgeLabel: 'custom',
|
||||
statusMessage: `Custom endpoint: ${config.baseUrl.trim()}`,
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
configReadState: 'skipped',
|
||||
appServerState: 'healthy',
|
||||
message:
|
||||
'Using app-managed Codex custom provider profile. Runtime support is verified during launch or model probe.',
|
||||
code: 'agent-teams-custom-provider',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyCodexRuntimeContextEnv(
|
||||
|
|
@ -426,6 +545,66 @@ export class ProviderConnectionService {
|
|||
return null;
|
||||
}
|
||||
|
||||
private getRawCodexCustomProvider(): CodexCustomProviderConfig {
|
||||
const config = this.configManager.getConfig().providerConnections.codex.customProvider;
|
||||
return {
|
||||
enabled: config.enabled === true,
|
||||
baseUrl: config.baseUrl.trim(),
|
||||
model: config.model.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
private getConfiguredCodexCustomProviderIssue(): string | null {
|
||||
const config = this.getRawCodexCustomProvider();
|
||||
if (config.enabled !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.getConfiguredAuthMode('codex') !== 'api_key') {
|
||||
return 'Codex custom provider is enabled but inactive because Codex auth mode is not API key.';
|
||||
}
|
||||
|
||||
if (!config.baseUrl) {
|
||||
return 'Codex custom provider is enabled, but no base URL is configured.';
|
||||
}
|
||||
|
||||
if (!isCodexCustomProviderBaseUrlUsable(config.baseUrl)) {
|
||||
return 'Codex custom provider base URL must use http:// or https:// and must not include credentials, query, or fragment.';
|
||||
}
|
||||
|
||||
if (!config.model) {
|
||||
return 'Codex custom provider is enabled, but no model is configured.';
|
||||
}
|
||||
|
||||
if (!isCodexCustomProviderModelUsable(config.model)) {
|
||||
return 'Codex custom provider model must be 200 characters or fewer and must not include control characters.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getConfiguredCodexCustomProvider(): CodexCustomProviderConfig | null {
|
||||
const config = this.getRawCodexCustomProvider();
|
||||
if (
|
||||
config.enabled !== true ||
|
||||
this.getConfiguredAuthMode('codex') !== 'api_key' ||
|
||||
!isCodexCustomProviderBaseUrlUsable(config.baseUrl) ||
|
||||
!isCodexCustomProviderModelUsable(config.model)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
baseUrl: config.baseUrl,
|
||||
model: config.model,
|
||||
};
|
||||
}
|
||||
|
||||
getConfiguredCodexCustomProviderModel(): string | null {
|
||||
return this.getConfiguredCodexCustomProvider()?.model ?? null;
|
||||
}
|
||||
|
||||
private getConfiguredAnthropicCompatibleEndpoint(): AnthropicCompatibleEndpointConfig | null {
|
||||
const endpoint =
|
||||
this.configManager.getConfig().providerConnections.anthropic.compatibleEndpoint;
|
||||
|
|
@ -776,6 +955,14 @@ export class ProviderConnectionService {
|
|||
return null;
|
||||
}
|
||||
|
||||
const customProviderIssue =
|
||||
this.getConfiguredAuthMode('codex') === 'api_key'
|
||||
? this.getConfiguredCodexCustomProviderIssue()
|
||||
: null;
|
||||
if (customProviderIssue) {
|
||||
return customProviderIssue;
|
||||
}
|
||||
|
||||
const snapshot = await this.getCodexLaunchSnapshot(env, {
|
||||
refreshRuntimeMissing: true,
|
||||
refreshBlockedLaunch: true,
|
||||
|
|
@ -902,11 +1089,16 @@ export class ProviderConnectionService {
|
|||
});
|
||||
|
||||
if (readiness.effectiveAuthMode === 'chatgpt') {
|
||||
return buildCodexForcedLoginLaunchArgs(binaryPath, 'chatgpt');
|
||||
return buildCodexLaunchArgs(binaryPath, 'chatgpt');
|
||||
}
|
||||
|
||||
if (readiness.effectiveAuthMode === 'api_key') {
|
||||
return buildCodexForcedLoginLaunchArgs(binaryPath, 'api');
|
||||
const customProvider = this.getConfiguredCodexCustomProvider();
|
||||
return buildCodexLaunchArgs(
|
||||
binaryPath,
|
||||
'api',
|
||||
customProvider ? buildCodexCustomProviderConfigOverrides(customProvider) : []
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
|
|
@ -929,6 +1121,47 @@ export class ProviderConnectionService {
|
|||
return withConnection;
|
||||
}
|
||||
|
||||
const customProvider = this.getConfiguredCodexCustomProvider();
|
||||
if (customProvider) {
|
||||
const catalog = createCodexCustomProviderCatalog(customProvider);
|
||||
const model = catalog.defaultLaunchModel ?? customProvider.model;
|
||||
const statusMessage =
|
||||
withConnection.statusMessage ??
|
||||
(withConnection.connection?.apiKeyConfigured
|
||||
? 'Codex custom provider configured'
|
||||
: 'Codex custom provider configured. API key is not set.');
|
||||
|
||||
return {
|
||||
...withConnection,
|
||||
models: [model],
|
||||
modelCatalog: catalog,
|
||||
subscriptionRateLimits: null,
|
||||
backend: withConnection.backend
|
||||
? {
|
||||
...withConnection.backend,
|
||||
endpointLabel: customProvider.baseUrl,
|
||||
}
|
||||
: {
|
||||
kind: CODEX_NATIVE_BACKEND_ID,
|
||||
label: 'Codex native',
|
||||
endpointLabel: customProvider.baseUrl,
|
||||
},
|
||||
runtimeCapabilities: {
|
||||
...withConnection.runtimeCapabilities,
|
||||
modelCatalog: {
|
||||
dynamic: false,
|
||||
source: catalog.source,
|
||||
},
|
||||
reasoningEffort: {
|
||||
supported: true,
|
||||
values: ['low', 'medium', 'high'] satisfies CliProviderReasoningEffort[],
|
||||
configPassthrough: true,
|
||||
},
|
||||
},
|
||||
statusMessage,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
options.hydrateModelCatalog === false &&
|
||||
|
|
@ -1140,6 +1373,14 @@ export class ProviderConnectionService {
|
|||
: (externalCredential?.label ?? null);
|
||||
const compatibleEndpoint =
|
||||
providerId === 'anthropic' ? await this.getAnthropicCompatibleEndpointConnectionInfo() : null;
|
||||
const codexCustomProvider =
|
||||
providerId === 'codex'
|
||||
? {
|
||||
config: this.getRawCodexCustomProvider(),
|
||||
issueMessage: this.getConfiguredCodexCustomProviderIssue(),
|
||||
active: Boolean(this.getConfiguredCodexCustomProvider()),
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
...capabilities,
|
||||
|
|
@ -1165,6 +1406,13 @@ export class ProviderConnectionService {
|
|||
launchAllowed: codexSnapshot.launchAllowed,
|
||||
launchIssueMessage: codexSnapshot.launchIssueMessage,
|
||||
launchReadinessState: codexSnapshot.launchReadinessState,
|
||||
customProvider: {
|
||||
enabled: codexCustomProvider?.config.enabled ?? false,
|
||||
active: codexCustomProvider?.active ?? false,
|
||||
baseUrl: codexCustomProvider?.config.baseUrl ?? '',
|
||||
model: codexCustomProvider?.config.model ?? '',
|
||||
issueMessage: codexCustomProvider?.issueMessage ?? null,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -102,7 +102,15 @@ export function getProviderModelProbeTimeoutMs(
|
|||
}
|
||||
}
|
||||
|
||||
export function getProviderPreflightModel(providerId: TeamProviderId | undefined): string {
|
||||
export function getProviderPreflightModel(
|
||||
providerId: TeamProviderId | undefined,
|
||||
options: { modelOverride?: string | null } = {}
|
||||
): string {
|
||||
const modelOverride = options.modelOverride?.trim();
|
||||
if (modelOverride) {
|
||||
return modelOverride;
|
||||
}
|
||||
|
||||
switch (resolveProbeProviderId(providerId)) {
|
||||
case 'codex':
|
||||
return 'gpt-5.4-mini';
|
||||
|
|
@ -114,6 +122,9 @@ export function getProviderPreflightModel(providerId: TeamProviderId | undefined
|
|||
}
|
||||
}
|
||||
|
||||
export function buildProviderPreflightPingArgs(providerId: TeamProviderId | undefined): string[] {
|
||||
return buildProviderModelProbeArgs(getProviderPreflightModel(providerId));
|
||||
export function buildProviderPreflightPingArgs(
|
||||
providerId: TeamProviderId | undefined,
|
||||
options: { modelOverride?: string | null } = {}
|
||||
): string[] {
|
||||
return buildProviderModelProbeArgs(getProviderPreflightModel(providerId, options));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const TRANSPORT_STAGE_LABELS: Record<string, string> = {
|
|||
process_spawned: 'process spawned',
|
||||
stdout_attached: 'stdout attached',
|
||||
cli_started: 'CLI started',
|
||||
startup_checkpoint: 'startup checkpoint',
|
||||
runtime_ready: 'runtime ready',
|
||||
inbox_poller_ready: 'inbox poller ready',
|
||||
mailbox_bootstrap_written: 'bootstrap mailbox row written',
|
||||
|
|
|
|||
|
|
@ -226,6 +226,8 @@ const WORKSPACE_TRUST_FAILURE_PATTERN =
|
|||
const BOOTSTRAP_TRANSPORT_EVIDENCE_PATTERN = new RegExp(
|
||||
[
|
||||
'mailbox_bootstrap_written',
|
||||
'startup_checkpoint',
|
||||
'last runtime stage',
|
||||
'bootstrap_prompt_observed',
|
||||
'bootstrap_submit_attempted',
|
||||
'bootstrap_submitted',
|
||||
|
|
@ -341,13 +343,15 @@ export function extractLaunchBootstrapTransportBreadcrumb(
|
|||
): LaunchBootstrapTransportBreadcrumb {
|
||||
const parts = collectLaunchFailureSearchParts(input);
|
||||
const combined = parts.join('\n');
|
||||
const lastStageMatches = [...combined.matchAll(/last transport stage:\s*([^;\n]+)/gi)];
|
||||
const lastStageMatches = [
|
||||
...combined.matchAll(/last (?:transport|runtime) stage:\s*([^;\n]+)/gi),
|
||||
];
|
||||
const retryableMatches = [
|
||||
...combined.matchAll(/bootstrap_submit_rejected[^\n]*(?:retryable[=:]\s*(true|false))/gi),
|
||||
];
|
||||
const evidence = firstEvidence(
|
||||
parts,
|
||||
/bootstrap_submit_|mailbox_bootstrap_written|bootstrap_prompt_observed|bootstrap_submitted|last transport stage|no stdin data received|local prompt handler/i
|
||||
/bootstrap_submit_|mailbox_bootstrap_written|startup_checkpoint|bootstrap_prompt_observed|bootstrap_submitted|last (?:transport|runtime) stage|no stdin data received|local prompt handler/i
|
||||
).map(redactLaunchFailureArtifactText);
|
||||
const retryableRaw = retryableMatches.at(-1)?.[1]?.toLowerCase();
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1478,7 +1478,11 @@ function classifyDeterministicBootstrapFailure(reason: string): {
|
|||
}
|
||||
|
||||
function getPreflightPingArgs(providerId: TeamProviderId | undefined): string[] {
|
||||
return buildProviderPreflightPingArgs(providerId);
|
||||
const codexCustomModel =
|
||||
resolveTeamProviderId(providerId) === 'codex'
|
||||
? ProviderConnectionService.getInstance().getConfiguredCodexCustomProviderModel()
|
||||
: null;
|
||||
return buildProviderPreflightPingArgs(providerId, { modelOverride: codexCustomModel });
|
||||
}
|
||||
|
||||
function getPreflightTimeoutMs(providerId: TeamProviderId | undefined): number {
|
||||
|
|
@ -3286,6 +3290,17 @@ interface OpenCodeMemberInboxDelivery {
|
|||
userVisibleImpact?: OpenCodeRuntimeDeliveryUserVisibleImpact;
|
||||
}
|
||||
|
||||
class InboxRelayInFlightTimeoutError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InboxRelayInFlightTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
function isInboxRelayInFlightTimeoutError(error: unknown): error is InboxRelayInFlightTimeoutError {
|
||||
return error instanceof InboxRelayInFlightTimeoutError;
|
||||
}
|
||||
|
||||
type OpenCodeVisibleReplyCorrelation = NonNullable<
|
||||
OpenCodePromptDeliveryLedgerRecord['visibleReplyCorrelation']
|
||||
>;
|
||||
|
|
@ -3444,6 +3459,15 @@ function getOpenCodeInboxRelayPriority(
|
|||
return 0;
|
||||
}
|
||||
|
||||
function getMemberInboxRelayPriority(
|
||||
message: Pick<InboxMessage, 'messageKind' | 'source'>
|
||||
): number {
|
||||
if (message.messageKind === 'member_work_sync_nudge') {
|
||||
return 30;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getLeadInboxRelayPriority(message: Pick<InboxMessage, 'messageKind'>): number {
|
||||
if (message.messageKind === 'member_work_sync_nudge') {
|
||||
return 30;
|
||||
|
|
@ -3478,6 +3502,13 @@ function compareOpenCodeInboxRelayMessagesByPriority(
|
|||
return compareInboxRelayMessages(a, b, getOpenCodeInboxRelayPriority);
|
||||
}
|
||||
|
||||
function compareMemberInboxRelayMessagesByPriority(
|
||||
a: Pick<InboxMessage, 'messageKind' | 'source' | 'timestamp'> & { messageId: string },
|
||||
b: Pick<InboxMessage, 'messageKind' | 'source' | 'timestamp'> & { messageId: string }
|
||||
): number {
|
||||
return compareInboxRelayMessages(a, b, getMemberInboxRelayPriority);
|
||||
}
|
||||
|
||||
function compareLeadInboxRelayMessagesByPriority(
|
||||
a: Pick<InboxMessage, 'messageKind' | 'source' | 'timestamp'> & { messageId: string },
|
||||
b: Pick<InboxMessage, 'messageKind' | 'source' | 'timestamp'> & { messageId: string }
|
||||
|
|
@ -3527,6 +3558,7 @@ export class TeamProvisioningService {
|
|||
private static readonly RETAINED_PROVISIONING_PROGRESS_TTL_MS = 5 * 60_000;
|
||||
private static readonly OPENCODE_RUNTIME_DELIVERY_ADVISORY_EVENT_TTL_MS = 24 * 60 * 60_000;
|
||||
private static readonly OPENCODE_RUNTIME_DELIVERY_LEAD_NOTICE_TTL_MS = 24 * 60 * 60_000;
|
||||
private static readonly INBOX_RELAY_IN_FLIGHT_TIMEOUT_MS = 2 * 60_000;
|
||||
|
||||
private readonly runs = new Map<string, ProvisioningRun>();
|
||||
private readonly provisioningRunByTeam = new Map<string, string>();
|
||||
|
|
@ -6162,6 +6194,27 @@ export class TeamProvisioningService {
|
|||
return enabled;
|
||||
}
|
||||
|
||||
private async markOpenCodePromptLedgerFailedTerminal(input: {
|
||||
ledger: OpenCodePromptDeliveryLedgerStore;
|
||||
id: string;
|
||||
reason: string;
|
||||
diagnostics?: string[];
|
||||
failedAt: string;
|
||||
eventContext?: Record<string, unknown>;
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
const failed = await input.ledger.markFailedTerminal({
|
||||
id: input.id,
|
||||
reason: input.reason,
|
||||
...(input.diagnostics ? { diagnostics: input.diagnostics } : {}),
|
||||
failedAt: input.failedAt,
|
||||
});
|
||||
this.logOpenCodePromptDeliveryEvent('opencode_prompt_delivery_terminal_failure', failed, {
|
||||
reason: input.reason,
|
||||
...(input.eventContext ?? {}),
|
||||
});
|
||||
return failed;
|
||||
}
|
||||
|
||||
private async findOpenCodeVisibleReplyByRelayOfMessageId(input: {
|
||||
teamName: string;
|
||||
replyRecipient?: string | null;
|
||||
|
|
@ -7243,7 +7296,8 @@ export class TeamProvisioningService {
|
|||
input.ledgerRecord.maxSessionRefreshAttempts ??
|
||||
OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS;
|
||||
if ((input.ledgerRecord.sessionRefreshAttempts ?? 0) >= maxSessionRefreshAttempts) {
|
||||
return await input.ledger.markFailedTerminal({
|
||||
return await this.markOpenCodePromptLedgerFailedTerminal({
|
||||
ledger: input.ledger,
|
||||
id: input.ledgerRecord.id,
|
||||
reason: 'opencode_session_stale_observe_loop_after_accepted_prompt',
|
||||
diagnostics: [
|
||||
|
|
@ -7251,6 +7305,11 @@ export class TeamProvisioningService {
|
|||
`OpenCode session stayed stale while observing an accepted prompt after ${maxSessionRefreshAttempts} attempt(s).`,
|
||||
],
|
||||
failedAt: now,
|
||||
eventContext: {
|
||||
observeOnlyAfterAcceptedPrompt: true,
|
||||
sessionRefreshAttempts: input.ledgerRecord.sessionRefreshAttempts ?? 0,
|
||||
maxSessionRefreshAttempts,
|
||||
},
|
||||
});
|
||||
}
|
||||
const delayMs = OPENCODE_PROMPT_DELIVERY_RETRY_DELAY_MS;
|
||||
|
|
@ -7287,7 +7346,8 @@ export class TeamProvisioningService {
|
|||
input.ledgerRecord.maxSessionRefreshAttempts ??
|
||||
OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS;
|
||||
if ((input.ledgerRecord.sessionRefreshAttempts ?? 0) >= maxSessionRefreshAttempts) {
|
||||
return await input.ledger.markFailedTerminal({
|
||||
return await this.markOpenCodePromptLedgerFailedTerminal({
|
||||
ledger: input.ledger,
|
||||
id: input.ledgerRecord.id,
|
||||
reason: 'opencode_session_refresh_loop_after_resolved_behavior_changed',
|
||||
diagnostics: [
|
||||
|
|
@ -7295,6 +7355,11 @@ export class TeamProvisioningService {
|
|||
`OpenCode session stayed stale after ${maxSessionRefreshAttempts} session refresh attempt(s).`,
|
||||
],
|
||||
failedAt: now,
|
||||
eventContext: {
|
||||
retry: true,
|
||||
sessionRefreshAttempts: input.ledgerRecord.sessionRefreshAttempts ?? 0,
|
||||
maxSessionRefreshAttempts,
|
||||
},
|
||||
});
|
||||
}
|
||||
const delayMs = this.getOpenCodeDeliveryNextDelayMs({
|
||||
|
|
@ -7338,10 +7403,12 @@ export class TeamProvisioningService {
|
|||
input.ledgerRecord.attempts >= input.ledgerRecord.maxAttempts &&
|
||||
!canScheduleNoAssistantRecoveryRetry
|
||||
) {
|
||||
return await input.ledger.markFailedTerminal({
|
||||
return await this.markOpenCodePromptLedgerFailedTerminal({
|
||||
ledger: input.ledger,
|
||||
id: input.ledgerRecord.id,
|
||||
reason: input.reason,
|
||||
failedAt: now,
|
||||
eventContext: { retry: input.retry },
|
||||
});
|
||||
}
|
||||
const delayMs = this.getOpenCodeDeliveryNextDelayMs({
|
||||
|
|
@ -11626,6 +11693,33 @@ export class TeamProvisioningService {
|
|||
return `opencode:${this.getMemberRelayKey(teamName, memberName)}`;
|
||||
}
|
||||
|
||||
private async waitForInboxRelayInFlight<T>(input: {
|
||||
promise: Promise<T>;
|
||||
relayName: string;
|
||||
relayKey: string;
|
||||
}): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
input.promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(
|
||||
new InboxRelayInFlightTimeoutError(
|
||||
`${input.relayName} timed out after ${TeamProvisioningService.INBOX_RELAY_IN_FLIGHT_TIMEOUT_MS}ms: ${input.relayKey}`
|
||||
)
|
||||
);
|
||||
}, TeamProvisioningService.INBOX_RELAY_IN_FLIGHT_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeRelayCandidateText(text: string): string {
|
||||
return stripAgentBlocks(String(text)).trim().replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
|
@ -23166,7 +23260,23 @@ export class TeamProvisioningService {
|
|||
const relayKey = this.getMemberRelayKey(teamName, memberName);
|
||||
const existing = this.memberInboxRelayInFlight.get(relayKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
try {
|
||||
return await this.waitForInboxRelayInFlight({
|
||||
promise: existing,
|
||||
relayName: 'member_inbox_relay',
|
||||
relayKey,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isInboxRelayInFlightTimeoutError(error)) {
|
||||
throw error;
|
||||
}
|
||||
logger.warn(`[${teamName}] member_inbox_relay_timed_out: ${getErrorMessage(error)}`);
|
||||
return 0;
|
||||
} finally {
|
||||
if (this.memberInboxRelayInFlight.get(relayKey) === existing) {
|
||||
this.memberInboxRelayInFlight.delete(relayKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const work = (async (): Promise<number> => {
|
||||
|
|
@ -23239,7 +23349,9 @@ export class TeamProvisioningService {
|
|||
if (actionableUnread.length === 0) return 0;
|
||||
|
||||
const MAX_RELAY = 10;
|
||||
const batch = actionableUnread.slice(0, MAX_RELAY);
|
||||
const batch = [...actionableUnread]
|
||||
.sort(compareMemberInboxRelayMessagesByPriority)
|
||||
.slice(0, MAX_RELAY);
|
||||
|
||||
this.armSilentTeammateForward(run, memberName, 'member_inbox_relay');
|
||||
const rememberedRelayIds = this.rememberPendingInboxRelayCandidates(run, memberName, batch);
|
||||
|
|
@ -23320,7 +23432,17 @@ export class TeamProvisioningService {
|
|||
|
||||
this.memberInboxRelayInFlight.set(relayKey, work);
|
||||
try {
|
||||
return await work;
|
||||
return await this.waitForInboxRelayInFlight({
|
||||
promise: work,
|
||||
relayName: 'member_inbox_relay',
|
||||
relayKey,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isInboxRelayInFlightTimeoutError(error)) {
|
||||
throw error;
|
||||
}
|
||||
logger.warn(`[${teamName}] member_inbox_relay_timed_out: ${getErrorMessage(error)}`);
|
||||
return 0;
|
||||
} finally {
|
||||
if (this.memberInboxRelayInFlight.get(relayKey) === work) {
|
||||
this.memberInboxRelayInFlight.delete(relayKey);
|
||||
|
|
@ -23486,13 +23608,66 @@ export class TeamProvisioningService {
|
|||
if (existing) {
|
||||
const onlyMessageId = options.onlyMessageId?.trim();
|
||||
if (!onlyMessageId) {
|
||||
return existing;
|
||||
try {
|
||||
return await this.waitForInboxRelayInFlight({
|
||||
promise: existing,
|
||||
relayName: 'opencode_member_inbox_relay',
|
||||
relayKey,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isInboxRelayInFlightTimeoutError(error)) {
|
||||
throw error;
|
||||
}
|
||||
const diagnostic = `opencode_member_inbox_relay_timed_out: ${getErrorMessage(error)}`;
|
||||
logger.warn(`[${teamName}] ${diagnostic}`);
|
||||
return {
|
||||
relayed: 0,
|
||||
attempted: 0,
|
||||
delivered: 0,
|
||||
failed: 1,
|
||||
lastDelivery: {
|
||||
delivered: false,
|
||||
accepted: false,
|
||||
responsePending: false,
|
||||
reason: 'opencode_member_inbox_relay_timed_out',
|
||||
diagnostics: [diagnostic],
|
||||
},
|
||||
diagnostics: [diagnostic],
|
||||
};
|
||||
} finally {
|
||||
if (this.openCodeMemberInboxRelayInFlight.get(relayKey) === existing) {
|
||||
this.openCodeMemberInboxRelayInFlight.delete(relayKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
const inboxMessages = await this.inboxReader
|
||||
.getMessagesFor(teamName, memberName)
|
||||
.catch(() => []);
|
||||
const targetMessage = inboxMessages.find((message) => message.messageId === onlyMessageId);
|
||||
if (targetMessage?.read) {
|
||||
if (targetMessage.messageKind === 'member_work_sync_nudge') {
|
||||
this.scheduleOpenCodeMemberInboxDeliveryWake({
|
||||
teamName,
|
||||
memberName,
|
||||
messageId: onlyMessageId,
|
||||
delayMs: 500,
|
||||
});
|
||||
const diagnostic = `opencode_work_sync_read_commit_waiting_for_active_relay: ${onlyMessageId}`;
|
||||
return {
|
||||
relayed: 0,
|
||||
attempted: 1,
|
||||
delivered: 0,
|
||||
failed: 0,
|
||||
lastDelivery: {
|
||||
delivered: true,
|
||||
accepted: false,
|
||||
responsePending: true,
|
||||
reason: 'opencode_work_sync_read_commit_waiting_for_active_relay',
|
||||
diagnostics: [diagnostic],
|
||||
},
|
||||
diagnostics: [diagnostic],
|
||||
};
|
||||
}
|
||||
return {
|
||||
relayed: 0,
|
||||
attempted: 1,
|
||||
|
|
@ -23576,7 +23751,7 @@ export class TeamProvisioningService {
|
|||
const onlyMessageId = options.onlyMessageId?.trim();
|
||||
if (onlyMessageId) {
|
||||
const targetMessage = inboxMessages.find((message) => message.messageId === onlyMessageId);
|
||||
if (targetMessage?.read) {
|
||||
if (targetMessage?.read && targetMessage.messageKind !== 'member_work_sync_nudge') {
|
||||
return {
|
||||
relayed: 0,
|
||||
attempted: 1,
|
||||
|
|
@ -23603,8 +23778,13 @@ export class TeamProvisioningService {
|
|||
}
|
||||
const unread = inboxMessages
|
||||
.filter((message): message is InboxMessage & { messageId: string } => {
|
||||
if (message.read) return false;
|
||||
if (onlyMessageId && message.messageId !== onlyMessageId) return false;
|
||||
if (
|
||||
message.read &&
|
||||
(!onlyMessageId || message.messageKind !== 'member_work_sync_nudge')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (typeof message.text !== 'string' || message.text.trim().length === 0) return false;
|
||||
return this.hasStableMessageId(message);
|
||||
})
|
||||
|
|
@ -23813,17 +23993,14 @@ export class TeamProvisioningService {
|
|||
pendingRecord
|
||||
);
|
||||
}
|
||||
failedRecord = await promptLedger.markFailedTerminal({
|
||||
failedRecord = await this.markOpenCodePromptLedgerFailedTerminal({
|
||||
ledger: promptLedger,
|
||||
id: pendingRecord.id,
|
||||
reason: attachmentPayloads.reason,
|
||||
diagnostics: attachmentPayloads.diagnostics,
|
||||
failedAt: nowIso(),
|
||||
eventContext: { attachmentPayloadUnavailable: true },
|
||||
});
|
||||
this.logOpenCodePromptDeliveryEvent(
|
||||
'opencode_prompt_delivery_response_observed',
|
||||
failedRecord,
|
||||
{ attachmentPayloadUnavailable: true }
|
||||
);
|
||||
} catch (error) {
|
||||
const diagnostic = `opencode_inbox_attachment_terminal_ledger_failed: ${getErrorMessage(
|
||||
error
|
||||
|
|
@ -23952,7 +24129,31 @@ export class TeamProvisioningService {
|
|||
|
||||
this.openCodeMemberInboxRelayInFlight.set(relayKey, work);
|
||||
try {
|
||||
return await work;
|
||||
return await this.waitForInboxRelayInFlight({
|
||||
promise: work,
|
||||
relayName: 'opencode_member_inbox_relay',
|
||||
relayKey,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isInboxRelayInFlightTimeoutError(error)) {
|
||||
throw error;
|
||||
}
|
||||
const diagnostic = `opencode_member_inbox_relay_timed_out: ${getErrorMessage(error)}`;
|
||||
logger.warn(`[${teamName}] ${diagnostic}`);
|
||||
return {
|
||||
relayed: 0,
|
||||
attempted: options.onlyMessageId ? 1 : 0,
|
||||
delivered: 0,
|
||||
failed: 1,
|
||||
lastDelivery: {
|
||||
delivered: false,
|
||||
accepted: false,
|
||||
responsePending: false,
|
||||
reason: 'opencode_member_inbox_relay_timed_out',
|
||||
diagnostics: [diagnostic],
|
||||
},
|
||||
diagnostics: [diagnostic],
|
||||
};
|
||||
} finally {
|
||||
if (this.openCodeMemberInboxRelayInFlight.get(relayKey) === work) {
|
||||
this.openCodeMemberInboxRelayInFlight.delete(relayKey);
|
||||
|
|
@ -24261,7 +24462,23 @@ export class TeamProvisioningService {
|
|||
async relayLeadInboxMessages(teamName: string): Promise<number> {
|
||||
const existing = this.leadInboxRelayInFlight.get(teamName);
|
||||
if (existing) {
|
||||
return existing;
|
||||
try {
|
||||
return await this.waitForInboxRelayInFlight({
|
||||
promise: existing,
|
||||
relayName: 'lead_inbox_relay',
|
||||
relayKey: teamName,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isInboxRelayInFlightTimeoutError(error)) {
|
||||
throw error;
|
||||
}
|
||||
logger.warn(`[${teamName}] lead_inbox_relay_timed_out: ${getErrorMessage(error)}`);
|
||||
return 0;
|
||||
} finally {
|
||||
if (this.leadInboxRelayInFlight.get(teamName) === existing) {
|
||||
this.leadInboxRelayInFlight.delete(teamName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const work = (async (): Promise<number> => {
|
||||
|
|
@ -24817,7 +25034,17 @@ export class TeamProvisioningService {
|
|||
|
||||
this.leadInboxRelayInFlight.set(teamName, work);
|
||||
try {
|
||||
return await work;
|
||||
return await this.waitForInboxRelayInFlight({
|
||||
promise: work,
|
||||
relayName: 'lead_inbox_relay',
|
||||
relayKey: teamName,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isInboxRelayInFlightTimeoutError(error)) {
|
||||
throw error;
|
||||
}
|
||||
logger.warn(`[${teamName}] lead_inbox_relay_timed_out: ${getErrorMessage(error)}`);
|
||||
return 0;
|
||||
} finally {
|
||||
if (this.leadInboxRelayInFlight.get(teamName) === work) {
|
||||
this.leadInboxRelayInFlight.delete(teamName);
|
||||
|
|
|
|||
|
|
@ -11,15 +11,61 @@ interface TeamReconcileDrainState {
|
|||
lastTrigger: TeamReconcileTrigger | null;
|
||||
}
|
||||
|
||||
const DEFAULT_TEAM_RECONCILE_DRAIN_RUN_TIMEOUT_MS = 2 * 60_000;
|
||||
|
||||
export interface TeamReconcileDrainScheduler {
|
||||
schedule(teamName: string, trigger: TeamReconcileTrigger): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
class TeamReconcileDrainTimeoutError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TeamReconcileDrainTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
async function runWithTimeout(options: {
|
||||
run: () => Promise<void>;
|
||||
timeoutMs: number;
|
||||
teamName: string;
|
||||
trigger: TeamReconcileTrigger;
|
||||
}): Promise<void> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
await Promise.race([
|
||||
options.run(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(
|
||||
new TeamReconcileDrainTimeoutError(
|
||||
`team reconcile drain timed out for ${options.teamName} source=${options.trigger.source} detail=${options.trigger.detail} after ${options.timeoutMs}ms`
|
||||
)
|
||||
);
|
||||
}, options.timeoutMs);
|
||||
unrefTimer(timeout);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createTeamReconcileDrainScheduler(options: {
|
||||
run: (teamName: string, trigger: TeamReconcileTrigger) => Promise<void>;
|
||||
runTimeoutMs?: number;
|
||||
}): TeamReconcileDrainScheduler {
|
||||
const states = new Map<string, TeamReconcileDrainState>();
|
||||
const runTimeoutMs = Math.max(
|
||||
1,
|
||||
options.runTimeoutMs ?? DEFAULT_TEAM_RECONCILE_DRAIN_RUN_TIMEOUT_MS
|
||||
);
|
||||
let disposed = false;
|
||||
|
||||
const drainTeam = async (teamName: string): Promise<void> => {
|
||||
|
|
@ -40,9 +86,18 @@ export function createTeamReconcileDrainScheduler(options: {
|
|||
}
|
||||
|
||||
try {
|
||||
await options.run(teamName, trigger);
|
||||
await runWithTimeout({
|
||||
run: () => options.run(teamName, trigger),
|
||||
timeoutMs: runTimeoutMs,
|
||||
teamName,
|
||||
trigger,
|
||||
});
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
if (error instanceof TeamReconcileDrainTimeoutError && !state.pending) {
|
||||
state.pending = true;
|
||||
state.lastTrigger = trigger;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (!disposed) {
|
||||
|
|
@ -54,10 +109,7 @@ export function createTeamReconcileDrainScheduler(options: {
|
|||
state.running = false;
|
||||
if (disposed || !state.pending) {
|
||||
states.delete(teamName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
} else if (failed) {
|
||||
void drainTeam(teamName).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import {
|
||||
isProvisionedButNotAliveFailureReason,
|
||||
stripProcessTableUnavailableDiagnosticSuffix,
|
||||
} from '@shared/utils/teamLaunchFailureReason';
|
||||
import { stripProcessTableUnavailableDiagnosticSuffix } from '@shared/utils/teamLaunchFailureReason';
|
||||
|
||||
import { mentionsProcessTableUnavailable } from './TeamProvisioningLaunchDiagnostics';
|
||||
import { isBootstrapInstructionPrompt } from './TeamProvisioningPromptBuilders';
|
||||
|
|
@ -72,7 +69,20 @@ export function isBootstrapMcpResourceReadFailureReason(reason?: string): boolea
|
|||
}
|
||||
|
||||
export function isBootstrapCheckInTimeoutFailureReason(reason?: string): boolean {
|
||||
return reason?.trim() === 'Teammate was registered but did not bootstrap-confirm before timeout.';
|
||||
const text = reason?.trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
if (text === 'Teammate was registered but did not bootstrap-confirm before timeout.') {
|
||||
return true;
|
||||
}
|
||||
const normalized = text.toLowerCase();
|
||||
return (
|
||||
normalized.includes('bootstrap prompt was submitted') &&
|
||||
normalized.includes('did not bootstrap-confirm') &&
|
||||
normalized.includes('submitted-confirmation timeout') &&
|
||||
normalized.includes('last transport stage: bootstrap_submitted')
|
||||
);
|
||||
}
|
||||
|
||||
export function isBootstrapInstructionPromptFailureReason(reason?: string): boolean {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { TeamLogSourceTracker } from '../TeamLogSourceTracker';
|
||||
import type { TeamChangeEvent } from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:ActiveTeamRegistry');
|
||||
|
||||
interface TeamAliveProcessesReader {
|
||||
listAliveProcessTeams(): Promise<string[]>;
|
||||
}
|
||||
|
|
@ -23,6 +27,8 @@ function unrefBackgroundTimer(timer: ReturnType<typeof setInterval>): void {
|
|||
|
||||
export class ActiveTeamRegistry {
|
||||
private readonly activeTeams = new Set<string>();
|
||||
private readonly activationInFlight = new Set<string>();
|
||||
private activationGeneration = 0;
|
||||
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
|
|
@ -41,8 +47,7 @@ export class ActiveTeamRegistry {
|
|||
(event.type === 'lead-activity' && event.detail !== 'offline')
|
||||
) {
|
||||
if (!this.activeTeams.has(event.teamName)) {
|
||||
this.activeTeams.add(event.teamName);
|
||||
void this.teamLogSourceTracker.enableTracking(event.teamName, 'stall_monitor');
|
||||
void this.activateTeam(event.teamName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -70,6 +75,7 @@ export class ActiveTeamRegistry {
|
|||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.activationGeneration += 1;
|
||||
if (this.reconcileTimer) {
|
||||
clearInterval(this.reconcileTimer);
|
||||
this.reconcileTimer = null;
|
||||
|
|
@ -85,6 +91,7 @@ export class ActiveTeamRegistry {
|
|||
}
|
||||
|
||||
async reconcile(): Promise<void> {
|
||||
const reconcileGeneration = this.activationGeneration;
|
||||
const aliveTeams = await this.teamDataService.listAliveProcessTeams();
|
||||
const aliveSet = new Set(aliveTeams);
|
||||
|
||||
|
|
@ -92,8 +99,7 @@ export class ActiveTeamRegistry {
|
|||
if (this.activeTeams.has(teamName)) {
|
||||
continue;
|
||||
}
|
||||
this.activeTeams.add(teamName);
|
||||
await this.teamLogSourceTracker.enableTracking(teamName, 'stall_monitor');
|
||||
await this.activateTeam(teamName, reconcileGeneration);
|
||||
}
|
||||
|
||||
for (const teamName of [...this.activeTeams]) {
|
||||
|
|
@ -104,4 +110,41 @@ export class ActiveTeamRegistry {
|
|||
await this.teamLogSourceTracker.disableTracking(teamName, 'stall_monitor');
|
||||
}
|
||||
}
|
||||
|
||||
private async activateTeam(
|
||||
teamName: string,
|
||||
expectedGeneration = this.activationGeneration
|
||||
): Promise<void> {
|
||||
if (expectedGeneration !== this.activationGeneration) {
|
||||
return;
|
||||
}
|
||||
if (this.activeTeams.has(teamName) || this.activationInFlight.has(teamName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activationInFlight.add(teamName);
|
||||
const activationGeneration = this.activationGeneration;
|
||||
try {
|
||||
await this.teamLogSourceTracker.enableTracking(teamName, 'stall_monitor');
|
||||
if (activationGeneration !== this.activationGeneration) {
|
||||
await this.disableStaleActivation(teamName);
|
||||
return;
|
||||
}
|
||||
this.activeTeams.add(teamName);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to enable stall-monitor tracking for ${teamName}: ${String(error)}`);
|
||||
} finally {
|
||||
this.activationInFlight.delete(teamName);
|
||||
}
|
||||
}
|
||||
|
||||
private async disableStaleActivation(teamName: string): Promise<void> {
|
||||
try {
|
||||
await this.teamLogSourceTracker.disableTracking(teamName, 'stall_monitor');
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to disable stale stall-monitor tracking for ${teamName}: ${String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import * as path from 'path';
|
|||
import { atomicWriteAsync } from '../atomicWrite';
|
||||
import { withFileLock } from '../fileLock';
|
||||
|
||||
import { getTeamTaskStallAlertCooldownMs } from './featureGates';
|
||||
|
||||
import type {
|
||||
TaskStallEvaluation,
|
||||
TaskStallJournalEntry,
|
||||
|
|
@ -15,7 +17,28 @@ function isValidState(value: unknown): value is TaskStallJournalState {
|
|||
return value === 'suspected' || value === 'alert_ready' || value === 'alerted';
|
||||
}
|
||||
|
||||
function parseTime(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
|
||||
export interface TeamTaskStallJournalOptions {
|
||||
alertCooldownMs?: number;
|
||||
}
|
||||
|
||||
export class TeamTaskStallJournal {
|
||||
private readonly alertCooldownMs: number;
|
||||
|
||||
constructor(options: TeamTaskStallJournalOptions = {}) {
|
||||
this.alertCooldownMs =
|
||||
options.alertCooldownMs != null && options.alertCooldownMs > 0
|
||||
? options.alertCooldownMs
|
||||
: getTeamTaskStallAlertCooldownMs();
|
||||
}
|
||||
|
||||
private getFilePath(teamName: string): string {
|
||||
return path.join(getTeamsBasePath(), teamName, 'stall-monitor-journal.json');
|
||||
}
|
||||
|
|
@ -67,6 +90,7 @@ export class TeamTaskStallJournal {
|
|||
epochKey,
|
||||
teamName: args.teamName,
|
||||
taskId: evaluation.taskId,
|
||||
...(evaluation.memberName ? { memberName: evaluation.memberName } : {}),
|
||||
branch: evaluation.branch,
|
||||
signal: evaluation.signal,
|
||||
state: 'suspected',
|
||||
|
|
@ -78,7 +102,23 @@ export class TeamTaskStallJournal {
|
|||
}
|
||||
|
||||
existing.updatedAt = args.now;
|
||||
if (evaluation.memberName) {
|
||||
existing.memberName = evaluation.memberName;
|
||||
}
|
||||
if (existing.state === 'alerted') {
|
||||
const nowMs = parseTime(args.now) ?? Date.now();
|
||||
const alertedAtMs = parseTime(existing.alertedAt);
|
||||
if (
|
||||
alertedAtMs != null &&
|
||||
alertedAtMs <= nowMs &&
|
||||
nowMs - alertedAtMs < this.alertCooldownMs
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.state = 'alert_ready';
|
||||
existing.consecutiveScans += 1;
|
||||
readyEvaluations.push(evaluation);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -138,6 +178,9 @@ export class TeamTaskStallJournal {
|
|||
)
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
...(typeof entry.memberName === 'string' && entry.memberName.trim()
|
||||
? { memberName: entry.memberName }
|
||||
: {}),
|
||||
...(entry.alertedAt ? { alertedAt: entry.alertedAt } : {}),
|
||||
}));
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,16 @@ interface TeamObservationState {
|
|||
lastActivationAtMs: number;
|
||||
}
|
||||
|
||||
interface TeamTaskStallMonitorOptions {
|
||||
scanTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface TeamTaskStallScanRun {
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_TEAM_TASK_STALL_SCAN_TIMEOUT_MS = 2 * 60_000;
|
||||
|
||||
function unrefBackgroundTimer(timer: ReturnType<typeof setTimeout>): void {
|
||||
const maybeTimer = timer as { unref?: () => void };
|
||||
maybeTimer.unref?.();
|
||||
|
|
@ -37,14 +47,21 @@ export class TeamTaskStallMonitor {
|
|||
private scanInFlight = false;
|
||||
private started = false;
|
||||
private readonly observationByTeam = new Map<string, TeamObservationState>();
|
||||
private readonly scanTimeoutMs: number;
|
||||
|
||||
constructor(
|
||||
private readonly registry: ActiveTeamRegistry,
|
||||
private readonly snapshotSource: TeamTaskStallSnapshotSource,
|
||||
private readonly policy: TeamTaskStallPolicy,
|
||||
private readonly journal: TeamTaskStallJournal,
|
||||
private readonly notifier: TeamTaskStallNotifier
|
||||
) {}
|
||||
private readonly notifier: TeamTaskStallNotifier,
|
||||
options: TeamTaskStallMonitorOptions = {}
|
||||
) {
|
||||
this.scanTimeoutMs = Math.max(
|
||||
1,
|
||||
options.scanTimeoutMs ?? DEFAULT_TEAM_TASK_STALL_SCAN_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (!isTeamTaskStallScannerEnabled()) {
|
||||
|
|
@ -127,38 +144,87 @@ export class TeamTaskStallMonitor {
|
|||
return;
|
||||
}
|
||||
this.scanInFlight = true;
|
||||
const scanRun: TeamTaskStallScanRun = { cancelled: false };
|
||||
try {
|
||||
const activeTeams = await this.registry.listActiveTeams();
|
||||
const activeSet = new Set(activeTeams);
|
||||
for (const teamName of [...this.observationByTeam.keys()]) {
|
||||
if (!activeSet.has(teamName)) {
|
||||
this.observationByTeam.delete(teamName);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
for (const teamName of activeTeams) {
|
||||
const observation = this.getOrCreateObservation(teamName, now.getTime());
|
||||
const startupAgeMs = now.getTime() - observation.firstSeenAtMs;
|
||||
if (startupAgeMs < getTeamTaskStallStartupGraceMs()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const activationAgeMs = now.getTime() - observation.lastActivationAtMs;
|
||||
if (activationAgeMs < getTeamTaskStallActivationGraceMs()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.scanTeam(teamName, now);
|
||||
}
|
||||
await this.runScanWithTimeout(scanRun);
|
||||
} catch (error) {
|
||||
logger.warn(`Task stall monitor scan failed: ${String(error)}`);
|
||||
} finally {
|
||||
scanRun.cancelled = true;
|
||||
this.scanInFlight = false;
|
||||
this.scheduleNextScan(getTeamTaskStallScanIntervalMs());
|
||||
}
|
||||
}
|
||||
|
||||
private async runScanWithTimeout(scanRun: TeamTaskStallScanRun): Promise<void> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
await Promise.race([
|
||||
this.runScanBody(scanRun),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
scanRun.cancelled = true;
|
||||
reject(new Error(`task stall monitor scan timed out after ${this.scanTimeoutMs}ms`));
|
||||
}, this.scanTimeoutMs);
|
||||
unrefBackgroundTimer(timeout);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shouldContinueScan(scanRun: TeamTaskStallScanRun): boolean {
|
||||
return this.started && !scanRun.cancelled;
|
||||
}
|
||||
|
||||
private async runScanBody(scanRun: TeamTaskStallScanRun): Promise<void> {
|
||||
const activeTeams = await this.registry.listActiveTeams();
|
||||
if (!this.shouldContinueScan(scanRun)) {
|
||||
return;
|
||||
}
|
||||
const activeSet = new Set(activeTeams);
|
||||
for (const teamName of [...this.observationByTeam.keys()]) {
|
||||
if (!activeSet.has(teamName)) {
|
||||
this.observationByTeam.delete(teamName);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const eligibleTeamNames: string[] = [];
|
||||
for (const teamName of activeTeams) {
|
||||
const observation = this.getOrCreateObservation(teamName, now.getTime());
|
||||
const startupAgeMs = now.getTime() - observation.firstSeenAtMs;
|
||||
if (startupAgeMs < getTeamTaskStallStartupGraceMs()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const activationAgeMs = now.getTime() - observation.lastActivationAtMs;
|
||||
if (activationAgeMs < getTeamTaskStallActivationGraceMs()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
eligibleTeamNames.push(teamName);
|
||||
}
|
||||
|
||||
if (!this.shouldContinueScan(scanRun) || eligibleTeamNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
eligibleTeamNames.map((teamName) => this.scanTeam(teamName, now, scanRun))
|
||||
);
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (result.status === 'rejected' && this.shouldContinueScan(scanRun)) {
|
||||
logger.warn(
|
||||
`Task stall monitor scan failed for ${eligibleTeamNames[index]}: ${String(result.reason)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getOrCreateObservation(teamName: string, nowMs: number): TeamObservationState {
|
||||
const existing = this.observationByTeam.get(teamName);
|
||||
if (existing) {
|
||||
|
|
@ -172,8 +238,15 @@ export class TeamTaskStallMonitor {
|
|||
return created;
|
||||
}
|
||||
|
||||
private async scanTeam(teamName: string, now: Date): Promise<void> {
|
||||
private async scanTeam(
|
||||
teamName: string,
|
||||
now: Date,
|
||||
scanRun: TeamTaskStallScanRun
|
||||
): Promise<void> {
|
||||
const snapshot = await this.snapshotSource.getSnapshot(teamName);
|
||||
if (!this.shouldContinueScan(scanRun)) {
|
||||
return;
|
||||
}
|
||||
if (!snapshot) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -203,6 +276,9 @@ export class TeamTaskStallMonitor {
|
|||
...(scopedTaskIds ? { scopeTaskIds: scopedTaskIds } : {}),
|
||||
now: now.toISOString(),
|
||||
});
|
||||
if (!this.shouldContinueScan(scanRun)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alerts = readyEvaluations
|
||||
.map((evaluation) => this.buildAlert(snapshot, evaluation))
|
||||
|
|
@ -215,6 +291,9 @@ export class TeamTaskStallMonitor {
|
|||
const alertedEpochKeys = new Set<string>();
|
||||
if (openCodeRemediationEnabled) {
|
||||
const remediatedAlerts = await this.notifier.notifyOpenCodeOwners(teamName, alerts);
|
||||
if (!this.shouldContinueScan(scanRun)) {
|
||||
return;
|
||||
}
|
||||
for (const alert of remediatedAlerts) {
|
||||
alertedEpochKeys.add(alert.epochKey);
|
||||
}
|
||||
|
|
@ -223,6 +302,9 @@ export class TeamTaskStallMonitor {
|
|||
const leadFallbackAlerts = alerts.filter((alert) => !alertedEpochKeys.has(alert.epochKey));
|
||||
if (leadFallbackAlerts.length > 0 && isTeamTaskStallAlertsEnabled()) {
|
||||
await this.notifier.notifyLead(teamName, leadFallbackAlerts);
|
||||
if (!this.shouldContinueScan(scanRun)) {
|
||||
return;
|
||||
}
|
||||
for (const alert of leadFallbackAlerts) {
|
||||
alertedEpochKeys.add(alert.epochKey);
|
||||
}
|
||||
|
|
@ -233,6 +315,9 @@ export class TeamTaskStallMonitor {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!this.shouldContinueScan(scanRun)) {
|
||||
return;
|
||||
}
|
||||
await Promise.all(
|
||||
alerts
|
||||
.filter((alert) => alertedEpochKeys.has(alert.epochKey))
|
||||
|
|
|
|||
|
|
@ -304,6 +304,7 @@ function buildOpenCodeNoProgressEpochKey(args: {
|
|||
|
||||
function buildAlertEvaluation(args: {
|
||||
task: TeamTask;
|
||||
memberName?: string;
|
||||
branch: TaskStallBranch;
|
||||
signal: TaskStallSignal;
|
||||
progressSignal?: TaskProgressSignal;
|
||||
|
|
@ -313,6 +314,7 @@ function buildAlertEvaluation(args: {
|
|||
return {
|
||||
status: 'alert',
|
||||
taskId: args.task.id,
|
||||
...(args.memberName ? { memberName: args.memberName } : {}),
|
||||
branch: args.branch,
|
||||
signal: args.signal,
|
||||
...(args.progressSignal ? { progressSignal: args.progressSignal } : {}),
|
||||
|
|
@ -330,6 +332,7 @@ function buildOpenCodeNoProgressAlertEvaluation(args: {
|
|||
return {
|
||||
status: 'alert',
|
||||
taskId: args.task.id,
|
||||
memberName: args.owner,
|
||||
branch: 'work',
|
||||
signal: 'mid_turn_after_touch',
|
||||
progressSignal: 'unknown',
|
||||
|
|
@ -488,6 +491,7 @@ export class TeamTaskStallPolicy {
|
|||
|
||||
return buildAlertEvaluation({
|
||||
task,
|
||||
memberName: task.owner,
|
||||
branch: 'work',
|
||||
signal,
|
||||
progressSignal: progressClassification.signal,
|
||||
|
|
@ -595,6 +599,7 @@ export class TeamTaskStallPolicy {
|
|||
|
||||
return buildAlertEvaluation({
|
||||
task,
|
||||
memberName: resolvedReviewer.reviewer,
|
||||
branch: 'review',
|
||||
signal,
|
||||
touch: reviewContext.lastMeaningfulTouch,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export interface ResolvedReviewer {
|
|||
export interface TaskStallEvaluation {
|
||||
status: TaskStallEvaluationStatus;
|
||||
taskId?: string;
|
||||
memberName?: string;
|
||||
branch?: TaskStallBranch;
|
||||
signal?: TaskStallSignal;
|
||||
progressSignal?: TaskProgressSignal;
|
||||
|
|
@ -135,6 +136,7 @@ export interface TaskStallJournalEntry {
|
|||
epochKey: string;
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
memberName?: string;
|
||||
branch: TaskStallBranch;
|
||||
signal: TaskStallSignal;
|
||||
state: TaskStallJournalState;
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ export function getTeamTaskStallActivationGraceMs(): number {
|
|||
return readInt(process.env.CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS, 60_000);
|
||||
}
|
||||
|
||||
export function getTeamTaskStallAlertCooldownMs(): number {
|
||||
return readInt(process.env.CLAUDE_TEAM_TASK_STALL_ALERT_COOLDOWN_MS, 10 * 60_000);
|
||||
}
|
||||
|
||||
export function getOpenCodeWeakStartStallThresholdMs(): number {
|
||||
// Shorter OpenCode threshold for "started work" comments that do not contain concrete progress.
|
||||
return readInt(process.env.CLAUDE_TEAM_OPENCODE_WEAK_START_STALL_THRESHOLD_MS, 100_000);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
CodexLoginUserCodeBadge,
|
||||
} from '@renderer/components/runtime/CodexLoginLinkCopyButton';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -70,7 +71,14 @@ import type { CliProviderAuthMode, CliProviderId, CliProviderStatus } from '@sha
|
|||
import type { ApiKeyEntry } from '@shared/types/extensions';
|
||||
|
||||
type ApiKeyProviderId = 'anthropic' | 'codex' | 'gemini';
|
||||
type PendingConnectionAction = 'auto' | 'oauth' | 'chatgpt' | 'api_key' | 'compatible' | null;
|
||||
type PendingConnectionAction =
|
||||
| 'auto'
|
||||
| 'oauth'
|
||||
| 'chatgpt'
|
||||
| 'api_key'
|
||||
| 'compatible'
|
||||
| 'codex-custom-provider'
|
||||
| null;
|
||||
|
||||
interface ConnectionMethodCardOption {
|
||||
readonly authMode: CliProviderAuthMode;
|
||||
|
|
@ -163,6 +171,7 @@ const API_KEY_PROVIDER_TRANSLATION_KEYS = {
|
|||
|
||||
const ANTHROPIC_COMPATIBLE_AUTH_TOKEN_ENV_VAR = 'ANTHROPIC_AUTH_TOKEN';
|
||||
const ANTHROPIC_COMPATIBLE_AUTH_TOKEN_NAME = 'Anthropic-compatible Auth Token';
|
||||
const CODEX_CUSTOM_PROVIDER_MODEL_MAX_LENGTH = 200;
|
||||
const FIRST_PARTY_ANTHROPIC_HOSTS = new Set(['api.anthropic.com', 'api-staging.anthropic.com']);
|
||||
|
||||
function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProviderId {
|
||||
|
|
@ -231,6 +240,50 @@ function validateAnthropicCompatibleBaseUrl(
|
|||
return null;
|
||||
}
|
||||
|
||||
function validateCodexCustomProviderBaseUrl(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return 'Base URL is required when custom endpoint is enabled.';
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return 'Base URL must use http:// or https://.';
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
return 'Base URL must not include username or password.';
|
||||
}
|
||||
if (url.search || url.hash) {
|
||||
return 'Base URL must not include query string or fragment.';
|
||||
}
|
||||
} catch {
|
||||
return 'Base URL must be a valid URL.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateCodexCustomProviderModel(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return 'Model id is required when custom endpoint is enabled.';
|
||||
}
|
||||
|
||||
if (trimmed.length > CODEX_CUSTOM_PROVIDER_MODEL_MAX_LENGTH) {
|
||||
return `Model id must be ${CODEX_CUSTOM_PROVIDER_MODEL_MAX_LENGTH} characters or fewer.`;
|
||||
}
|
||||
|
||||
for (let index = 0; index < trimmed.length; index += 1) {
|
||||
const code = trimmed.charCodeAt(index);
|
||||
if (code <= 31 || code === 127) {
|
||||
return 'Model id must not include newlines or control characters.';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConnectionDescription(
|
||||
provider: CliProviderStatus,
|
||||
t: ReturnType<typeof useAppTranslation>['t']
|
||||
|
|
@ -808,6 +861,12 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
const [compatibleTokenValue, setCompatibleTokenValue] = useState('');
|
||||
const [compatibleEndpointError, setCompatibleEndpointError] = useState<string | null>(null);
|
||||
const [compatibleEndpointStatus, setCompatibleEndpointStatus] = useState<string | null>(null);
|
||||
const [codexCustomProviderEnabled, setCodexCustomProviderEnabled] = useState(false);
|
||||
const [codexCustomProviderBaseUrl, setCodexCustomProviderBaseUrl] = useState('');
|
||||
const [codexCustomProviderModel, setCodexCustomProviderModel] = useState('');
|
||||
const [codexCustomProviderApiKeyValue, setCodexCustomProviderApiKeyValue] = useState('');
|
||||
const [codexCustomProviderError, setCodexCustomProviderError] = useState<string | null>(null);
|
||||
const [codexCustomProviderStatus, setCodexCustomProviderStatus] = useState<string | null>(null);
|
||||
const apiKeyInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const apiKeys = useStore((s) => s.apiKeys);
|
||||
|
|
@ -854,6 +913,12 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
setCompatibleTokenValue('');
|
||||
setCompatibleEndpointError(null);
|
||||
setCompatibleEndpointStatus(null);
|
||||
setCodexCustomProviderEnabled(false);
|
||||
setCodexCustomProviderBaseUrl('');
|
||||
setCodexCustomProviderModel('');
|
||||
setCodexCustomProviderApiKeyValue('');
|
||||
setCodexCustomProviderError(null);
|
||||
setCodexCustomProviderStatus(null);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -861,6 +926,8 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
setRuntimeError(null);
|
||||
setCompatibleEndpointError(null);
|
||||
setCompatibleEndpointStatus(null);
|
||||
setCodexCustomProviderError(null);
|
||||
setCodexCustomProviderStatus(null);
|
||||
}, [selectedProviderId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -892,6 +959,11 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
enabled: false,
|
||||
baseUrl: '',
|
||||
};
|
||||
const codexCustomProviderConfig = appConfig?.providerConnections?.codex.customProvider ?? {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
};
|
||||
const selectedCompatibleToken = findPreferredApiKeyEntry(
|
||||
apiKeys,
|
||||
ANTHROPIC_COMPATIBLE_AUTH_TOKEN_ENV_VAR
|
||||
|
|
@ -939,6 +1011,32 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
nextConnection.configuredAuthMode =
|
||||
appConfig?.providerConnections?.codex.preferredAuthMode ??
|
||||
mergedStatusProvider.connection.configuredAuthMode;
|
||||
if (nextConnection.codex) {
|
||||
nextConnection.codex = {
|
||||
...nextConnection.codex,
|
||||
preferredAuthMode:
|
||||
appConfig?.providerConnections?.codex.preferredAuthMode ??
|
||||
nextConnection.codex.preferredAuthMode,
|
||||
customProvider: {
|
||||
...(nextConnection.codex.customProvider ?? {
|
||||
enabled: false,
|
||||
active: false,
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
issueMessage: null,
|
||||
}),
|
||||
enabled: codexCustomProviderConfig.enabled,
|
||||
active:
|
||||
codexCustomProviderConfig.enabled &&
|
||||
(appConfig?.providerConnections?.codex.preferredAuthMode ??
|
||||
mergedStatusProvider.connection.configuredAuthMode) === 'api_key' &&
|
||||
validateCodexCustomProviderBaseUrl(codexCustomProviderConfig.baseUrl) === null &&
|
||||
validateCodexCustomProviderModel(codexCustomProviderConfig.model) === null,
|
||||
baseUrl: codexCustomProviderConfig.baseUrl,
|
||||
model: codexCustomProviderConfig.model,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (statusApiKeyConfig) {
|
||||
|
|
@ -965,6 +1063,9 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
appConfig?.providerConnections?.anthropic.authMode,
|
||||
appConfig?.providerConnections?.codex.preferredAuthMode,
|
||||
codexAccount.snapshot,
|
||||
codexCustomProviderConfig.baseUrl,
|
||||
codexCustomProviderConfig.enabled,
|
||||
codexCustomProviderConfig.model,
|
||||
selectedCompatibleToken,
|
||||
selectedApiKey,
|
||||
statusApiKeyConfig,
|
||||
|
|
@ -983,6 +1084,25 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
setCompatibleEndpointStatus(null);
|
||||
}, [anthropicCompatibleConfig.baseUrl, open, selectedProviderId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || selectedProviderId !== 'codex') {
|
||||
return;
|
||||
}
|
||||
|
||||
setCodexCustomProviderEnabled(codexCustomProviderConfig.enabled);
|
||||
setCodexCustomProviderBaseUrl(codexCustomProviderConfig.baseUrl);
|
||||
setCodexCustomProviderModel(codexCustomProviderConfig.model);
|
||||
setCodexCustomProviderApiKeyValue('');
|
||||
setCodexCustomProviderError(null);
|
||||
setCodexCustomProviderStatus(null);
|
||||
}, [
|
||||
codexCustomProviderConfig.baseUrl,
|
||||
codexCustomProviderConfig.enabled,
|
||||
codexCustomProviderConfig.model,
|
||||
open,
|
||||
selectedProviderId,
|
||||
]);
|
||||
|
||||
const selectedProviderLoading = selectedProvider
|
||||
? providerStatusLoading[selectedProvider.providerId] === true
|
||||
: false;
|
||||
|
|
@ -1136,6 +1256,28 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
(anthropicCompatibleTokenConfigured ? t('providerRuntime.status.configured') : null);
|
||||
const anthropicCompatibleMissingToken =
|
||||
anthropicCompatibleEndpointEnabled && !anthropicCompatibleTokenConfigured;
|
||||
const codexCustomProvider =
|
||||
selectedProvider?.providerId === 'codex'
|
||||
? (selectedProvider.connection?.codex?.customProvider ?? null)
|
||||
: null;
|
||||
const codexCustomProviderPersistedEnabled =
|
||||
codexCustomProvider?.enabled ?? codexCustomProviderConfig.enabled;
|
||||
const codexCustomProviderActive = codexCustomProvider?.active === true;
|
||||
const codexCustomProviderIssueMessage = codexCustomProvider?.issueMessage ?? null;
|
||||
const codexCustomProviderApiKeyConfigured = Boolean(
|
||||
selectedProvider?.providerId === 'codex' &&
|
||||
(selectedApiKey || selectedProvider.connection?.apiKeyConfigured)
|
||||
);
|
||||
const codexCustomProviderApiKeyStatus =
|
||||
selectedApiKey?.maskedValue ??
|
||||
(selectedProvider?.providerId === 'codex'
|
||||
? selectedProvider.connection?.apiKeySourceLabel
|
||||
: null) ??
|
||||
(codexCustomProviderApiKeyConfigured ? t('providerRuntime.status.configured') : null);
|
||||
const codexCustomProviderInactiveMessage =
|
||||
codexCustomProviderPersistedEnabled && configuredAuthMode !== 'api_key'
|
||||
? 'Custom endpoint is saved but inactive because Codex is not in API key mode.'
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showApiKeyForm) {
|
||||
|
|
@ -1194,6 +1336,8 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
return t('providerRuntime.progress.switchingApiKeyMode');
|
||||
case 'auto':
|
||||
return t('providerRuntime.progress.switchingAuto');
|
||||
case 'codex-custom-provider':
|
||||
return 'Saving Codex custom endpoint';
|
||||
default:
|
||||
return t('providerRuntime.progress.applyingConnectionChanges');
|
||||
}
|
||||
|
|
@ -1443,6 +1587,146 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleSaveCodexCustomProvider = async (): Promise<void> => {
|
||||
if (selectedProvider?.providerId !== 'codex' || !apiKeyConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = codexCustomProviderBaseUrl.trim();
|
||||
const model = codexCustomProviderModel.trim();
|
||||
const shouldEnable = codexCustomProviderEnabled;
|
||||
if (shouldEnable) {
|
||||
const baseUrlError = validateCodexCustomProviderBaseUrl(baseUrl);
|
||||
if (baseUrlError) {
|
||||
setCodexCustomProviderError(baseUrlError);
|
||||
setCodexCustomProviderStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const modelError = validateCodexCustomProviderModel(model);
|
||||
if (modelError) {
|
||||
setCodexCustomProviderError(modelError);
|
||||
setCodexCustomProviderStatus(null);
|
||||
return;
|
||||
}
|
||||
} else if (baseUrl) {
|
||||
const baseUrlError = validateCodexCustomProviderBaseUrl(baseUrl);
|
||||
if (baseUrlError) {
|
||||
setCodexCustomProviderError(baseUrlError);
|
||||
setCodexCustomProviderStatus(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldEnable && model) {
|
||||
const modelError = validateCodexCustomProviderModel(model);
|
||||
if (modelError) {
|
||||
setCodexCustomProviderError(modelError);
|
||||
setCodexCustomProviderStatus(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setConnectionSaving(true);
|
||||
setPendingConnectionAction('codex-custom-provider');
|
||||
setConnectionError(null);
|
||||
setCodexCustomProviderError(null);
|
||||
setCodexCustomProviderStatus(null);
|
||||
let updateSucceeded = false;
|
||||
|
||||
try {
|
||||
if (codexCustomProviderApiKeyValue.trim()) {
|
||||
await saveApiKey({
|
||||
id: selectedApiKey?.id,
|
||||
name: apiKeyConfig.name,
|
||||
envVarName: apiKeyConfig.envVarName,
|
||||
value: codexCustomProviderApiKeyValue.trim(),
|
||||
scope: selectedApiKey?.scope ?? 'user',
|
||||
});
|
||||
}
|
||||
|
||||
await updateConfig('providerConnections', {
|
||||
codex: {
|
||||
...(shouldEnable ? { preferredAuthMode: 'api_key' as const } : {}),
|
||||
customProvider: {
|
||||
enabled: shouldEnable,
|
||||
baseUrl,
|
||||
model,
|
||||
},
|
||||
},
|
||||
});
|
||||
updateSucceeded = true;
|
||||
setCodexCustomProviderApiKeyValue('');
|
||||
setCodexCustomProviderStatus(
|
||||
shouldEnable
|
||||
? 'Custom endpoint saved. Codex API key mode is selected.'
|
||||
: 'Custom endpoint disabled. Saved endpoint, model, and key were kept.'
|
||||
);
|
||||
} catch (error) {
|
||||
setCodexCustomProviderError(
|
||||
error instanceof Error ? error.message : 'Failed to save Codex custom endpoint.'
|
||||
);
|
||||
} finally {
|
||||
if (updateSucceeded) {
|
||||
try {
|
||||
await codexAccount.refresh({ includeRateLimits: true, forceRefreshToken: true });
|
||||
await onRefreshProvider?.('codex');
|
||||
} catch {
|
||||
setConnectionError('Codex custom endpoint saved, but provider status refresh failed.');
|
||||
}
|
||||
}
|
||||
|
||||
setConnectionSaving(false);
|
||||
setPendingConnectionAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisableCodexCustomProvider = async (): Promise<void> => {
|
||||
if (selectedProvider?.providerId !== 'codex') {
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectionSaving(true);
|
||||
setPendingConnectionAction('codex-custom-provider');
|
||||
setConnectionError(null);
|
||||
setCodexCustomProviderError(null);
|
||||
setCodexCustomProviderStatus(null);
|
||||
let updateSucceeded = false;
|
||||
|
||||
try {
|
||||
await updateConfig('providerConnections', {
|
||||
codex: {
|
||||
customProvider: {
|
||||
enabled: false,
|
||||
baseUrl: codexCustomProviderConfig.baseUrl,
|
||||
model: codexCustomProviderConfig.model,
|
||||
},
|
||||
},
|
||||
});
|
||||
updateSucceeded = true;
|
||||
setCodexCustomProviderEnabled(false);
|
||||
setCodexCustomProviderStatus(
|
||||
'Custom endpoint disabled. Saved endpoint, model, and key were kept.'
|
||||
);
|
||||
} catch (error) {
|
||||
setCodexCustomProviderError(
|
||||
error instanceof Error ? error.message : 'Failed to disable Codex custom endpoint.'
|
||||
);
|
||||
} finally {
|
||||
if (updateSucceeded) {
|
||||
try {
|
||||
await codexAccount.refresh({ includeRateLimits: true, forceRefreshToken: true });
|
||||
await onRefreshProvider?.('codex');
|
||||
} catch {
|
||||
setConnectionError('Codex custom endpoint disabled, but provider status refresh failed.');
|
||||
}
|
||||
}
|
||||
|
||||
setConnectionSaving(false);
|
||||
setPendingConnectionAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodexAccountRefresh = async (): Promise<void> => {
|
||||
setConnectionError(null);
|
||||
try {
|
||||
|
|
@ -1891,6 +2175,255 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedProvider.providerId === 'codex' ? (
|
||||
<div
|
||||
data-testid="codex-custom-provider-panel"
|
||||
className="space-y-3 rounded-md border p-3"
|
||||
style={{ borderColor: 'var(--color-border-subtle)' }}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
Custom API endpoint
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Route Codex API-key launches through an app-managed custom provider.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5"
|
||||
style={{
|
||||
color: codexCustomProviderPersistedEnabled
|
||||
? '#86efac'
|
||||
: 'var(--color-text-muted)',
|
||||
backgroundColor: codexCustomProviderPersistedEnabled
|
||||
? 'rgba(74, 222, 128, 0.14)'
|
||||
: 'rgba(255, 255, 255, 0.05)',
|
||||
}}
|
||||
>
|
||||
{codexCustomProviderPersistedEnabled ? 'enabled' : 'off'}
|
||||
</span>
|
||||
{codexCustomProviderPersistedEnabled ? (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5"
|
||||
style={{
|
||||
color: codexCustomProviderActive
|
||||
? '#86efac'
|
||||
: 'var(--color-text-muted)',
|
||||
backgroundColor: codexCustomProviderActive
|
||||
? 'rgba(74, 222, 128, 0.14)'
|
||||
: 'rgba(255, 255, 255, 0.05)',
|
||||
}}
|
||||
>
|
||||
{codexCustomProviderActive ? 'active' : 'inactive'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 text-xs">
|
||||
<Checkbox
|
||||
checked={codexCustomProviderEnabled}
|
||||
disabled={connectionBusy}
|
||||
onCheckedChange={(checked) => {
|
||||
setCodexCustomProviderEnabled(checked === true);
|
||||
setCodexCustomProviderError(null);
|
||||
setCodexCustomProviderStatus(null);
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Enable custom endpoint for Codex API-key launches
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="codex-custom-provider-base-url" className="text-xs">
|
||||
Base URL
|
||||
</Label>
|
||||
<Input
|
||||
id="codex-custom-provider-base-url"
|
||||
data-testid="codex-custom-provider-base-url"
|
||||
value={codexCustomProviderBaseUrl}
|
||||
onChange={(event) => {
|
||||
setCodexCustomProviderBaseUrl(event.currentTarget.value);
|
||||
setCodexCustomProviderError(null);
|
||||
setCodexCustomProviderStatus(null);
|
||||
}}
|
||||
placeholder="https://gateway.example.com/v1"
|
||||
className="h-9 text-sm"
|
||||
disabled={connectionBusy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="codex-custom-provider-model" className="text-xs">
|
||||
Model id
|
||||
</Label>
|
||||
<Input
|
||||
id="codex-custom-provider-model"
|
||||
data-testid="codex-custom-provider-model"
|
||||
value={codexCustomProviderModel}
|
||||
onChange={(event) => {
|
||||
setCodexCustomProviderModel(event.currentTarget.value);
|
||||
setCodexCustomProviderError(null);
|
||||
setCodexCustomProviderStatus(null);
|
||||
}}
|
||||
placeholder="gateway-model-id"
|
||||
className="h-9 text-sm"
|
||||
disabled={connectionBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="codex-custom-provider-api-key" className="text-xs">
|
||||
API key
|
||||
</Label>
|
||||
<Input
|
||||
id="codex-custom-provider-api-key"
|
||||
data-testid="codex-custom-provider-api-key"
|
||||
type="password"
|
||||
value={codexCustomProviderApiKeyValue}
|
||||
onChange={(event) => {
|
||||
setCodexCustomProviderApiKeyValue(event.currentTarget.value);
|
||||
setCodexCustomProviderError(null);
|
||||
setCodexCustomProviderStatus(null);
|
||||
}}
|
||||
placeholder={
|
||||
codexCustomProviderApiKeyConfigured
|
||||
? 'Keep saved OPENAI_API_KEY'
|
||||
: apiKeyConfig?.placeholder
|
||||
}
|
||||
className="h-9 text-sm"
|
||||
disabled={connectionBusy || apiKeySaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5"
|
||||
style={{
|
||||
color: codexCustomProviderApiKeyConfigured
|
||||
? '#86efac'
|
||||
: 'var(--color-text-muted)',
|
||||
backgroundColor: codexCustomProviderApiKeyConfigured
|
||||
? 'rgba(74, 222, 128, 0.14)'
|
||||
: 'rgba(255, 255, 255, 0.05)',
|
||||
}}
|
||||
>
|
||||
API key:{' '}
|
||||
{codexCustomProviderApiKeyConfigured
|
||||
? t('providerRuntime.status.configured')
|
||||
: t('providerRuntime.status.notSet')}
|
||||
</span>
|
||||
{codexCustomProviderApiKeyStatus ? (
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{codexCustomProviderApiKeyStatus}
|
||||
</span>
|
||||
) : null}
|
||||
{codexCustomProviderPersistedEnabled && codexCustomProvider?.baseUrl ? (
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{codexCustomProvider.baseUrl}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.25)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.06)',
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
Endpoint must support the Codex Responses API. Chat Completions-only
|
||||
gateways may fail at launch or model probe time.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{codexCustomProviderError ? (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
borderColor: 'rgba(248, 113, 113, 0.25)',
|
||||
backgroundColor: 'rgba(248, 113, 113, 0.06)',
|
||||
color: '#fca5a5',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{codexCustomProviderError}</span>
|
||||
</div>
|
||||
) : codexCustomProviderStatus ? (
|
||||
<div
|
||||
className="rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
borderColor: 'rgba(74, 222, 128, 0.22)',
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.06)',
|
||||
color: '#86efac',
|
||||
}}
|
||||
>
|
||||
{codexCustomProviderStatus}
|
||||
</div>
|
||||
) : codexCustomProviderIssueMessage ||
|
||||
codexCustomProviderInactiveMessage ||
|
||||
(codexCustomProviderPersistedEnabled &&
|
||||
!codexCustomProviderApiKeyConfigured) ? (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.25)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.06)',
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{codexCustomProviderIssueMessage ??
|
||||
codexCustomProviderInactiveMessage ??
|
||||
'Custom endpoint is enabled, but no OPENAI_API_KEY is configured.'}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{codexCustomProviderPersistedEnabled ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={connectionBusy}
|
||||
onClick={() => void handleDisableCodexCustomProvider()}
|
||||
>
|
||||
{t('providerRuntime.actions.disable')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={
|
||||
connectionBusy ||
|
||||
apiKeySaving ||
|
||||
(codexCustomProviderEnabled &&
|
||||
(!codexCustomProviderBaseUrl.trim() ||
|
||||
!codexCustomProviderModel.trim()))
|
||||
}
|
||||
onClick={() => void handleSaveCodexCustomProvider()}
|
||||
>
|
||||
{connectionSaving && pendingConnectionAction === 'codex-custom-provider' ? (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1 size-3.5" />
|
||||
)}
|
||||
Save endpoint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{configuredAuthMode && !hideConnectionMethodMeta ? (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -348,6 +348,11 @@ export function useSettingsHandlers({
|
|||
},
|
||||
codex: {
|
||||
preferredAuthMode: 'auto',
|
||||
customProvider: {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,13 @@ export interface CliProviderConnectionInfo {
|
|||
launchAllowed: boolean;
|
||||
launchIssueMessage: string | null;
|
||||
launchReadinessState: CodexLaunchReadinessState;
|
||||
customProvider?: {
|
||||
enabled: boolean;
|
||||
active: boolean;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
issueMessage: string | null;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,11 @@ export interface AppConfig {
|
|||
};
|
||||
codex: {
|
||||
preferredAuthMode: 'auto' | 'chatgpt' | 'api_key';
|
||||
customProvider: {
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** Runtime backend preferences for app-launched agent_teams_orchestrator sessions */
|
||||
|
|
|
|||
|
|
@ -288,4 +288,45 @@ describe('mergeCodexProviderStatusWithSnapshot', () => {
|
|||
endpointLabel: 'codex exec --json',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an active Codex custom provider endpoint label through snapshot merge', () => {
|
||||
const provider = createBaseCodexProvider();
|
||||
const customProvider = {
|
||||
enabled: true,
|
||||
active: true,
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
model: 'gateway-codex-model',
|
||||
issueMessage: null,
|
||||
};
|
||||
|
||||
const merged = mergeCodexProviderStatusWithSnapshot(
|
||||
{
|
||||
...provider,
|
||||
backend: {
|
||||
...provider.backend!,
|
||||
endpointLabel: customProvider.baseUrl,
|
||||
},
|
||||
connection: {
|
||||
...provider.connection!,
|
||||
configuredAuthMode: 'api_key',
|
||||
codex: {
|
||||
...provider.connection!.codex!,
|
||||
preferredAuthMode: 'api_key',
|
||||
effectiveAuthMode: 'api_key',
|
||||
customProvider,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...createReadyChatgptSnapshot(),
|
||||
preferredAuthMode: 'api_key',
|
||||
effectiveAuthMode: 'api_key',
|
||||
launchReadinessState: 'ready_api_key',
|
||||
managedAccount: null,
|
||||
}
|
||||
);
|
||||
|
||||
expect(merged.backend?.endpointLabel).toBe('https://gateway.example.com/v1');
|
||||
expect(merged.connection?.codex?.customProvider).toEqual(customProvider);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
MemberWorkSyncNudgeDispatcher,
|
||||
type MemberWorkSyncOutboxStorePort,
|
||||
MemberWorkSyncPendingReportIntentReplayer,
|
||||
MemberWorkSyncReconcileCancelledError,
|
||||
MemberWorkSyncReconciler,
|
||||
MemberWorkSyncReporter,
|
||||
type MemberWorkSyncReviewPickupDeliveryPort,
|
||||
|
|
@ -13,7 +14,7 @@ import {
|
|||
type MemberWorkSyncStatusStorePort,
|
||||
type MemberWorkSyncUseCaseDeps,
|
||||
} from '@features/member-work-sync/core/application';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
MemberWorkSyncActionableWorkItem,
|
||||
|
|
@ -79,6 +80,10 @@ const secondReviewPickupItem: MemberWorkSyncActionableWorkItem = {
|
|||
},
|
||||
};
|
||||
|
||||
function isTerminalOutboxStatus(status: MemberWorkSyncOutboxItem['status']): boolean {
|
||||
return status === 'delivered' || status === 'superseded' || status === 'failed_terminal';
|
||||
}
|
||||
|
||||
class MutableClock {
|
||||
private current = new Date('2026-04-29T00:00:00.000Z');
|
||||
|
||||
|
|
@ -231,7 +236,7 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort {
|
|||
|
||||
async markDelivered(input: MemberWorkSyncOutboxMarkDeliveredInput): Promise<void> {
|
||||
const current = this.items.get(input.id);
|
||||
if (current?.attemptGeneration === input.attemptGeneration) {
|
||||
if (current?.attemptGeneration === input.attemptGeneration && current.status === 'claimed') {
|
||||
const next = {
|
||||
...current,
|
||||
status: 'delivered' as const,
|
||||
|
|
@ -254,7 +259,10 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort {
|
|||
|
||||
async markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise<void> {
|
||||
const current = this.items.get(input.id);
|
||||
if (current?.attemptGeneration === input.attemptGeneration) {
|
||||
if (
|
||||
current?.attemptGeneration === input.attemptGeneration &&
|
||||
!isTerminalOutboxStatus(current.status)
|
||||
) {
|
||||
this.items.set(input.id, {
|
||||
...current,
|
||||
status: input.retryable ? 'failed_retryable' : 'failed_terminal',
|
||||
|
|
@ -324,10 +332,12 @@ function createDeps(options?: {
|
|||
activeMemberNames?: string[];
|
||||
inactive?: boolean;
|
||||
teamActive?: boolean;
|
||||
memberActive?: boolean;
|
||||
providerId?: 'opencode' | 'codex';
|
||||
outboxStore?: MemberWorkSyncOutboxStorePort;
|
||||
inboxNudge?: MemberWorkSyncInboxNudgePort;
|
||||
busySignal?: MemberWorkSyncUseCaseDeps['busySignal'];
|
||||
watchdogCooldown?: MemberWorkSyncUseCaseDeps['watchdogCooldown'];
|
||||
reviewPickupDelivery?: MemberWorkSyncReviewPickupDeliveryPort;
|
||||
reviewPickupEscalation?: MemberWorkSyncReviewPickupEscalationPort;
|
||||
}) {
|
||||
|
|
@ -361,6 +371,7 @@ function createDeps(options?: {
|
|||
...(options?.outboxStore ? { outboxStore: options.outboxStore } : {}),
|
||||
...(options?.inboxNudge ? { inboxNudge: options.inboxNudge } : {}),
|
||||
...(options?.busySignal ? { busySignal: options.busySignal } : {}),
|
||||
...(options?.watchdogCooldown ? { watchdogCooldown: options.watchdogCooldown } : {}),
|
||||
...(options?.reviewPickupDelivery
|
||||
? { reviewPickupDelivery: options.reviewPickupDelivery }
|
||||
: {}),
|
||||
|
|
@ -379,6 +390,7 @@ function createDeps(options?: {
|
|||
},
|
||||
lifecycle: {
|
||||
isTeamActive: () => options?.teamActive ?? true,
|
||||
isMemberActive: () => options?.memberActive ?? true,
|
||||
},
|
||||
auditJournal: {
|
||||
append: async (event) => {
|
||||
|
|
@ -414,6 +426,71 @@ describe('MemberWorkSync use cases', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('does not write status or plan nudges after a queued reconcile is cancelled', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const { auditEvents, deps, store } = createDeps({ outboxStore: outbox });
|
||||
|
||||
await expect(
|
||||
new MemberWorkSyncReconciler(deps).execute(
|
||||
{ teamName: 'team-a', memberName: 'bob' },
|
||||
{
|
||||
reconciledBy: 'queue',
|
||||
triggerReasons: ['turn_settled'],
|
||||
isCancelled: () => true,
|
||||
}
|
||||
)
|
||||
).rejects.toBeInstanceOf(MemberWorkSyncReconcileCancelledError);
|
||||
|
||||
expect(store.writes).toHaveLength(0);
|
||||
expect(outbox.ensures).toHaveLength(0);
|
||||
expect(auditEvents.map((event) => event.event)).toEqual(['reconcile_started']);
|
||||
});
|
||||
|
||||
it('does not create a report token when a queued reconcile is cancelled after decision audit', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const { auditEvents, deps, store } = createDeps({ outboxStore: outbox });
|
||||
let cancelled = false;
|
||||
let tokenCreates = 0;
|
||||
deps.auditJournal = {
|
||||
append: async (event) => {
|
||||
auditEvents.push(event);
|
||||
if (event.event === 'decision_made') {
|
||||
cancelled = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
deps.reportToken = {
|
||||
create: async (input) => {
|
||||
tokenCreates += 1;
|
||||
return {
|
||||
token: `token:${input.teamName}:${input.memberName}:${input.agendaFingerprint}`,
|
||||
expiresAt: '2026-04-29T00:15:00.000Z',
|
||||
};
|
||||
},
|
||||
verify: async () => ({ ok: false, reason: 'missing' }),
|
||||
};
|
||||
|
||||
await expect(
|
||||
new MemberWorkSyncReconciler(deps).execute(
|
||||
{ teamName: 'team-a', memberName: 'bob' },
|
||||
{
|
||||
reconciledBy: 'queue',
|
||||
triggerReasons: ['turn_settled'],
|
||||
isCancelled: () => cancelled,
|
||||
}
|
||||
)
|
||||
).rejects.toBeInstanceOf(MemberWorkSyncReconcileCancelledError);
|
||||
|
||||
expect(tokenCreates).toBe(0);
|
||||
expect(store.writes).toHaveLength(0);
|
||||
expect(outbox.ensures).toHaveLength(0);
|
||||
expect(auditEvents.map((event) => event.event)).toEqual([
|
||||
'reconcile_started',
|
||||
'agenda_loaded',
|
||||
'decision_made',
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts still_working as a bounded lease for the current fingerprint', async () => {
|
||||
const { auditEvents, clock, deps } = createDeps();
|
||||
const reader = new MemberWorkSyncReconciler(deps);
|
||||
|
|
@ -447,6 +524,36 @@ describe('MemberWorkSync use cases', () => {
|
|||
expect(auditEvents.map((event) => event.event)).toContain('report_accepted');
|
||||
});
|
||||
|
||||
it('rejects reports when this member runtime is no longer active', async () => {
|
||||
const { deps } = createDeps();
|
||||
const reader = new MemberWorkSyncReconciler(deps);
|
||||
const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
|
||||
const reporter = new MemberWorkSyncReporter({
|
||||
...deps,
|
||||
lifecycle: {
|
||||
isTeamActive: () => true,
|
||||
isMemberActive: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await reporter.execute({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: current.agenda.fingerprint,
|
||||
reportToken: current.reportToken,
|
||||
source: 'test',
|
||||
});
|
||||
|
||||
expect(result.accepted).toBe(false);
|
||||
expect(result.code).toBe('member_runtime_inactive');
|
||||
expect(result.status.state).toBe('inactive');
|
||||
expect(result.status.report).toMatchObject({
|
||||
accepted: false,
|
||||
rejectionCode: 'member_runtime_inactive',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses app clock instead of model supplied reportedAt for lease timing', async () => {
|
||||
const { deps } = createDeps();
|
||||
const reader = new MemberWorkSyncReconciler(deps);
|
||||
|
|
@ -577,6 +684,18 @@ describe('MemberWorkSync use cases', () => {
|
|||
expect(status.shadow?.wouldNudge).toBe(false);
|
||||
});
|
||||
|
||||
it('marks status inactive when this member runtime is not active', async () => {
|
||||
const { deps } = createDeps({ memberActive: false });
|
||||
const status = await new MemberWorkSyncReconciler(deps).execute({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
});
|
||||
|
||||
expect(status.state).toBe('inactive');
|
||||
expect(status.diagnostics).toContain('member_runtime_inactive');
|
||||
expect(status.shadow?.wouldNudge).toBe(false);
|
||||
});
|
||||
|
||||
it('records fingerprint transitions without treating them as progress proof', async () => {
|
||||
const { deps, source } = createDeps();
|
||||
const reader = new MemberWorkSyncReconciler(deps);
|
||||
|
|
@ -892,6 +1011,379 @@ describe('MemberWorkSync use cases', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('supersedes due nudges for inactive member runtimes without inbox delivery', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const inbox = new InMemoryInboxNudge();
|
||||
const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox });
|
||||
store.phase2ReadinessState = 'shadow_ready';
|
||||
|
||||
const status = await new MemberWorkSyncReconciler(deps).execute(
|
||||
{
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
},
|
||||
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||
);
|
||||
const dispatcher = new MemberWorkSyncNudgeDispatcher({
|
||||
...deps,
|
||||
lifecycle: {
|
||||
isTeamActive: () => true,
|
||||
isMemberActive: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
const summary = await dispatcher.dispatchDue({
|
||||
teamNames: ['team-a'],
|
||||
claimedBy: 'test-dispatcher',
|
||||
});
|
||||
|
||||
expect(summary).toMatchObject({ claimed: 1, delivered: 0, superseded: 1 });
|
||||
expect(inbox.inserted).toEqual([]);
|
||||
expect(
|
||||
outbox.items.get(`member-work-sync:team-a:bob:${status.agenda.fingerprint}`)
|
||||
).toMatchObject({
|
||||
status: 'superseded',
|
||||
lastError: 'member_runtime_inactive',
|
||||
});
|
||||
});
|
||||
|
||||
it('continues dispatching later claimed nudges when one item times out', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const { deps, store } = createDeps({ outboxStore: outbox });
|
||||
store.phase2ReadinessState = 'shadow_ready';
|
||||
|
||||
const status = await new MemberWorkSyncReconciler(deps).execute(
|
||||
{
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
},
|
||||
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||
);
|
||||
const firstItem = [...outbox.items.values()][0];
|
||||
expect(firstItem).toBeDefined();
|
||||
await outbox.ensurePending({
|
||||
id: `${firstItem!.id}:second`,
|
||||
teamName: firstItem!.teamName,
|
||||
memberName: firstItem!.memberName,
|
||||
agendaFingerprint: firstItem!.agendaFingerprint,
|
||||
payloadHash: `${firstItem!.payloadHash}:second`,
|
||||
payload: {
|
||||
...firstItem!.payload,
|
||||
workSyncIntentKey: 'test-second',
|
||||
},
|
||||
nowIso: status.evaluatedAt,
|
||||
});
|
||||
|
||||
const inserted: Array<Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]> = [];
|
||||
const inbox: MemberWorkSyncInboxNudgePort = {
|
||||
insertIfAbsent: async (input) => {
|
||||
if (input.messageId === firstItem!.id) {
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
inserted.push(input);
|
||||
return { inserted: true, messageId: input.messageId };
|
||||
},
|
||||
};
|
||||
const dispatcher = new MemberWorkSyncNudgeDispatcher({
|
||||
...deps,
|
||||
inboxNudge: inbox,
|
||||
});
|
||||
|
||||
await expect(
|
||||
dispatcher.dispatchDue({
|
||||
teamNames: ['team-a'],
|
||||
claimedBy: 'test-dispatcher',
|
||||
itemTimeoutMs: 1,
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
claimed: 2,
|
||||
delivered: 1,
|
||||
retryable: 1,
|
||||
});
|
||||
|
||||
expect(outbox.items.get(firstItem!.id)).toMatchObject({
|
||||
status: 'failed_retryable',
|
||||
lastError: 'nudge dispatch item timed out after 1ms',
|
||||
});
|
||||
expect(inserted).toHaveLength(1);
|
||||
expect(inserted[0]?.messageId).toBe(`${firstItem!.id}:second`);
|
||||
expect(outbox.items.get(`${firstItem!.id}:second`)).toMatchObject({
|
||||
status: 'delivered',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not late-deliver an item after item dispatch timeout resolves', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const { deps, store } = createDeps({ outboxStore: outbox });
|
||||
store.phase2ReadinessState = 'shadow_ready';
|
||||
|
||||
const status = await new MemberWorkSyncReconciler(deps).execute(
|
||||
{
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
},
|
||||
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||
);
|
||||
const firstItem = [...outbox.items.values()][0];
|
||||
expect(firstItem).toBeDefined();
|
||||
|
||||
let resolveInsertStarted!: () => void;
|
||||
const insertStarted = new Promise<void>((resolve) => {
|
||||
resolveInsertStarted = resolve;
|
||||
});
|
||||
let resolveInsert!: (value: { inserted: boolean; messageId: string }) => void;
|
||||
const insertResult = new Promise<{ inserted: boolean; messageId: string }>((resolve) => {
|
||||
resolveInsert = resolve;
|
||||
});
|
||||
const inbox: MemberWorkSyncInboxNudgePort = {
|
||||
insertIfAbsent: async () => {
|
||||
resolveInsertStarted();
|
||||
return insertResult;
|
||||
},
|
||||
};
|
||||
|
||||
const dispatch = new MemberWorkSyncNudgeDispatcher({
|
||||
...deps,
|
||||
inboxNudge: inbox,
|
||||
}).dispatchDue({
|
||||
teamNames: ['team-a'],
|
||||
claimedBy: 'test-dispatcher',
|
||||
itemTimeoutMs: 5,
|
||||
teamTimeoutMs: 100,
|
||||
});
|
||||
await insertStarted;
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
|
||||
await expect(dispatch).resolves.toMatchObject({
|
||||
claimed: 1,
|
||||
delivered: 0,
|
||||
retryable: 1,
|
||||
});
|
||||
expect(outbox.items.get(firstItem!.id)).toMatchObject({
|
||||
status: 'failed_retryable',
|
||||
lastError: 'nudge dispatch item timed out after 5ms',
|
||||
});
|
||||
|
||||
resolveInsert({ inserted: true, messageId: firstItem!.id });
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(outbox.items.get(`member-work-sync:team-a:bob:${status.agenda.fingerprint}`))
|
||||
.toMatchObject({
|
||||
status: 'failed_retryable',
|
||||
lastError: 'nudge dispatch item timed out after 5ms',
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('continues dispatching later claimed nudges when retry marking also hangs', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const { deps, store } = createDeps({ outboxStore: outbox });
|
||||
store.phase2ReadinessState = 'shadow_ready';
|
||||
|
||||
const status = await new MemberWorkSyncReconciler(deps).execute(
|
||||
{
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
},
|
||||
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||
);
|
||||
const firstItem = [...outbox.items.values()][0];
|
||||
expect(firstItem).toBeDefined();
|
||||
await outbox.ensurePending({
|
||||
id: `${firstItem!.id}:second`,
|
||||
teamName: firstItem!.teamName,
|
||||
memberName: firstItem!.memberName,
|
||||
agendaFingerprint: firstItem!.agendaFingerprint,
|
||||
payloadHash: `${firstItem!.payloadHash}:second`,
|
||||
payload: {
|
||||
...firstItem!.payload,
|
||||
workSyncIntentKey: 'test-second',
|
||||
},
|
||||
nowIso: status.evaluatedAt,
|
||||
});
|
||||
|
||||
const originalMarkFailed = outbox.markFailed.bind(outbox);
|
||||
outbox.markFailed = async (input) => {
|
||||
if (input.id === firstItem!.id) {
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
return originalMarkFailed(input);
|
||||
};
|
||||
const inserted: Array<Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]> = [];
|
||||
const inbox: MemberWorkSyncInboxNudgePort = {
|
||||
insertIfAbsent: async (input) => {
|
||||
if (input.messageId === firstItem!.id) {
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
inserted.push(input);
|
||||
return { inserted: true, messageId: input.messageId };
|
||||
},
|
||||
};
|
||||
const dispatcher = new MemberWorkSyncNudgeDispatcher({
|
||||
...deps,
|
||||
inboxNudge: inbox,
|
||||
});
|
||||
|
||||
await expect(
|
||||
dispatcher.dispatchDue({
|
||||
teamNames: ['team-a'],
|
||||
claimedBy: 'test-dispatcher',
|
||||
itemTimeoutMs: 1,
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
claimed: 2,
|
||||
delivered: 1,
|
||||
retryable: 1,
|
||||
});
|
||||
|
||||
expect(inserted).toHaveLength(1);
|
||||
expect(inserted[0]?.messageId).toBe(`${firstItem!.id}:second`);
|
||||
expect(outbox.items.get(`${firstItem!.id}:second`)).toMatchObject({
|
||||
status: 'delivered',
|
||||
});
|
||||
});
|
||||
|
||||
it('continues checking other teams when one team outbox claim hangs', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const warn = vi.fn();
|
||||
const claimDue = vi.fn(
|
||||
async (input: Parameters<MemberWorkSyncOutboxStorePort['claimDue']>[0]) => {
|
||||
if (input.teamName === 'stuck') {
|
||||
await new Promise<void>(() => undefined);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
);
|
||||
const inbox = new InMemoryInboxNudge();
|
||||
const { deps } = createDeps({
|
||||
outboxStore: { claimDue } as unknown as MemberWorkSyncOutboxStorePort,
|
||||
inboxNudge: inbox,
|
||||
});
|
||||
deps.logger = {
|
||||
debug: vi.fn(),
|
||||
warn,
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const dispatch = new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||
teamNames: ['stuck', 'healthy'],
|
||||
claimedBy: 'test-dispatcher',
|
||||
claimTimeoutMs: 10,
|
||||
teamTimeoutMs: 50,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
await expect(dispatch).resolves.toEqual({
|
||||
claimed: 0,
|
||||
delivered: 0,
|
||||
superseded: 0,
|
||||
retryable: 0,
|
||||
terminal: 0,
|
||||
});
|
||||
expect(claimDue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamName: 'healthy',
|
||||
})
|
||||
);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'member work sync nudge claim timed out',
|
||||
expect.objectContaining({
|
||||
teamName: 'stuck',
|
||||
timeoutMs: 10,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not mutate timed-out team items after team dispatch returns', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const warn = vi.fn();
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const { deps, store } = createDeps({ outboxStore: outbox });
|
||||
store.phase2ReadinessState = 'shadow_ready';
|
||||
|
||||
const status = await new MemberWorkSyncReconciler(deps).execute(
|
||||
{
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
},
|
||||
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||
);
|
||||
const firstItem = [...outbox.items.values()][0];
|
||||
expect(firstItem).toBeDefined();
|
||||
|
||||
let resolveInsertStarted!: () => void;
|
||||
const insertStarted = new Promise<void>((resolve) => {
|
||||
resolveInsertStarted = resolve;
|
||||
});
|
||||
let resolveInsert!: (value: { inserted: boolean; messageId: string }) => void;
|
||||
const insertResult = new Promise<{ inserted: boolean; messageId: string }>((resolve) => {
|
||||
resolveInsert = resolve;
|
||||
});
|
||||
const inbox: MemberWorkSyncInboxNudgePort = {
|
||||
insertIfAbsent: async () => {
|
||||
resolveInsertStarted();
|
||||
return insertResult;
|
||||
},
|
||||
};
|
||||
deps.logger = {
|
||||
debug: vi.fn(),
|
||||
warn,
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const dispatch = new MemberWorkSyncNudgeDispatcher({
|
||||
...deps,
|
||||
inboxNudge: inbox,
|
||||
}).dispatchDue({
|
||||
teamNames: ['team-a'],
|
||||
claimedBy: 'test-dispatcher',
|
||||
itemTimeoutMs: 100,
|
||||
teamTimeoutMs: 5,
|
||||
});
|
||||
await insertStarted;
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
|
||||
await expect(dispatch).resolves.toEqual({
|
||||
claimed: 0,
|
||||
delivered: 0,
|
||||
superseded: 0,
|
||||
retryable: 0,
|
||||
terminal: 0,
|
||||
});
|
||||
expect(outbox.items.get(firstItem!.id)).toMatchObject({
|
||||
status: 'claimed',
|
||||
});
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'member work sync team nudge dispatch timed out',
|
||||
expect.objectContaining({
|
||||
teamName: 'team-a',
|
||||
timeoutMs: 5,
|
||||
})
|
||||
);
|
||||
|
||||
resolveInsert({ inserted: true, messageId: firstItem!.id });
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(outbox.items.get(`member-work-sync:team-a:bob:${status.agenda.fingerprint}`))
|
||||
.toMatchObject({
|
||||
status: 'claimed',
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a status-only recovery nudge after a delivered nudge turn settles without a report', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const inbox = new InMemoryInboxNudge();
|
||||
|
|
@ -902,9 +1394,7 @@ describe('MemberWorkSync use cases', () => {
|
|||
busySignal: {
|
||||
isBusy: async () => {
|
||||
busyChecks += 1;
|
||||
return busyChecks > 1
|
||||
? { busy: true, reason: 'recent_tool_activity' }
|
||||
: { busy: false };
|
||||
return busyChecks > 1 ? { busy: true, reason: 'recent_tool_activity' } : { busy: false };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1093,6 +1583,31 @@ describe('MemberWorkSync use cases', () => {
|
|||
expect(recoverySummary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 });
|
||||
expect(inbox.inserted).toHaveLength(2);
|
||||
expect(inbox.inserted[1]?.messageId).toContain('agenda-sync-still-stuck');
|
||||
|
||||
clock.set('2026-04-29T01:02:00.000Z');
|
||||
store.metricsGeneratedAt = '2026-04-29T01:02:00.000Z';
|
||||
await reconciler.execute(
|
||||
{
|
||||
teamName: 'team-a',
|
||||
memberName: 'team-lead',
|
||||
},
|
||||
{ reconciledBy: 'queue', triggerReasons: ['manual_refresh'] }
|
||||
);
|
||||
|
||||
const recoveryItems = [...outbox.items.values()].filter((item) =>
|
||||
item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:')
|
||||
);
|
||||
expect(recoveryItems).toHaveLength(2);
|
||||
expect(new Set(recoveryItems.map((item) => item.id)).size).toBe(2);
|
||||
|
||||
const secondRecoverySummary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||
teamNames: ['team-a'],
|
||||
claimedBy: 'test-dispatcher',
|
||||
});
|
||||
|
||||
expect(secondRecoverySummary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 });
|
||||
expect(inbox.inserted).toHaveLength(3);
|
||||
expect(inbox.inserted[2]?.messageId).toContain('agenda-sync-still-stuck');
|
||||
});
|
||||
|
||||
it('creates an agenda-sync refresh recovery when a delivered nudge has a stale payload hash', async () => {
|
||||
|
|
@ -1396,6 +1911,184 @@ describe('MemberWorkSync use cases', () => {
|
|||
expect(inbox.inserted).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('creates a delivered-still-stuck recovery after an accepted still_working lease expires', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const inbox = new InMemoryInboxNudge();
|
||||
const { clock, deps, store } = createDeps({
|
||||
providerId: 'codex',
|
||||
outboxStore: outbox,
|
||||
inboxNudge: inbox,
|
||||
});
|
||||
store.phase2ReadinessState = 'shadow_ready';
|
||||
|
||||
const reconciler = new MemberWorkSyncReconciler(deps);
|
||||
const reporter = new MemberWorkSyncReporter(deps);
|
||||
const firstStatus = await reconciler.execute(
|
||||
{
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
},
|
||||
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||
);
|
||||
await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||
teamNames: ['team-a'],
|
||||
claimedBy: 'test-dispatcher',
|
||||
});
|
||||
|
||||
const baseId = `member-work-sync:team-a:bob:${firstStatus.agenda.fingerprint}`;
|
||||
expect(outbox.items.get(baseId)).toMatchObject({ status: 'delivered' });
|
||||
|
||||
await reporter.execute({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: firstStatus.agenda.fingerprint,
|
||||
reportToken: firstStatus.reportToken,
|
||||
taskIds: ['task-1'],
|
||||
leaseTtlMs: 120_000,
|
||||
source: 'test',
|
||||
});
|
||||
|
||||
clock.set('2026-04-29T00:10:00.000Z');
|
||||
store.phase2ReadinessState = 'blocked';
|
||||
store.phase2ReadinessReasons = ['would_nudge_rate_high'];
|
||||
store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z';
|
||||
store.recentEvents = [
|
||||
{
|
||||
id: 'old-report-accepted',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
kind: 'report_accepted',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: firstStatus.agenda.fingerprint,
|
||||
recordedAt: '2026-04-29T00:01:00.000Z',
|
||||
actionableCount: 1,
|
||||
providerId: 'codex',
|
||||
},
|
||||
{
|
||||
id: 'needs-sync-after-lease-expired',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
kind: 'status_evaluated',
|
||||
state: 'needs_sync',
|
||||
agendaFingerprint: firstStatus.agenda.fingerprint,
|
||||
recordedAt: '2026-04-29T00:04:00.000Z',
|
||||
actionableCount: 1,
|
||||
providerId: 'codex',
|
||||
},
|
||||
];
|
||||
|
||||
const expiredStatus = await reconciler.execute(
|
||||
{
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
},
|
||||
{ reconciledBy: 'queue', triggerReasons: ['manual_refresh'] }
|
||||
);
|
||||
|
||||
expect(expiredStatus).toMatchObject({
|
||||
state: 'needs_sync',
|
||||
diagnostics: expect.arrayContaining(['report_lease_expired']),
|
||||
});
|
||||
expect(expiredStatus.report).toBeUndefined();
|
||||
const recovery = [...outbox.items.values()].find((item) =>
|
||||
item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:')
|
||||
);
|
||||
expect(recovery).toMatchObject({
|
||||
status: 'pending',
|
||||
agendaFingerprint: firstStatus.agenda.fingerprint,
|
||||
});
|
||||
|
||||
const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||
teamNames: ['team-a'],
|
||||
claimedBy: 'test-dispatcher',
|
||||
});
|
||||
|
||||
expect(summary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 });
|
||||
expect(inbox.inserted).toHaveLength(2);
|
||||
expect(inbox.inserted[1]?.messageId).toContain('agenda-sync-still-stuck');
|
||||
});
|
||||
|
||||
it('creates a delivered-still-stuck recovery for mixed review pickup and native work under noisy metrics', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const inbox = new InMemoryInboxNudge();
|
||||
const inProgressItem: MemberWorkSyncActionableWorkItem = {
|
||||
...workItem,
|
||||
reason: 'owned_in_progress_task',
|
||||
evidence: {
|
||||
status: 'in_progress',
|
||||
owner: 'bob',
|
||||
},
|
||||
};
|
||||
const { clock, deps, store } = createDeps({
|
||||
items: [reviewPickupItem, inProgressItem],
|
||||
providerId: 'codex',
|
||||
outboxStore: outbox,
|
||||
inboxNudge: inbox,
|
||||
});
|
||||
store.phase2ReadinessState = 'shadow_ready';
|
||||
|
||||
const reconciler = new MemberWorkSyncReconciler(deps);
|
||||
const firstStatus = await reconciler.execute(
|
||||
{
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
},
|
||||
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||
);
|
||||
await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||
teamNames: ['team-a'],
|
||||
claimedBy: 'test-dispatcher',
|
||||
});
|
||||
|
||||
const baseId = `member-work-sync:team-a:bob:${firstStatus.agenda.fingerprint}`;
|
||||
expect(outbox.items.get(baseId)).toMatchObject({ status: 'delivered' });
|
||||
|
||||
clock.set('2026-04-29T00:10:00.000Z');
|
||||
store.phase2ReadinessState = 'blocked';
|
||||
store.phase2ReadinessReasons = ['would_nudge_rate_high'];
|
||||
store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z';
|
||||
store.recentEvents = [
|
||||
{
|
||||
id: 'mixed-needs-sync-stable',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
kind: 'status_evaluated',
|
||||
state: 'needs_sync',
|
||||
agendaFingerprint: firstStatus.agenda.fingerprint,
|
||||
recordedAt: '2026-04-29T00:02:00.000Z',
|
||||
actionableCount: 2,
|
||||
providerId: 'codex',
|
||||
},
|
||||
];
|
||||
|
||||
await reconciler.execute(
|
||||
{
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
},
|
||||
{ reconciledBy: 'queue', triggerReasons: ['manual_refresh'] }
|
||||
);
|
||||
|
||||
const recovery = [...outbox.items.values()].find((item) =>
|
||||
item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:')
|
||||
);
|
||||
expect(recovery).toMatchObject({
|
||||
status: 'pending',
|
||||
agendaFingerprint: firstStatus.agenda.fingerprint,
|
||||
});
|
||||
expect(recovery?.payload.text).toContain('still no accepted member_work_sync_report');
|
||||
|
||||
const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||
teamNames: ['team-a'],
|
||||
claimedBy: 'test-dispatcher',
|
||||
});
|
||||
|
||||
expect(summary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 });
|
||||
expect(inbox.inserted).toHaveLength(2);
|
||||
expect(inbox.inserted[1]?.messageId).toContain('agenda-sync-still-stuck');
|
||||
});
|
||||
|
||||
it('records an existing delivered agenda nudge as skipped before still-stuck recovery age', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const inbox = new InMemoryInboxNudge();
|
||||
|
|
@ -1511,6 +2204,33 @@ describe('MemberWorkSync use cases', () => {
|
|||
expect(recoverySummary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 });
|
||||
expect(inbox.inserted).toHaveLength(2);
|
||||
expect(inbox.inserted[1]?.messageId).toContain('agenda-sync-still-stuck');
|
||||
|
||||
clock.set('2026-04-29T01:02:00.000Z');
|
||||
store.phase2ReadinessState = 'shadow_ready';
|
||||
store.phase2ReadinessReasons = [];
|
||||
store.metricsGeneratedAt = '2026-04-29T01:02:00.000Z';
|
||||
await reconciler.execute(
|
||||
{
|
||||
teamName: 'team-a',
|
||||
memberName: 'team-lead',
|
||||
},
|
||||
{ reconciledBy: 'queue', triggerReasons: ['manual_refresh'] }
|
||||
);
|
||||
|
||||
const recoveryItems = [...outbox.items.values()].filter((item) =>
|
||||
item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:')
|
||||
);
|
||||
expect(recoveryItems).toHaveLength(2);
|
||||
expect(new Set(recoveryItems.map((item) => item.id)).size).toBe(2);
|
||||
|
||||
const secondRecoverySummary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||
teamNames: ['team-a'],
|
||||
claimedBy: 'test-dispatcher',
|
||||
});
|
||||
|
||||
expect(secondRecoverySummary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 });
|
||||
expect(inbox.inserted).toHaveLength(3);
|
||||
expect(inbox.inserted[2]?.messageId).toContain('agenda-sync-still-stuck');
|
||||
});
|
||||
|
||||
it('creates a still-stuck recovery when a terminal inbox conflict blocks an agenda nudge', async () => {
|
||||
|
|
@ -2096,6 +2816,45 @@ describe('MemberWorkSync use cases', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('uses the watchdog cooldown retry deadline instead of exponential retry backoff', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const inbox = new InMemoryInboxNudge();
|
||||
const { deps, store } = createDeps({
|
||||
outboxStore: outbox,
|
||||
inboxNudge: inbox,
|
||||
watchdogCooldown: {
|
||||
hasRecentNudge: async () => true,
|
||||
getRecentNudgeCooldown: async () => ({
|
||||
active: true,
|
||||
retryAfterIso: '2026-04-29T00:10:00.000Z',
|
||||
}),
|
||||
},
|
||||
});
|
||||
store.phase2ReadinessState = 'shadow_ready';
|
||||
|
||||
const current = await new MemberWorkSyncReconciler(deps).execute(
|
||||
{
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
},
|
||||
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||
);
|
||||
const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||
teamNames: ['team-a'],
|
||||
claimedBy: 'test-dispatcher',
|
||||
});
|
||||
|
||||
expect(summary).toMatchObject({ claimed: 1, delivered: 0, retryable: 1 });
|
||||
expect(inbox.inserted).toEqual([]);
|
||||
expect(
|
||||
outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)
|
||||
).toMatchObject({
|
||||
status: 'failed_retryable',
|
||||
lastError: 'watchdog_cooldown_active',
|
||||
nextAttemptAt: '2026-04-29T00:10:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses bounded retry backoff when inbox delivery fails', async () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const inbox = new InMemoryInboxNudge();
|
||||
|
|
@ -2185,4 +2944,41 @@ describe('MemberWorkSync use cases', () => {
|
|||
});
|
||||
expect(store.writes.at(-1)?.state).toBe('still_working');
|
||||
});
|
||||
|
||||
it('supersedes pending controller intents when the member runtime is inactive', async () => {
|
||||
const { deps, store } = createDeps();
|
||||
const reader = new MemberWorkSyncReconciler(deps);
|
||||
const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
|
||||
store.pendingIntents.set('intent-1', {
|
||||
id: 'intent-1',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
status: 'pending',
|
||||
reason: 'control_api_unavailable',
|
||||
recordedAt: '2026-04-29T00:00:01.000Z',
|
||||
request: {
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
state: 'still_working',
|
||||
agendaFingerprint: current.agenda.fingerprint,
|
||||
reportToken: current.reportToken,
|
||||
leaseTtlMs: 120_000,
|
||||
source: 'mcp',
|
||||
},
|
||||
});
|
||||
|
||||
const summary = await new MemberWorkSyncPendingReportIntentReplayer({
|
||||
...deps,
|
||||
lifecycle: {
|
||||
isTeamActive: () => true,
|
||||
isMemberActive: () => false,
|
||||
},
|
||||
}).replayTeam('team-a');
|
||||
|
||||
expect(summary).toEqual({ processed: 1, accepted: 0, rejected: 0, superseded: 1 });
|
||||
expect(store.pendingIntents.get('intent-1')).toMatchObject({
|
||||
status: 'superseded',
|
||||
resultCode: 'member_runtime_inactive',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -305,6 +305,107 @@ describe('JsonMemberWorkSyncStore', () => {
|
|||
).toEqual(['bob', 'tom']);
|
||||
});
|
||||
|
||||
it('repairs a stale processed pending-report index route when member report is pending', async () => {
|
||||
const request = {
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
state: 'still_working' as const,
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
reportToken: 'wrs:v1.test',
|
||||
source: 'mcp' as const,
|
||||
};
|
||||
|
||||
await store.appendPendingReport(request, 'control_api_unavailable');
|
||||
const [intent] = await store.listPendingReports('team-a');
|
||||
await store.markPendingReportProcessed('team-a', intent!.id, {
|
||||
status: 'accepted',
|
||||
resultCode: 'accepted',
|
||||
processedAt: '2026-04-29T00:01:00.000Z',
|
||||
});
|
||||
|
||||
const reportsPath = join(memberWorkSyncDir(root, 'team-a', 'bob'), 'reports.json');
|
||||
const reports = JSON.parse(await readFile(reportsPath, 'utf8'));
|
||||
reports.intents[intent!.id] = {
|
||||
...reports.intents[intent!.id],
|
||||
status: 'pending',
|
||||
};
|
||||
delete reports.intents[intent!.id].resultCode;
|
||||
delete reports.intents[intent!.id].processedAt;
|
||||
await writeFile(reportsPath, JSON.stringify(reports), 'utf8');
|
||||
|
||||
const pending = await store.listPendingReports('team-a');
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0]).toMatchObject({
|
||||
id: intent!.id,
|
||||
memberName: 'bob',
|
||||
status: 'pending',
|
||||
});
|
||||
});
|
||||
|
||||
it('repairs stale pending-report update routes before marking processed', async () => {
|
||||
const request = {
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
state: 'still_working' as const,
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
reportToken: 'wrs:v1.test',
|
||||
source: 'mcp' as const,
|
||||
};
|
||||
|
||||
await store.appendPendingReport(request, 'control_api_unavailable');
|
||||
await mkdir(memberWorkSyncDir(root, 'team-a', 'tom'), { recursive: true });
|
||||
const [intent] = await store.listPendingReports('team-a');
|
||||
await writeFile(
|
||||
join(memberWorkSyncDir(root, 'team-a', 'tom'), 'reports.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
intents: {
|
||||
[intent!.id]: {
|
||||
...intent!,
|
||||
teamName: 'other-team',
|
||||
memberName: 'tom',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
const indexPath = join(
|
||||
root,
|
||||
'team-a',
|
||||
'.member-work-sync',
|
||||
'indexes',
|
||||
'pending-reports-index.json'
|
||||
);
|
||||
const index = JSON.parse(await readFile(indexPath, 'utf8'));
|
||||
index.items[intent!.id] = {
|
||||
...index.items[intent!.id],
|
||||
memberKey: 'tom',
|
||||
memberName: 'tom',
|
||||
};
|
||||
await writeFile(indexPath, JSON.stringify(index), 'utf8');
|
||||
|
||||
await store.markPendingReportProcessed('team-a', intent!.id, {
|
||||
status: 'accepted',
|
||||
resultCode: 'accepted',
|
||||
processedAt: '2026-04-29T00:01:00.000Z',
|
||||
});
|
||||
|
||||
const reports = JSON.parse(
|
||||
await readFile(join(memberWorkSyncDir(root, 'team-a', 'bob'), 'reports.json'), 'utf8')
|
||||
);
|
||||
expect(reports.intents[intent!.id]).toMatchObject({
|
||||
memberName: 'bob',
|
||||
status: 'accepted',
|
||||
resultCode: 'accepted',
|
||||
});
|
||||
const repaired = JSON.parse(await readFile(indexPath, 'utf8'));
|
||||
expect(repaired.items[intent!.id]).toMatchObject({
|
||||
memberKey: 'bob',
|
||||
memberName: 'bob',
|
||||
status: 'accepted',
|
||||
});
|
||||
});
|
||||
|
||||
it('records bounded shadow metrics from status writes', async () => {
|
||||
await store.write(makeStatus({}));
|
||||
await store.write(
|
||||
|
|
@ -611,6 +712,60 @@ describe('JsonMemberWorkSyncStore', () => {
|
|||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('treats invalid retry delay timestamps as due so retryable items cannot sleep forever', async () => {
|
||||
const input = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:abc',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
payloadHash: 'hash-a',
|
||||
payload: makeNudgePayload(),
|
||||
nowIso: '2026-04-29T00:00:00.000Z',
|
||||
};
|
||||
|
||||
await store.ensurePending(input);
|
||||
const [claimed] = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-a',
|
||||
nowIso: '2026-04-29T00:01:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
await store.markFailed({
|
||||
teamName: 'team-a',
|
||||
id: input.id,
|
||||
attemptGeneration: claimed.attemptGeneration,
|
||||
retryable: true,
|
||||
error: 'member_busy:active_tool_activity',
|
||||
nextAttemptAt: '2026-04-29T00:30:00.000Z',
|
||||
nowIso: '2026-04-29T00:02:00.000Z',
|
||||
});
|
||||
|
||||
const memberOutboxPath = join(memberWorkSyncDir(root, 'team-a', 'bob'), 'outbox.json');
|
||||
const memberOutbox = JSON.parse(await readFile(memberOutboxPath, 'utf8'));
|
||||
memberOutbox.items[input.id].nextAttemptAt = 'not-a-date';
|
||||
await writeFile(memberOutboxPath, JSON.stringify(memberOutbox), 'utf8');
|
||||
|
||||
const indexPath = join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json');
|
||||
const index = JSON.parse(await readFile(indexPath, 'utf8'));
|
||||
index.items[input.id].nextAttemptAt = 'not-a-date';
|
||||
await writeFile(indexPath, JSON.stringify(index), 'utf8');
|
||||
|
||||
await expect(
|
||||
store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-b',
|
||||
nowIso: '2026-04-29T00:04:00.000Z',
|
||||
limit: 1,
|
||||
})
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: input.id,
|
||||
status: 'claimed',
|
||||
attemptGeneration: claimed.attemptGeneration + 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears retry delay when a retryable outbox item is delivered', async () => {
|
||||
const input = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:abc',
|
||||
|
|
@ -672,6 +827,185 @@ describe('JsonMemberWorkSyncStore', () => {
|
|||
expect(index.items[input.id]).not.toHaveProperty('nextAttemptAt');
|
||||
});
|
||||
|
||||
it('keeps delivered outbox items delivered when a late retry mark races after delivery', async () => {
|
||||
const input = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:abc',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
payloadHash: 'hash-a',
|
||||
payload: makeNudgePayload(),
|
||||
nowIso: '2026-04-29T00:00:00.000Z',
|
||||
};
|
||||
|
||||
await store.ensurePending(input);
|
||||
const [claimed] = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-a',
|
||||
nowIso: '2026-04-29T00:01:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
await store.markDelivered({
|
||||
teamName: 'team-a',
|
||||
id: input.id,
|
||||
attemptGeneration: claimed.attemptGeneration,
|
||||
deliveredMessageId: 'message-1',
|
||||
nowIso: '2026-04-29T00:01:30.000Z',
|
||||
});
|
||||
await store.markFailed({
|
||||
teamName: 'team-a',
|
||||
id: input.id,
|
||||
attemptGeneration: claimed.attemptGeneration,
|
||||
retryable: true,
|
||||
error: 'nudge dispatch item timed out after 1ms',
|
||||
nextAttemptAt: '2026-04-29T00:03:00.000Z',
|
||||
nowIso: '2026-04-29T00:02:00.000Z',
|
||||
});
|
||||
|
||||
const memberOutbox = JSON.parse(
|
||||
await readFile(
|
||||
join(root, 'team-a', 'members', 'bob', '.member-work-sync', 'outbox.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
expect(memberOutbox.items[input.id]).toMatchObject({
|
||||
status: 'delivered',
|
||||
deliveredMessageId: 'message-1',
|
||||
});
|
||||
expect(memberOutbox.items[input.id]).not.toHaveProperty('lastError');
|
||||
expect(memberOutbox.items[input.id]).not.toHaveProperty('nextAttemptAt');
|
||||
|
||||
const index = JSON.parse(
|
||||
await readFile(
|
||||
join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
expect(index.items[input.id]).toMatchObject({
|
||||
status: 'delivered',
|
||||
});
|
||||
expect(index.items[input.id]).not.toHaveProperty('nextAttemptAt');
|
||||
});
|
||||
|
||||
it('keeps retryable outbox items retryable when a late delivery races after timeout', async () => {
|
||||
const input = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:retry-race',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:retry-race',
|
||||
payloadHash: 'hash-a',
|
||||
payload: makeNudgePayload(),
|
||||
nowIso: '2026-04-29T00:00:00.000Z',
|
||||
};
|
||||
|
||||
await store.ensurePending(input);
|
||||
const [claimed] = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-a',
|
||||
nowIso: '2026-04-29T00:01:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
await store.markFailed({
|
||||
teamName: 'team-a',
|
||||
id: input.id,
|
||||
attemptGeneration: claimed.attemptGeneration,
|
||||
retryable: true,
|
||||
error: 'nudge dispatch item timed out after 1ms',
|
||||
nextAttemptAt: '2026-04-29T00:03:00.000Z',
|
||||
nowIso: '2026-04-29T00:02:00.000Z',
|
||||
});
|
||||
await store.markDelivered({
|
||||
teamName: 'team-a',
|
||||
id: input.id,
|
||||
attemptGeneration: claimed.attemptGeneration,
|
||||
deliveredMessageId: 'late-message',
|
||||
nowIso: '2026-04-29T00:02:30.000Z',
|
||||
});
|
||||
|
||||
const memberOutbox = JSON.parse(
|
||||
await readFile(
|
||||
join(root, 'team-a', 'members', 'bob', '.member-work-sync', 'outbox.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
expect(memberOutbox.items[input.id]).toMatchObject({
|
||||
status: 'failed_retryable',
|
||||
lastError: 'nudge dispatch item timed out after 1ms',
|
||||
nextAttemptAt: '2026-04-29T00:03:00.000Z',
|
||||
});
|
||||
expect(memberOutbox.items[input.id]).not.toHaveProperty('deliveredMessageId');
|
||||
|
||||
const index = JSON.parse(
|
||||
await readFile(
|
||||
join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
expect(index.items[input.id]).toMatchObject({
|
||||
status: 'failed_retryable',
|
||||
nextAttemptAt: '2026-04-29T00:03:00.000Z',
|
||||
});
|
||||
expect(index.items[input.id]).not.toHaveProperty('deliveredMessageId');
|
||||
});
|
||||
|
||||
it('keeps terminal outbox items terminal when a late delivery races after failure', async () => {
|
||||
const input = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:terminal-race',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:terminal-race',
|
||||
payloadHash: 'hash-a',
|
||||
payload: makeNudgePayload(),
|
||||
nowIso: '2026-04-29T00:00:00.000Z',
|
||||
};
|
||||
|
||||
await store.ensurePending(input);
|
||||
const [claimed] = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-a',
|
||||
nowIso: '2026-04-29T00:01:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
await store.markFailed({
|
||||
teamName: 'team-a',
|
||||
id: input.id,
|
||||
attemptGeneration: claimed.attemptGeneration,
|
||||
retryable: false,
|
||||
error: 'inbox_payload_conflict',
|
||||
nowIso: '2026-04-29T00:01:30.000Z',
|
||||
});
|
||||
await store.markDelivered({
|
||||
teamName: 'team-a',
|
||||
id: input.id,
|
||||
attemptGeneration: claimed.attemptGeneration,
|
||||
deliveredMessageId: 'late-message',
|
||||
nowIso: '2026-04-29T00:02:00.000Z',
|
||||
});
|
||||
|
||||
const memberOutbox = JSON.parse(
|
||||
await readFile(
|
||||
join(root, 'team-a', 'members', 'bob', '.member-work-sync', 'outbox.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
expect(memberOutbox.items[input.id]).toMatchObject({
|
||||
status: 'failed_terminal',
|
||||
lastError: 'inbox_payload_conflict',
|
||||
});
|
||||
expect(memberOutbox.items[input.id]).not.toHaveProperty('deliveredMessageId');
|
||||
|
||||
const index = JSON.parse(
|
||||
await readFile(
|
||||
join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
expect(index.items[input.id]).toMatchObject({
|
||||
status: 'failed_terminal',
|
||||
});
|
||||
expect(index.items[input.id]).not.toHaveProperty('deliveredMessageId');
|
||||
});
|
||||
|
||||
it('finds recent recovery outbox rows by logical intent key', async () => {
|
||||
const olderInput = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:older',
|
||||
|
|
@ -761,6 +1095,34 @@ describe('JsonMemberWorkSyncStore', () => {
|
|||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('ignores superseded rows for logical recovery lookup', async () => {
|
||||
const input = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:superseded',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:superseded',
|
||||
payloadHash: 'hash-a',
|
||||
payload: makeNudgePayload({ workSyncIntentKey: 'proof-missing:message-1' }),
|
||||
nowIso: '2026-04-29T00:00:00.000Z',
|
||||
};
|
||||
await store.ensurePending(input);
|
||||
await store.markSuperseded({
|
||||
teamName: 'team-a',
|
||||
id: input.id,
|
||||
reason: 'status_no_longer_matches_outbox',
|
||||
nowIso: '2026-04-29T00:01:00.000Z',
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.findRecentRecoveryByIntent({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
intentKey: 'proof-missing:message-1',
|
||||
sinceIso: '2026-04-29T00:00:00.000Z',
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('claims due outbox items and fences terminal updates by attempt generation', async () => {
|
||||
const input = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:abc',
|
||||
|
|
@ -838,6 +1200,98 @@ describe('JsonMemberWorkSyncStore', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('reclaims stale claimed outbox items without waiting for a fresh reconcile', async () => {
|
||||
const input = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:stale-claim',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:stale-claim',
|
||||
payloadHash: 'hash-a',
|
||||
payload: makeNudgePayload(),
|
||||
nowIso: '2026-04-29T00:00:00.000Z',
|
||||
};
|
||||
await store.ensurePending(input);
|
||||
|
||||
const [claimed] = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-a',
|
||||
nowIso: '2026-04-29T00:01:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
expect(claimed).toMatchObject({
|
||||
id: input.id,
|
||||
status: 'claimed',
|
||||
attemptGeneration: 1,
|
||||
claimedBy: 'dispatcher-a',
|
||||
claimedAt: '2026-04-29T00:01:00.000Z',
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-b',
|
||||
nowIso: '2026-04-29T00:05:59.000Z',
|
||||
limit: 1,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
|
||||
const [reclaimed] = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-b',
|
||||
nowIso: '2026-04-29T00:06:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
expect(reclaimed).toMatchObject({
|
||||
id: input.id,
|
||||
status: 'claimed',
|
||||
attemptGeneration: 2,
|
||||
claimedBy: 'dispatcher-b',
|
||||
claimedAt: '2026-04-29T00:06:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats future claimedAt outbox items as stale', async () => {
|
||||
const input = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:future-claim',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:future-claim',
|
||||
payloadHash: 'hash-a',
|
||||
payload: makeNudgePayload(),
|
||||
nowIso: '2026-04-29T00:00:00.000Z',
|
||||
};
|
||||
await store.ensurePending(input);
|
||||
|
||||
const [claimed] = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-a',
|
||||
nowIso: '2026-04-29T00:10:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
expect(claimed).toMatchObject({
|
||||
id: input.id,
|
||||
status: 'claimed',
|
||||
attemptGeneration: 1,
|
||||
claimedBy: 'dispatcher-a',
|
||||
claimedAt: '2026-04-29T00:10:00.000Z',
|
||||
});
|
||||
|
||||
const [reclaimed] = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-b',
|
||||
nowIso: '2026-04-29T00:01:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
expect(reclaimed).toMatchObject({
|
||||
id: input.id,
|
||||
status: 'claimed',
|
||||
attemptGeneration: 2,
|
||||
claimedBy: 'dispatcher-b',
|
||||
claimedAt: '2026-04-29T00:01:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('claims due outbox items from the index without scanning unrelated member outboxes', async () => {
|
||||
const bobInput = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:abc',
|
||||
|
|
@ -1210,6 +1664,221 @@ describe('JsonMemberWorkSyncStore', () => {
|
|||
expect(claimed.map((item) => item.memberName).sort()).toEqual(['bob', 'tom']);
|
||||
});
|
||||
|
||||
it('rewrites stale due outbox member keys while claiming', async () => {
|
||||
const input = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:abc',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
payloadHash: 'hash-a',
|
||||
payload: makeNudgePayload(),
|
||||
nowIso: '2026-04-29T00:00:00.000Z',
|
||||
};
|
||||
await store.ensurePending(input);
|
||||
const indexPath = join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json');
|
||||
const index = JSON.parse(await readFile(indexPath, 'utf8'));
|
||||
index.items[input.id] = {
|
||||
...index.items[input.id],
|
||||
memberKey: 'tom',
|
||||
memberName: 'bob',
|
||||
};
|
||||
await writeFile(indexPath, JSON.stringify(index), 'utf8');
|
||||
|
||||
const [claimed] = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-a',
|
||||
nowIso: '2026-04-29T00:01:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
expect(claimed).toMatchObject({
|
||||
id: input.id,
|
||||
memberName: 'bob',
|
||||
status: 'claimed',
|
||||
});
|
||||
const repaired = JSON.parse(await readFile(indexPath, 'utf8'));
|
||||
expect(repaired.items[input.id]).toMatchObject({
|
||||
memberKey: 'bob',
|
||||
memberName: 'bob',
|
||||
status: 'claimed',
|
||||
});
|
||||
});
|
||||
|
||||
it('repairs stale outbox update routes before marking failures', async () => {
|
||||
const input = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:abc',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
payloadHash: 'hash-a',
|
||||
payload: makeNudgePayload(),
|
||||
nowIso: '2026-04-29T00:00:00.000Z',
|
||||
};
|
||||
await store.ensurePending(input);
|
||||
await mkdir(memberWorkSyncDir(root, 'team-a', 'tom'), { recursive: true });
|
||||
const [claimed] = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-a',
|
||||
nowIso: '2026-04-29T00:01:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
await writeFile(
|
||||
join(memberWorkSyncDir(root, 'team-a', 'tom'), 'outbox.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
items: {
|
||||
[input.id]: {
|
||||
...input,
|
||||
teamName: 'other-team',
|
||||
memberName: 'tom',
|
||||
status: 'claimed',
|
||||
attemptGeneration: claimed!.attemptGeneration,
|
||||
claimedBy: 'dispatcher-a',
|
||||
claimedAt: '2026-04-29T00:01:00.000Z',
|
||||
updatedAt: '2026-04-29T00:01:00.000Z',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
const indexPath = join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json');
|
||||
const index = JSON.parse(await readFile(indexPath, 'utf8'));
|
||||
index.items[input.id] = {
|
||||
...index.items[input.id],
|
||||
memberKey: 'tom',
|
||||
memberName: 'tom',
|
||||
};
|
||||
await writeFile(indexPath, JSON.stringify(index), 'utf8');
|
||||
|
||||
await store.markFailed({
|
||||
teamName: 'team-a',
|
||||
id: input.id,
|
||||
attemptGeneration: claimed!.attemptGeneration,
|
||||
error: 'delivery failed',
|
||||
retryable: true,
|
||||
nextAttemptAt: '2026-04-29T00:10:00.000Z',
|
||||
nowIso: '2026-04-29T00:02:00.000Z',
|
||||
});
|
||||
|
||||
const memberOutbox = JSON.parse(
|
||||
await readFile(join(memberWorkSyncDir(root, 'team-a', 'bob'), 'outbox.json'), 'utf8')
|
||||
);
|
||||
expect(memberOutbox.items[input.id]).toMatchObject({
|
||||
status: 'failed_retryable',
|
||||
lastError: 'delivery failed',
|
||||
nextAttemptAt: '2026-04-29T00:10:00.000Z',
|
||||
});
|
||||
const repaired = JSON.parse(await readFile(indexPath, 'utf8'));
|
||||
expect(repaired.items[input.id]).toMatchObject({
|
||||
memberKey: 'bob',
|
||||
memberName: 'bob',
|
||||
status: 'failed_retryable',
|
||||
});
|
||||
});
|
||||
|
||||
it('repairs wrong-member due outbox index routes before returning a limited claim', async () => {
|
||||
const bobInput = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:abc',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
payloadHash: 'hash-a',
|
||||
payload: makeNudgePayload(),
|
||||
nowIso: '2026-04-29T00:00:00.000Z',
|
||||
};
|
||||
await store.ensurePending(bobInput);
|
||||
await mkdir(memberWorkSyncDir(root, 'team-a', 'tom'), { recursive: true });
|
||||
await writeFile(
|
||||
join(memberWorkSyncDir(root, 'team-a', 'tom'), 'outbox.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
items: {
|
||||
[bobInput.id]: {
|
||||
...bobInput,
|
||||
teamName: 'other-team',
|
||||
memberName: 'tom',
|
||||
status: 'pending',
|
||||
createdAt: '2026-04-29T00:00:00.000Z',
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
const indexPath = join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json');
|
||||
const index = JSON.parse(await readFile(indexPath, 'utf8'));
|
||||
index.items[bobInput.id] = {
|
||||
...index.items[bobInput.id],
|
||||
memberKey: 'tom',
|
||||
memberName: 'tom',
|
||||
};
|
||||
await writeFile(indexPath, JSON.stringify(index), 'utf8');
|
||||
|
||||
const claimed = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-a',
|
||||
nowIso: '2026-04-29T00:01:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
expect(claimed.map((item) => item.memberName)).toEqual(['bob']);
|
||||
const repaired = JSON.parse(await readFile(indexPath, 'utf8'));
|
||||
expect(repaired.items[bobInput.id]).toMatchObject({
|
||||
memberKey: 'bob',
|
||||
memberName: 'bob',
|
||||
status: 'claimed',
|
||||
});
|
||||
});
|
||||
|
||||
it('repairs stale terminal outbox index routes when member-scoped item is due', async () => {
|
||||
const input = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:abc',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:abc',
|
||||
payloadHash: 'hash-a',
|
||||
payload: makeNudgePayload(),
|
||||
nowIso: '2026-04-29T00:00:00.000Z',
|
||||
};
|
||||
await store.ensurePending(input);
|
||||
const [claimed] = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-a',
|
||||
nowIso: '2026-04-29T00:01:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
await store.markDelivered({
|
||||
teamName: 'team-a',
|
||||
id: input.id,
|
||||
attemptGeneration: claimed!.attemptGeneration,
|
||||
deliveredMessageId: input.id,
|
||||
nowIso: '2026-04-29T00:02:00.000Z',
|
||||
});
|
||||
|
||||
const memberOutboxPath = join(memberWorkSyncDir(root, 'team-a', 'bob'), 'outbox.json');
|
||||
const memberOutbox = JSON.parse(await readFile(memberOutboxPath, 'utf8'));
|
||||
memberOutbox.items[input.id] = {
|
||||
...memberOutbox.items[input.id],
|
||||
status: 'pending',
|
||||
updatedAt: '2026-04-29T00:03:00.000Z',
|
||||
};
|
||||
delete memberOutbox.items[input.id].deliveredMessageId;
|
||||
await writeFile(memberOutboxPath, JSON.stringify(memberOutbox), 'utf8');
|
||||
|
||||
const [reclaimed] = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-b',
|
||||
nowIso: '2026-04-29T00:04:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
expect(reclaimed).toMatchObject({
|
||||
id: input.id,
|
||||
status: 'claimed',
|
||||
attemptGeneration: 2,
|
||||
claimedBy: 'dispatcher-b',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to legacy v1 status and materializes legacy outbox during claim', async () => {
|
||||
const auditEvents: MemberWorkSyncAuditEvent[] = [];
|
||||
store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(root), {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MemberWorkSyncEventQueue } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('MemberWorkSyncEventQueue', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -230,6 +229,29 @@ describe('MemberWorkSyncEventQueue', () => {
|
|||
await queue.stop();
|
||||
});
|
||||
|
||||
it('can reconcile inactive teams when the caller needs inactive statuses refreshed', async () => {
|
||||
const reconcile = vi.fn(async () => undefined);
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
quietWindowMs: 1,
|
||||
reconcile,
|
||||
isTeamActive: () => false,
|
||||
reconcileInactiveTeams: true,
|
||||
});
|
||||
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'manual_refresh' });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(reconcile).toHaveBeenCalledWith(
|
||||
{ teamName: 'team-a', memberName: 'bob' },
|
||||
expect.objectContaining({
|
||||
reconciledBy: 'queue',
|
||||
triggerReasons: ['manual_refresh'],
|
||||
})
|
||||
);
|
||||
await queue.stop();
|
||||
expect(queue.getDiagnostics()).toMatchObject({ dropped: 0, reconciled: 1 });
|
||||
});
|
||||
|
||||
it('runs one follow-up pass when events arrive during an active reconcile', async () => {
|
||||
let release: () => void = () => {
|
||||
throw new Error('reconcile did not start');
|
||||
|
|
@ -370,4 +392,210 @@ describe('MemberWorkSyncEventQueue', () => {
|
|||
expect(reconciles).toHaveLength(2);
|
||||
await queue.stop();
|
||||
});
|
||||
|
||||
it('retries a failed reconcile with bounded backoff', async () => {
|
||||
const reconciles: unknown[] = [];
|
||||
const auditEvents: string[] = [];
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
quietWindowMs: 1,
|
||||
retryDelayMs: 10,
|
||||
maxRetryAttempts: 2,
|
||||
reconcile: async (request) => {
|
||||
reconciles.push(request);
|
||||
if (reconciles.length === 1) {
|
||||
throw new Error('transient');
|
||||
}
|
||||
},
|
||||
isTeamActive: () => true,
|
||||
auditJournal: {
|
||||
append: async (event) => {
|
||||
auditEvents.push(event.event);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'turn_settled' });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(reconciles).toHaveLength(1);
|
||||
expect(queue.getDiagnostics()).toMatchObject({ failed: 1, queued: 1, reconciled: 0 });
|
||||
expect(auditEvents).toEqual(['queue_enqueued', 'queue_retry_scheduled']);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9);
|
||||
expect(reconciles).toHaveLength(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(reconciles).toHaveLength(2);
|
||||
expect(queue.getDiagnostics()).toMatchObject({ failed: 1, queued: 0, reconciled: 1 });
|
||||
|
||||
await queue.stop();
|
||||
});
|
||||
|
||||
it('times out a hung reconcile and retries so the member cannot stay running forever', async () => {
|
||||
let reconcileCalls = 0;
|
||||
const auditEvents: string[] = [];
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
quietWindowMs: 1,
|
||||
retryDelayMs: 10,
|
||||
reconcileTimeoutMs: 20,
|
||||
maxRetryAttempts: 1,
|
||||
reconcile: async () => {
|
||||
reconcileCalls += 1;
|
||||
if (reconcileCalls === 1) {
|
||||
await new Promise<void>(() => undefined);
|
||||
}
|
||||
},
|
||||
isTeamActive: () => true,
|
||||
auditJournal: {
|
||||
append: async (event) => {
|
||||
auditEvents.push(event.event);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'turn_settled' });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(reconcileCalls).toBe(1);
|
||||
expect(queue.getDiagnostics()).toMatchObject({ running: 1, queued: 0, failed: 0 });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
|
||||
expect(queue.getDiagnostics()).toMatchObject({
|
||||
running: 0,
|
||||
queued: 1,
|
||||
failed: 1,
|
||||
reconciled: 0,
|
||||
});
|
||||
expect(auditEvents).toEqual(['queue_enqueued', 'queue_retry_scheduled']);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9);
|
||||
expect(reconcileCalls).toBe(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(reconcileCalls).toBe(2);
|
||||
expect(queue.getDiagnostics()).toMatchObject({
|
||||
running: 0,
|
||||
queued: 0,
|
||||
failed: 1,
|
||||
reconciled: 1,
|
||||
});
|
||||
expect(auditEvents).toEqual([
|
||||
'queue_enqueued',
|
||||
'queue_retry_scheduled',
|
||||
'queue_reconciled',
|
||||
]);
|
||||
|
||||
await queue.stop();
|
||||
});
|
||||
|
||||
it('marks a timed-out reconcile context as cancelled for late continuations', async () => {
|
||||
let releaseFirst: () => void = () => {
|
||||
throw new Error('first reconcile did not start');
|
||||
};
|
||||
let reconcileCalls = 0;
|
||||
const lateSideEffects: string[] = [];
|
||||
const cancellationChecks: boolean[] = [];
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
quietWindowMs: 1,
|
||||
retryDelayMs: 10,
|
||||
reconcileTimeoutMs: 20,
|
||||
maxRetryAttempts: 1,
|
||||
reconcile: async (_request, context) => {
|
||||
reconcileCalls += 1;
|
||||
if (reconcileCalls === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
const cancelled = context.isCancelled?.() === true;
|
||||
cancellationChecks.push(cancelled);
|
||||
if (!cancelled) {
|
||||
lateSideEffects.push('first');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cancelled = context.isCancelled?.() === true;
|
||||
cancellationChecks.push(cancelled);
|
||||
if (!cancelled) {
|
||||
lateSideEffects.push('retry');
|
||||
}
|
||||
},
|
||||
isTeamActive: () => true,
|
||||
});
|
||||
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'turn_settled' });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(lateSideEffects).toEqual(['retry']);
|
||||
|
||||
releaseFirst();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(cancellationChecks).toEqual([false, true]);
|
||||
expect(lateSideEffects).toEqual(['retry']);
|
||||
|
||||
await queue.stop();
|
||||
});
|
||||
|
||||
it('drops a failed reconcile after the retry budget is exhausted', async () => {
|
||||
const reconcile = vi.fn(async () => {
|
||||
throw new Error('still failing');
|
||||
});
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
quietWindowMs: 1,
|
||||
retryDelayMs: 10,
|
||||
maxRetryAttempts: 1,
|
||||
reconcile,
|
||||
isTeamActive: () => true,
|
||||
});
|
||||
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'turn_settled' });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(reconcile).toHaveBeenCalledTimes(2);
|
||||
expect(queue.getDiagnostics()).toMatchObject({
|
||||
dropped: 1,
|
||||
failed: 2,
|
||||
queued: 0,
|
||||
reconciled: 0,
|
||||
});
|
||||
|
||||
await queue.stop();
|
||||
});
|
||||
|
||||
it('resets retry budget when a fresh event joins a queued retry item', async () => {
|
||||
const reconcile = vi.fn(async () => {
|
||||
throw new Error('still failing');
|
||||
});
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
quietWindowMs: 1,
|
||||
retryDelayMs: 10,
|
||||
maxRetryAttempts: 1,
|
||||
reconcile,
|
||||
isTeamActive: () => true,
|
||||
});
|
||||
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'turn_settled' });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(queue.getDiagnostics()).toMatchObject({ failed: 1, queued: 1, dropped: 0 });
|
||||
|
||||
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(reconcile).toHaveBeenCalledTimes(2);
|
||||
expect(queue.getDiagnostics()).toMatchObject({
|
||||
dropped: 0,
|
||||
failed: 2,
|
||||
queued: 1,
|
||||
reconciled: 0,
|
||||
});
|
||||
|
||||
await queue.stop();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MemberWorkSyncNudgeDispatchScheduler } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncNudgeDispatchScheduler';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('MemberWorkSyncNudgeDispatchScheduler', () => {
|
||||
it('dispatches due nudges for unique active teams without overlapping runs', async () => {
|
||||
|
|
@ -19,7 +18,9 @@ describe('MemberWorkSyncNudgeDispatchScheduler', () => {
|
|||
|
||||
const first = scheduler.runOnce();
|
||||
const second = scheduler.runOnce();
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
expect(dispatchDue).toHaveBeenCalledTimes(1);
|
||||
|
||||
release();
|
||||
|
|
@ -61,4 +62,99 @@ describe('MemberWorkSyncNudgeDispatchScheduler', () => {
|
|||
expect.objectContaining({ error: 'Error: list failed' })
|
||||
);
|
||||
});
|
||||
|
||||
it('times out a hung dispatch so later scheduled runs can continue', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let dispatchCalls = 0;
|
||||
const warn = vi.fn();
|
||||
const dispatchDue = vi.fn(async () => {
|
||||
dispatchCalls += 1;
|
||||
if (dispatchCalls === 1) {
|
||||
await new Promise<void>(() => undefined);
|
||||
}
|
||||
return { claimed: 0, delivered: 0, superseded: 0, retryable: 0, terminal: 0 };
|
||||
});
|
||||
const scheduler = new MemberWorkSyncNudgeDispatchScheduler({
|
||||
listLifecycleActiveTeamNames: async () => ['team-a'],
|
||||
dispatchDue,
|
||||
dispatchTimeoutMs: 20,
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
warn,
|
||||
error: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const first = scheduler.runOnce();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(dispatchDue).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await first;
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'member work sync scheduled nudge dispatch failed',
|
||||
expect.objectContaining({
|
||||
error: 'Error: member work sync scheduled nudge dispatch timed out after 20ms',
|
||||
})
|
||||
);
|
||||
|
||||
await scheduler.runOnce();
|
||||
|
||||
expect(dispatchDue).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('times out hung active team listing so later scheduled runs can continue', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let listCalls = 0;
|
||||
const warn = vi.fn();
|
||||
const dispatchDue = vi.fn(async () => ({
|
||||
claimed: 0,
|
||||
delivered: 0,
|
||||
superseded: 0,
|
||||
retryable: 0,
|
||||
terminal: 0,
|
||||
}));
|
||||
const scheduler = new MemberWorkSyncNudgeDispatchScheduler({
|
||||
listLifecycleActiveTeamNames: async () => {
|
||||
listCalls += 1;
|
||||
if (listCalls === 1) {
|
||||
await new Promise<string[]>(() => undefined);
|
||||
}
|
||||
return ['team-a'];
|
||||
},
|
||||
dispatchDue,
|
||||
dispatchTimeoutMs: 20,
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
warn,
|
||||
error: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const first = scheduler.runOnce();
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await first;
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'member work sync scheduled nudge dispatch failed',
|
||||
expect.objectContaining({
|
||||
error: 'Error: member work sync scheduled nudge team listing timed out after 20ms',
|
||||
})
|
||||
);
|
||||
expect(dispatchDue).not.toHaveBeenCalled();
|
||||
|
||||
await scheduler.runOnce();
|
||||
|
||||
expect(dispatchDue).toHaveBeenCalledWith(['team-a']);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MemberWorkSyncTeamChangeRouter } from '@features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function createRouter(activeMembers: string[] = ['alice', 'bob']) {
|
||||
const queue = {
|
||||
|
|
@ -96,13 +95,25 @@ describe('MemberWorkSyncTeamChangeRouter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('drops queued work when the team goes offline', () => {
|
||||
it('refreshes member runtime state when the team goes offline', async () => {
|
||||
const { queue, router } = createRouter();
|
||||
|
||||
router.noteTeamChange({ type: 'lead-activity', teamName: 'team-a', detail: 'offline' });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(queue.dropTeam).toHaveBeenCalledWith('team-a');
|
||||
expect(queue.enqueue).not.toHaveBeenCalled();
|
||||
expect(queue.enqueue).toHaveBeenCalledWith({
|
||||
teamName: 'team-a',
|
||||
memberName: 'alice',
|
||||
triggerReason: 'runtime_activity',
|
||||
runAfterMs: 0,
|
||||
});
|
||||
expect(queue.enqueue).toHaveBeenCalledWith({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
triggerReason: 'runtime_activity',
|
||||
runAfterMs: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('routes member-turn-settled events to one member reconcile', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { MemberWorkSyncToolActivityBusySignal } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncToolActivityBusySignal';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TeamChangeEvent, ToolActivityEventPayload } from '@shared/types';
|
||||
|
||||
|
|
@ -180,4 +180,80 @@ describe('MemberWorkSyncToolActivityBusySignal', () => {
|
|||
})
|
||||
).resolves.toEqual({ busy: false });
|
||||
});
|
||||
|
||||
it('bounds future tool timestamps so busy state cannot sleep nudges for too long', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-04-29T00:00:00.000Z'));
|
||||
|
||||
const activeSignal = new MemberWorkSyncToolActivityBusySignal({
|
||||
busyGraceMs: 90_000,
|
||||
activeToolStaleMs: 10 * 60_000,
|
||||
});
|
||||
|
||||
activeSignal.noteTeamChange(
|
||||
toolEvent('team-a', {
|
||||
action: 'start',
|
||||
activity: {
|
||||
memberName: 'bob',
|
||||
toolUseId: 'tool-1',
|
||||
toolName: 'bash',
|
||||
startedAt: '2026-04-29T01:00:00.000Z',
|
||||
source: 'runtime',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
activeSignal.isBusy({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
nowIso: '2026-04-29T00:09:59.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
busy: true,
|
||||
reason: 'active_tool_activity',
|
||||
});
|
||||
|
||||
await expect(
|
||||
activeSignal.isBusy({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
nowIso: '2026-04-29T00:10:00.000Z',
|
||||
})
|
||||
).resolves.toEqual({ busy: false });
|
||||
|
||||
const finishSignal = new MemberWorkSyncToolActivityBusySignal({ busyGraceMs: 90_000 });
|
||||
finishSignal.noteTeamChange(
|
||||
toolEvent('team-a', {
|
||||
action: 'finish',
|
||||
memberName: 'bob',
|
||||
toolUseId: 'tool-2',
|
||||
finishedAt: '2026-04-29T01:00:00.000Z',
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
finishSignal.isBusy({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
nowIso: '2026-04-29T00:01:29.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
busy: true,
|
||||
reason: 'recent_tool_activity',
|
||||
retryAfterIso: '2026-04-29T00:01:30.000Z',
|
||||
});
|
||||
|
||||
await expect(
|
||||
finishSignal.isBusy({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
nowIso: '2026-04-29T00:01:30.000Z',
|
||||
})
|
||||
).resolves.toEqual({ busy: false });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import { RuntimeTurnSettledDrainScheduler } from '@features/member-work-sync/main/infrastructure/RuntimeTurnSettledDrainScheduler';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('RuntimeTurnSettledDrainScheduler', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not overlap active drains', async () => {
|
||||
let release!: () => void;
|
||||
const firstDrain = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const drain = vi.fn(async () => {
|
||||
await firstDrain;
|
||||
return { claimed: 1, enqueued: 1, unresolved: 0, ignored: 0, invalid: 0, failed: 0 };
|
||||
});
|
||||
const scheduler = new RuntimeTurnSettledDrainScheduler({ drain });
|
||||
|
||||
const first = scheduler.drainNow();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await expect(scheduler.drainNow()).resolves.toBeNull();
|
||||
expect(drain).toHaveBeenCalledTimes(1);
|
||||
|
||||
release();
|
||||
await first;
|
||||
});
|
||||
|
||||
it('times out a hung drain so later turn-settled drains can continue', async () => {
|
||||
let drainCalls = 0;
|
||||
const warn = vi.fn();
|
||||
const drain = vi.fn(async () => {
|
||||
drainCalls += 1;
|
||||
if (drainCalls === 1) {
|
||||
await new Promise<void>(() => undefined);
|
||||
}
|
||||
return { claimed: 0, enqueued: 0, unresolved: 0, ignored: 0, invalid: 0, failed: 0 };
|
||||
});
|
||||
const scheduler = new RuntimeTurnSettledDrainScheduler({
|
||||
drain,
|
||||
drainTimeoutMs: 20,
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
warn,
|
||||
error: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const first = scheduler.drainNow();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(drain).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await expect(first).resolves.toBeNull();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'runtime turn settled scheduled drain failed',
|
||||
expect.objectContaining({
|
||||
error: 'Error: runtime turn settled drain timed out after 20ms',
|
||||
})
|
||||
);
|
||||
|
||||
await expect(scheduler.drainNow()).resolves.toMatchObject({
|
||||
claimed: 0,
|
||||
enqueued: 0,
|
||||
});
|
||||
expect(drain).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -41,6 +41,71 @@ describe('TeamTaskStallJournalWorkSyncCooldown', () => {
|
|||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('returns the exact retry deadline for an active watchdog cooldown', async () => {
|
||||
await mkdir(join(root, 'team-a'), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, 'team-a', 'stall-monitor-journal.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
taskId: 'task-1',
|
||||
memberName: 'bob',
|
||||
state: 'alerted',
|
||||
alertedAt: '2026-04-29T00:05:00.000Z',
|
||||
},
|
||||
]),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const cooldown = new TeamTaskStallJournalWorkSyncCooldown(root, 10 * 60_000);
|
||||
|
||||
await expect(
|
||||
cooldown.getRecentNudgeCooldown({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
taskIds: ['task-1'],
|
||||
nowIso: '2026-04-29T00:10:00.000Z',
|
||||
})
|
||||
).resolves.toEqual({
|
||||
active: true,
|
||||
retryAfterIso: '2026-04-29T00:15:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not suppress a reassigned task for a different member', async () => {
|
||||
await mkdir(join(root, 'team-a'), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, 'team-a', 'stall-monitor-journal.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
taskId: 'task-1',
|
||||
memberName: 'alice',
|
||||
state: 'alerted',
|
||||
alertedAt: '2026-04-29T00:05:00.000Z',
|
||||
},
|
||||
]),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const cooldown = new TeamTaskStallJournalWorkSyncCooldown(root, 10 * 60_000);
|
||||
|
||||
await expect(
|
||||
cooldown.hasRecentNudge({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
taskIds: ['task-1'],
|
||||
nowIso: '2026-04-29T00:10:00.000Z',
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
cooldown.hasRecentNudge({
|
||||
teamName: 'team-a',
|
||||
memberName: 'alice',
|
||||
taskIds: ['task-1'],
|
||||
nowIso: '2026-04-29T00:10:00.000Z',
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('ignores old watchdog alerts and missing journals', async () => {
|
||||
await mkdir(join(root, 'team-a'), { recursive: true });
|
||||
await writeFile(
|
||||
|
|
@ -75,6 +140,58 @@ describe('TeamTaskStallJournalWorkSyncCooldown', () => {
|
|||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('does not suppress exactly at the watchdog cooldown boundary', async () => {
|
||||
await mkdir(join(root, 'team-a'), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, 'team-a', 'stall-monitor-journal.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
taskId: 'task-1',
|
||||
state: 'alerted',
|
||||
alertedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const cooldown = new TeamTaskStallJournalWorkSyncCooldown(root, 10 * 60_000);
|
||||
|
||||
await expect(
|
||||
cooldown.getRecentNudgeCooldown({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
taskIds: ['task-1'],
|
||||
nowIso: '2026-04-29T00:10:00.000Z',
|
||||
})
|
||||
).resolves.toEqual({ active: false });
|
||||
});
|
||||
|
||||
it('ignores future watchdog alert timestamps', async () => {
|
||||
await mkdir(join(root, 'team-a'), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, 'team-a', 'stall-monitor-journal.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
taskId: 'task-1',
|
||||
state: 'alerted',
|
||||
alertedAt: '2026-04-29T01:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const cooldown = new TeamTaskStallJournalWorkSyncCooldown(root, 10 * 60_000);
|
||||
|
||||
await expect(
|
||||
cooldown.hasRecentNudge({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
taskIds: ['task-1'],
|
||||
nowIso: '2026-04-29T00:10:00.000Z',
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('fails open when the watchdog journal is invalid', async () => {
|
||||
await mkdir(join(root, 'team-a'), { recursive: true });
|
||||
await writeFile(join(root, 'team-a', 'stall-monitor-journal.json'), '{bad json', 'utf8');
|
||||
|
|
|
|||
|
|
@ -651,6 +651,95 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('dispatches existing due nudges before background stale refresh work', async () => {
|
||||
const claudeRoot = makeTempRoot();
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
const teamsBasePath = getTeamsBasePath();
|
||||
const teamName = 'team-a';
|
||||
const memberName = 'bob';
|
||||
let postSeedGetConfigCalls = 0;
|
||||
let refreshBlocked = false;
|
||||
let releaseRefresh: () => void = () => undefined;
|
||||
const refreshBlocker = new Promise<void>((resolve) => {
|
||||
releaseRefresh = resolve;
|
||||
});
|
||||
const getConfig = vi.fn(async () => {
|
||||
postSeedGetConfigCalls += 1;
|
||||
if (postSeedGetConfigCalls === 2) {
|
||||
refreshBlocked = true;
|
||||
await refreshBlocker;
|
||||
}
|
||||
return {
|
||||
name: teamName,
|
||||
members: [{ name: memberName }],
|
||||
};
|
||||
});
|
||||
const feature = createMemberWorkSyncFeature({
|
||||
teamsBasePath,
|
||||
configReader: {
|
||||
getConfig,
|
||||
} as never,
|
||||
taskReader: {
|
||||
getTasks: vi.fn(async () => [
|
||||
{
|
||||
id: 'task-1',
|
||||
displayId: '11111111',
|
||||
subject: 'Ship sync',
|
||||
status: 'pending',
|
||||
owner: memberName,
|
||||
},
|
||||
]),
|
||||
} as never,
|
||||
kanbanManager: {
|
||||
getState: vi.fn(async () => ({
|
||||
teamName,
|
||||
reviewers: [],
|
||||
tasks: {},
|
||||
})),
|
||||
} as never,
|
||||
membersMetaStore: {
|
||||
getMembers: vi.fn(async () => []),
|
||||
} as never,
|
||||
});
|
||||
let dispatchPromise: Promise<unknown> | null = null;
|
||||
|
||||
try {
|
||||
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
||||
const status = await feature.refreshStatus({ teamName, memberName });
|
||||
const outboxInput = buildMemberWorkSyncOutboxEnsureInput({
|
||||
status,
|
||||
hash: new NodeHashAdapter(),
|
||||
nowIso: status.evaluatedAt,
|
||||
});
|
||||
expect(outboxInput).not.toBeNull();
|
||||
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
|
||||
await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({
|
||||
ok: true,
|
||||
outcome: 'existing',
|
||||
});
|
||||
|
||||
postSeedGetConfigCalls = 0;
|
||||
dispatchPromise = feature.dispatchDueNudges([teamName]);
|
||||
await waitForAssertion(() => {
|
||||
expect(refreshBlocked).toBe(true);
|
||||
});
|
||||
|
||||
await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ messageId: outboxInput!.id })])
|
||||
);
|
||||
|
||||
releaseRefresh();
|
||||
await expect(dispatchPromise).resolves.toMatchObject({
|
||||
claimed: 1,
|
||||
delivered: 1,
|
||||
});
|
||||
} finally {
|
||||
releaseRefresh();
|
||||
await dispatchPromise?.catch(() => undefined);
|
||||
await feature.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('suppresses queued proof-missing recovery when the original delivery is no longer proof-missing', async () => {
|
||||
const claudeRoot = makeTempRoot();
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
|
|
@ -1115,8 +1204,6 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
);
|
||||
|
||||
await expect(feature.drainRuntimeTurnSettledEvents()).resolves.toMatchObject({
|
||||
claimed: 1,
|
||||
enqueued: 1,
|
||||
invalid: 0,
|
||||
unresolved: 0,
|
||||
});
|
||||
|
|
@ -3891,6 +3978,73 @@ describe('createMemberWorkSyncFeature composition', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('refreshes stale needs_sync into inactive after the whole team stops', async () => {
|
||||
const claudeRoot = makeTempRoot();
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
const teamsBasePath = getTeamsBasePath();
|
||||
const teamName = 'team-stopped';
|
||||
const memberName = 'bob';
|
||||
let teamActive = true;
|
||||
const feature = createMemberWorkSyncFeature({
|
||||
teamsBasePath,
|
||||
configReader: {
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: teamName,
|
||||
members: [{ name: memberName, providerId: 'codex' }],
|
||||
})),
|
||||
} as never,
|
||||
taskReader: {
|
||||
getTasks: vi.fn(async () => [
|
||||
{
|
||||
id: 'task-1',
|
||||
displayId: '11111111',
|
||||
subject: 'Finish work after sleep',
|
||||
status: 'pending',
|
||||
owner: memberName,
|
||||
},
|
||||
]),
|
||||
} as never,
|
||||
kanbanManager: {
|
||||
getState: vi.fn(async () => ({
|
||||
teamName,
|
||||
reviewers: [],
|
||||
tasks: {},
|
||||
})),
|
||||
} as never,
|
||||
membersMetaStore: {
|
||||
getMembers: vi.fn(async () => []),
|
||||
} as never,
|
||||
isTeamActive: vi.fn(async () => teamActive),
|
||||
canDispatchNudges: vi.fn(async () => teamActive),
|
||||
});
|
||||
|
||||
try {
|
||||
const current = await feature.refreshStatus({ teamName, memberName });
|
||||
expect(current.state).toBe('needs_sync');
|
||||
|
||||
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
|
||||
await store.write({
|
||||
...current,
|
||||
evaluatedAt: new Date(Date.now() - 3 * 60_000).toISOString(),
|
||||
});
|
||||
teamActive = false;
|
||||
|
||||
await expect(feature.getStatus({ teamName, memberName })).resolves.toMatchObject({
|
||||
state: 'needs_sync',
|
||||
diagnostics: expect.arrayContaining(['status_stale_refresh_enqueued']),
|
||||
});
|
||||
await waitForQueueIdle(feature);
|
||||
|
||||
await expect(store.read({ teamName, memberName })).resolves.toMatchObject({
|
||||
state: 'inactive',
|
||||
diagnostics: expect.arrayContaining(['team_runtime_inactive']),
|
||||
shadow: { reconciledBy: 'queue', triggerReasons: ['manual_refresh'] },
|
||||
});
|
||||
} finally {
|
||||
await feature.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses snapshot config reads for startup roster materialization', async () => {
|
||||
const getConfig = vi.fn(async () => ({ members: [] }));
|
||||
const getConfigSnapshot = vi.fn(async () => ({
|
||||
|
|
|
|||
|
|
@ -264,6 +264,136 @@ describe('configValidation', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('accepts Codex custom provider profile updates', () => {
|
||||
const result = validateConfigUpdatePayload('providerConnections', {
|
||||
codex: {
|
||||
preferredAuthMode: 'api_key',
|
||||
customProvider: {
|
||||
enabled: true,
|
||||
baseUrl: ' http://127.0.0.1:8080/v1 ',
|
||||
model: ' gateway-codex-model ',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.data).toEqual({
|
||||
codex: {
|
||||
preferredAuthMode: 'api_key',
|
||||
customProvider: {
|
||||
enabled: true,
|
||||
baseUrl: 'http://127.0.0.1:8080/v1',
|
||||
model: 'gateway-codex-model',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('allows disabling Codex custom provider while keeping empty fields', () => {
|
||||
const result = validateConfigUpdatePayload('providerConnections', {
|
||||
codex: {
|
||||
customProvider: {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.data).toEqual({
|
||||
codex: {
|
||||
customProvider: {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ftp://gateway.example.com/v1', 'http:// or https://'],
|
||||
['https://user:token@gateway.example.com/v1', 'credentials'],
|
||||
['https://gateway.example.com/v1?token=secret', 'query or fragment'],
|
||||
['https://gateway.example.com/v1#token', 'query or fragment'],
|
||||
['not a url', 'valid URL'],
|
||||
])('rejects invalid Codex custom provider base URL %s', (baseUrl, expectedError) => {
|
||||
const result = validateConfigUpdatePayload('providerConnections', {
|
||||
codex: {
|
||||
customProvider: {
|
||||
enabled: true,
|
||||
baseUrl,
|
||||
model: 'gateway-codex-model',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain(expectedError);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires Codex custom provider model when enabled', () => {
|
||||
const result = validateConfigUpdatePayload('providerConnections', {
|
||||
codex: {
|
||||
customProvider: {
|
||||
enabled: true,
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
model: ' ',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain('model is required');
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[`gateway\nmodel`, 'control characters'],
|
||||
['m'.repeat(201), '200 characters or fewer'],
|
||||
])('rejects invalid Codex custom provider model %s', (model, expectedError) => {
|
||||
const result = validateConfigUpdatePayload('providerConnections', {
|
||||
codex: {
|
||||
customProvider: {
|
||||
enabled: true,
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
model,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain(expectedError);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects UI-derived Codex custom provider status fields', () => {
|
||||
const result = validateConfigUpdatePayload('providerConnections', {
|
||||
codex: {
|
||||
customProvider: {
|
||||
enabled: true,
|
||||
active: true,
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
model: 'gateway-codex-model',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain('active is not a valid setting');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts Anthropic-compatible endpoint provider connection updates', () => {
|
||||
const result = validateConfigUpdatePayload('providerConnections', {
|
||||
anthropic: {
|
||||
|
|
|
|||
|
|
@ -57,11 +57,105 @@ describe('ConfigManager Codex migration hardening', () => {
|
|||
|
||||
expect(persisted.providerConnections.codex).toEqual({
|
||||
preferredAuthMode: 'chatgpt',
|
||||
customProvider: {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
},
|
||||
});
|
||||
expect(persisted.runtime.providerBackends.codex).toBe('codex-native');
|
||||
});
|
||||
});
|
||||
|
||||
it('deep-merges and persists Codex custom provider updates', async () => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-codex-custom-provider-'));
|
||||
const configPath = path.join(tempRoot, 'agent-teams-config.json');
|
||||
|
||||
const { ConfigManager } = await import(
|
||||
'../../../../src/main/services/infrastructure/ConfigManager'
|
||||
);
|
||||
|
||||
const manager = new ConfigManager(configPath);
|
||||
const updated = manager.updateConfig('providerConnections', {
|
||||
codex: {
|
||||
preferredAuthMode: 'api_key',
|
||||
customProvider: {
|
||||
enabled: true,
|
||||
baseUrl: ' https://gateway.example.com/v1 ',
|
||||
model: ' gateway-codex-model ',
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(updated.providerConnections.codex).toEqual({
|
||||
preferredAuthMode: 'api_key',
|
||||
customProvider: {
|
||||
enabled: true,
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
model: 'gateway-codex-model',
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
|
||||
const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||
providerConnections: {
|
||||
codex: {
|
||||
preferredAuthMode: string;
|
||||
customProvider: { enabled: boolean; baseUrl: string; model: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
expect(persisted.providerConnections.codex).toEqual({
|
||||
preferredAuthMode: 'api_key',
|
||||
customProvider: {
|
||||
enabled: true,
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
model: 'gateway-codex-model',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const disabled = manager.updateConfig('providerConnections', {
|
||||
codex: {
|
||||
customProvider: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(disabled.providerConnections.codex).toEqual({
|
||||
preferredAuthMode: 'api_key',
|
||||
customProvider: {
|
||||
enabled: false,
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
model: 'gateway-codex-model',
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- temp fixture path
|
||||
const persisted = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||
providerConnections: {
|
||||
codex: {
|
||||
preferredAuthMode: string;
|
||||
customProvider: { enabled: boolean; baseUrl: string; model: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
expect(persisted.providerConnections.codex).toEqual({
|
||||
preferredAuthMode: 'api_key',
|
||||
customProvider: {
|
||||
enabled: false,
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
model: 'gateway-codex-model',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes legacy Codex runtime backend updates inside ConfigManager updateConfig', async () => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'config-codex-runtime-update-'));
|
||||
const configPath = path.join(tempRoot, 'claude-devtools-config.json');
|
||||
|
|
|
|||
|
|
@ -43,7 +43,11 @@ describe('ProviderConnectionService', () => {
|
|||
|
||||
function createConfig(
|
||||
authMode: 'auto' | 'oauth' | 'api_key' = 'auto',
|
||||
compatibleEndpoint: { enabled: boolean; baseUrl: string } = { enabled: false, baseUrl: '' }
|
||||
compatibleEndpoint: { enabled: boolean; baseUrl: string } = { enabled: false, baseUrl: '' },
|
||||
codex: Partial<{
|
||||
preferredAuthMode: 'auto' | 'chatgpt' | 'api_key';
|
||||
customProvider: { enabled: boolean; baseUrl: string; model: string };
|
||||
}> = {}
|
||||
) {
|
||||
return {
|
||||
providerConnections: {
|
||||
|
|
@ -53,7 +57,12 @@ describe('ProviderConnectionService', () => {
|
|||
compatibleEndpoint,
|
||||
},
|
||||
codex: {
|
||||
preferredAuthMode: 'auto' as const,
|
||||
preferredAuthMode: codex.preferredAuthMode ?? ('auto' as const),
|
||||
customProvider: codex.customProvider ?? {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
|
|
@ -2180,6 +2189,232 @@ describe('ProviderConnectionService', () => {
|
|||
expect(args).toEqual(['-c', 'forced_login_method="api"']);
|
||||
});
|
||||
|
||||
it('adds custom provider settings for managed Codex API-key launches', async () => {
|
||||
const { ProviderConnectionService } =
|
||||
await import('@main/services/runtime/ProviderConnectionService');
|
||||
|
||||
const service = new ProviderConnectionService(
|
||||
{
|
||||
lookupPreferred: vi.fn().mockResolvedValue({
|
||||
envVarName: 'OPENAI_API_KEY',
|
||||
value: 'stored-key',
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
getConfig: () =>
|
||||
createConfig('auto', { enabled: false, baseUrl: '' }, {
|
||||
preferredAuthMode: 'api_key',
|
||||
customProvider: {
|
||||
enabled: true,
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
model: 'gateway-codex-model',
|
||||
},
|
||||
}),
|
||||
} as never
|
||||
);
|
||||
|
||||
const args = await service.getConfiguredConnectionLaunchArgs(
|
||||
{
|
||||
OPENAI_API_KEY: 'stored-key',
|
||||
CODEX_API_KEY: 'stored-key',
|
||||
},
|
||||
'codex',
|
||||
undefined,
|
||||
'/mock/claude-multimodel'
|
||||
);
|
||||
|
||||
expect(args).toEqual([
|
||||
'--settings',
|
||||
JSON.stringify({
|
||||
codex: {
|
||||
forced_login_method: 'api',
|
||||
agent_teams_custom_provider: {
|
||||
config_overrides: [
|
||||
'model_provider="agent_teams_custom"',
|
||||
'model_providers.agent_teams_custom.name="Agent Teams Custom"',
|
||||
'model_providers.agent_teams_custom.base_url="https://gateway.example.com/v1"',
|
||||
'model_providers.agent_teams_custom.wire_api="responses"',
|
||||
'model_providers.agent_teams_custom.env_key="CODEX_API_KEY"',
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds direct -c custom provider settings for direct Codex API-key launches', async () => {
|
||||
const { ProviderConnectionService } =
|
||||
await import('@main/services/runtime/ProviderConnectionService');
|
||||
|
||||
const service = new ProviderConnectionService(
|
||||
{
|
||||
lookupPreferred: vi.fn().mockResolvedValue({
|
||||
envVarName: 'OPENAI_API_KEY',
|
||||
value: 'stored-key',
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
getConfig: () =>
|
||||
createConfig('auto', { enabled: false, baseUrl: '' }, {
|
||||
preferredAuthMode: 'api_key',
|
||||
customProvider: {
|
||||
enabled: true,
|
||||
baseUrl: 'http://127.0.0.1:8080/v1',
|
||||
model: 'local-codex-model',
|
||||
},
|
||||
}),
|
||||
} as never
|
||||
);
|
||||
|
||||
const args = await service.getConfiguredConnectionLaunchArgs(
|
||||
{
|
||||
OPENAI_API_KEY: 'stored-key',
|
||||
CODEX_API_KEY: 'stored-key',
|
||||
},
|
||||
'codex',
|
||||
undefined,
|
||||
'/usr/local/bin/codex'
|
||||
);
|
||||
|
||||
expect(args).toEqual([
|
||||
'-c',
|
||||
'forced_login_method="api"',
|
||||
'-c',
|
||||
'model_provider="agent_teams_custom"',
|
||||
'-c',
|
||||
'model_providers.agent_teams_custom.name="Agent Teams Custom"',
|
||||
'-c',
|
||||
'model_providers.agent_teams_custom.base_url="http://127.0.0.1:8080/v1"',
|
||||
'-c',
|
||||
'model_providers.agent_teams_custom.wire_api="responses"',
|
||||
'-c',
|
||||
'model_providers.agent_teams_custom.env_key="CODEX_API_KEY"',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not pass custom provider settings when Codex resolves to ChatGPT mode', async () => {
|
||||
const { ProviderConnectionService } =
|
||||
await import('@main/services/runtime/ProviderConnectionService');
|
||||
|
||||
const service = new ProviderConnectionService(
|
||||
{
|
||||
lookupPreferred: vi.fn().mockResolvedValue({
|
||||
envVarName: 'OPENAI_API_KEY',
|
||||
value: 'stored-key',
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
getConfig: () =>
|
||||
createConfig('auto', { enabled: false, baseUrl: '' }, {
|
||||
preferredAuthMode: 'chatgpt',
|
||||
customProvider: {
|
||||
enabled: true,
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
model: 'gateway-codex-model',
|
||||
},
|
||||
}),
|
||||
} as never
|
||||
);
|
||||
|
||||
service.setCodexAccountFeature({
|
||||
getSnapshot: vi.fn().mockResolvedValue(
|
||||
createCodexSnapshot({
|
||||
preferredAuthMode: 'chatgpt',
|
||||
effectiveAuthMode: 'chatgpt',
|
||||
apiKey: {
|
||||
available: true,
|
||||
source: 'stored',
|
||||
sourceLabel: 'Stored in app',
|
||||
},
|
||||
})
|
||||
),
|
||||
} as never);
|
||||
|
||||
const args = await service.getConfiguredConnectionLaunchArgs(
|
||||
{
|
||||
OPENAI_API_KEY: 'stored-key',
|
||||
CODEX_API_KEY: 'stored-key',
|
||||
},
|
||||
'codex',
|
||||
undefined,
|
||||
'/mock/claude-multimodel'
|
||||
);
|
||||
|
||||
expect(args).toEqual(['--settings', '{"codex":{"forced_login_method":"chatgpt"}}']);
|
||||
});
|
||||
|
||||
it('synthesizes the Codex model catalog from the custom provider model', async () => {
|
||||
const { ProviderConnectionService } =
|
||||
await import('@main/services/runtime/ProviderConnectionService');
|
||||
const directCatalog = vi.fn().mockResolvedValue(null);
|
||||
|
||||
const service = new ProviderConnectionService(
|
||||
{
|
||||
lookupPreferred: vi.fn().mockResolvedValue({
|
||||
envVarName: 'OPENAI_API_KEY',
|
||||
value: 'stored-key',
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
getConfig: () =>
|
||||
createConfig('auto', { enabled: false, baseUrl: '' }, {
|
||||
preferredAuthMode: 'api_key',
|
||||
customProvider: {
|
||||
enabled: true,
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
model: 'gateway-codex-model',
|
||||
},
|
||||
}),
|
||||
} as never
|
||||
);
|
||||
service.setCodexModelCatalogFeature({ getCatalog: directCatalog } as never);
|
||||
|
||||
const enriched = await service.enrichProviderStatus({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
verificationState: 'verified',
|
||||
models: ['gpt-5.4'],
|
||||
subscriptionRateLimits: {
|
||||
primary: null,
|
||||
secondary: null,
|
||||
},
|
||||
runtimeCapabilities: {
|
||||
modelCatalog: { dynamic: true, source: 'app-server' },
|
||||
},
|
||||
canLoginFromUi: false,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: {
|
||||
plugins: { status: 'unsupported', ownership: 'shared' },
|
||||
mcp: { status: 'supported', ownership: 'shared' },
|
||||
skills: { status: 'supported', ownership: 'shared' },
|
||||
apiKeys: { status: 'supported', ownership: 'shared' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(directCatalog).not.toHaveBeenCalled();
|
||||
expect(enriched.models).toEqual(['gateway-codex-model']);
|
||||
expect(enriched.modelCatalog?.defaultLaunchModel).toBe('gateway-codex-model');
|
||||
expect(enriched.modelCatalog?.models).toHaveLength(1);
|
||||
expect(enriched.modelCatalog?.models[0]).toMatchObject({
|
||||
id: 'gateway-codex-model',
|
||||
launchModel: 'gateway-codex-model',
|
||||
supportsFastMode: false,
|
||||
source: 'static-fallback',
|
||||
});
|
||||
expect(enriched.subscriptionRateLimits).toBeNull();
|
||||
expect(enriched.backend?.endpointLabel).toBe('https://gateway.example.com/v1');
|
||||
expect(enriched.runtimeCapabilities?.modelCatalog).toEqual({
|
||||
dynamic: false,
|
||||
source: 'static-fallback',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the orchestrator Codex model catalog over the legacy direct app-server fallback', async () => {
|
||||
const { ProviderConnectionService } =
|
||||
await import('@main/services/runtime/ProviderConnectionService');
|
||||
|
|
|
|||
|
|
@ -454,6 +454,49 @@ describe('buildProviderAwareCliEnv', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('returns Codex custom provider launch args after API-key env application', async () => {
|
||||
applyConfiguredConnectionEnvMock.mockImplementation(async (env: NodeJS.ProcessEnv) => {
|
||||
env.OPENAI_API_KEY = 'stored-key';
|
||||
env.CODEX_API_KEY = 'stored-key';
|
||||
return env;
|
||||
});
|
||||
const customSettings = JSON.stringify({
|
||||
codex: {
|
||||
forced_login_method: 'api',
|
||||
agent_teams_custom_provider: {
|
||||
config_overrides: [
|
||||
'model_provider="agent_teams_custom"',
|
||||
'model_providers.agent_teams_custom.name="Agent Teams Custom"',
|
||||
'model_providers.agent_teams_custom.base_url="https://gateway.example.com/v1"',
|
||||
'model_providers.agent_teams_custom.wire_api="responses"',
|
||||
'model_providers.agent_teams_custom.env_key="CODEX_API_KEY"',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
getConfiguredConnectionLaunchArgsMock.mockResolvedValue(['--settings', customSettings]);
|
||||
|
||||
const { buildProviderAwareCliEnv } =
|
||||
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
|
||||
const result = await buildProviderAwareCliEnv({
|
||||
binaryPath: '/mock/claude-multimodel',
|
||||
providerId: 'codex',
|
||||
});
|
||||
|
||||
expect(getConfiguredConnectionLaunchArgsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
OPENAI_API_KEY: 'stored-key',
|
||||
CODEX_API_KEY: 'stored-key',
|
||||
}),
|
||||
'codex',
|
||||
undefined,
|
||||
'/mock/claude-multimodel'
|
||||
);
|
||||
expect(result.providerArgs).toEqual(['--settings', customSettings]);
|
||||
expect(result.env.OPENAI_API_KEY).toBe('stored-key');
|
||||
expect(result.env.CODEX_API_KEY).toBe('stored-key');
|
||||
});
|
||||
|
||||
it('passes Codex env refreshed by strict credential application into launch args and issue checks', async () => {
|
||||
applyConfiguredConnectionEnvMock.mockImplementation(
|
||||
async (env: NodeJS.ProcessEnv, providerId: string) => {
|
||||
|
|
|
|||
22
test/main/services/runtime/providerModelProbe.test.ts
Normal file
22
test/main/services/runtime/providerModelProbe.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import {
|
||||
buildProviderPreflightPingArgs,
|
||||
getProviderPreflightModel,
|
||||
} from '@main/services/runtime/providerModelProbe';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('providerModelProbe', () => {
|
||||
it('uses the configured model override for Codex preflight probes', () => {
|
||||
expect(getProviderPreflightModel('codex', { modelOverride: 'gateway-codex-model' })).toBe(
|
||||
'gateway-codex-model'
|
||||
);
|
||||
|
||||
expect(
|
||||
buildProviderPreflightPingArgs('codex', { modelOverride: 'gateway-codex-model' })
|
||||
).toContain('gateway-codex-model');
|
||||
});
|
||||
|
||||
it('keeps the default Codex preflight model when no override is configured', () => {
|
||||
expect(getProviderPreflightModel('codex')).toBe('gpt-5.4-mini');
|
||||
expect(buildProviderPreflightPingArgs('codex')).toContain('gpt-5.4-mini');
|
||||
});
|
||||
});
|
||||
|
|
@ -96,6 +96,30 @@ describe('ProcessBootstrapTransportEvidence', () => {
|
|||
expect(summary?.lastStage).toBe('process spawned');
|
||||
});
|
||||
|
||||
it('surfaces headless startup checkpoints as transport progress', () => {
|
||||
const summary = summarizeProcessBootstrapTransportEvents([
|
||||
{
|
||||
type: 'cli_started',
|
||||
timestamp: '2026-05-07T10:00:00.000Z',
|
||||
detail: 'teammateRuntime=headless',
|
||||
},
|
||||
{
|
||||
type: 'startup_checkpoint',
|
||||
timestamp: '2026-05-07T10:00:01.000Z',
|
||||
detail: 'commands_agents_loaded',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
submitted: false,
|
||||
hasProgress: true,
|
||||
lastStage: 'startup checkpoint: commands_agents_loaded',
|
||||
});
|
||||
expect(buildProcessBootstrapTimeoutDiagnostic(summary!)).toBe(
|
||||
'Bootstrap prompt was not submitted before timeout. Last transport stage: startup checkpoint: commands_agents_loaded'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds stable pending and timeout diagnostics from the last transport stage', () => {
|
||||
const summary = summarizeProcessBootstrapTransportEvents([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -276,6 +276,26 @@ describe('TeamLaunchFailureArtifactPack', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('extracts startup checkpoint runtime stages and keeps stdin warning secondary', () => {
|
||||
const input = {
|
||||
teamName: 'artifact-team',
|
||||
runId: 'run-startup-checkpoint',
|
||||
reason:
|
||||
'alice: Teammate process alice@signal-ops did not become runtime_ready: timed out waiting for runtime_ready; last runtime stage: startup_checkpoint: commands_agents_loaded Last stderr: Warning: no stdin data received in 3s, proceeding without it.',
|
||||
progressTraceLines: [
|
||||
'startup_checkpoint detail=commands_agents_loaded',
|
||||
'Warning: no stdin data received in 3s, proceeding without it.',
|
||||
],
|
||||
};
|
||||
|
||||
expect(classifyLaunchFailureArtifact(input).code).toBe('process_readiness_timeout');
|
||||
expect(extractLaunchBootstrapTransportBreadcrumb(input)).toMatchObject({
|
||||
lastTransportStage: 'startup_checkpoint: commands_agents_loaded',
|
||||
noStdinWarning: true,
|
||||
bootstrapSubmitted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps inbox poller bootstrap stalls out of stdin_missing classification', () => {
|
||||
const input = {
|
||||
teamName: 'artifact-team',
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import {
|
|||
isAutoClearableLaunchFailureReason,
|
||||
isBootstrapCheckInTimeoutFailureReason,
|
||||
isBootstrapInstructionPromptFailureReason,
|
||||
isCliProvisionedButNotAliveFailureReason,
|
||||
isBootstrapMcpResourceReadFailureReason,
|
||||
isCliProvisionedButNotAliveFailureReason,
|
||||
isConfigRegistrationFailureReason,
|
||||
isLaunchCleanupBootstrapIncompleteFailureReason,
|
||||
isLaunchGraceWindowFailureReason,
|
||||
|
|
@ -68,6 +68,16 @@ describe('TeamProvisioningLaunchFailurePolicy', () => {
|
|||
'Teammate was registered but did not bootstrap-confirm before timeout.'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isBootstrapCheckInTimeoutFailureReason(
|
||||
'Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before submitted-confirmation timeout (3m). Last transport stage: bootstrap_submitted'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isBootstrapCheckInTimeoutFailureReason(
|
||||
'Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before timeout.'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isBootstrapInstructionPromptFailureReason(
|
||||
'You are bootstrapping into team atlas. Your first action is to call the MCP tool member_briefing.'
|
||||
|
|
@ -107,6 +117,16 @@ describe('TeamProvisioningLaunchFailurePolicy', () => {
|
|||
'Teammate did not join within the launch grace window.; process table unavailable'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAutoClearableLaunchFailureReason(
|
||||
'Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before submitted-confirmation timeout (3m). Last transport stage: bootstrap_submitted'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAutoClearableLaunchFailureReason(
|
||||
'Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before timeout.'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAutoClearableLaunchFailureReason(
|
||||
'CLI process exited (code 1) \u2014 team provisioned but not alive'
|
||||
|
|
|
|||
|
|
@ -711,9 +711,7 @@ type TeamProvisioningServicePrivateHarness = {
|
|||
applyProcessBootstrapTransportOverlay: (
|
||||
input: Record<string, unknown>
|
||||
) => Record<string, unknown>;
|
||||
reconcilePersistedLaunchState: (
|
||||
teamName: string
|
||||
) => Promise<{
|
||||
reconcilePersistedLaunchState: (teamName: string) => Promise<{
|
||||
snapshot: null;
|
||||
statuses: Record<string, never>;
|
||||
}>;
|
||||
|
|
@ -1152,6 +1150,104 @@ describe('TeamProvisioningService', () => {
|
|||
expect(nextRecord.status).toBe('retry_scheduled');
|
||||
});
|
||||
|
||||
it('emits a terminal failure event when exhausted work-sync proof retries fail', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const taskRefs = [{ taskId: 'task-1', displayId: 'task-1', teamName: 'team-a' }];
|
||||
const record = {
|
||||
id: 'opencode-prompt:work-sync-proof-missing',
|
||||
teamName: 'team-a',
|
||||
memberName: 'atlas',
|
||||
laneId: 'secondary:opencode:atlas',
|
||||
runId: 'run-1',
|
||||
runtimeSessionId: 'ses-1',
|
||||
inboxMessageId: 'msg-work-sync-proof-missing',
|
||||
inboxTimestamp: '2026-05-18T08:31:00.000Z',
|
||||
source: 'watcher',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
replyRecipient: 'team-lead',
|
||||
actionMode: 'do',
|
||||
taskRefs,
|
||||
payloadHash: 'sha256:work-sync',
|
||||
status: 'retry_scheduled',
|
||||
responseState: 'responded_non_visible_tool',
|
||||
attempts: 3,
|
||||
maxAttempts: 3,
|
||||
acceptanceUnknown: false,
|
||||
nextAttemptAt: null,
|
||||
lastAttemptAt: '2026-05-18T08:31:30.000Z',
|
||||
lastObservedAt: '2026-05-18T08:31:45.000Z',
|
||||
acceptedAt: '2026-05-18T08:31:30.000Z',
|
||||
respondedAt: '2026-05-18T08:31:45.000Z',
|
||||
failedAt: null,
|
||||
inboxReadCommittedAt: null,
|
||||
inboxReadCommitError: null,
|
||||
prePromptCursor: null,
|
||||
postPromptCursor: null,
|
||||
deliveredUserMessageId: 'delivered-1',
|
||||
observedAssistantMessageId: 'assistant-1',
|
||||
observedAssistantPreview: null,
|
||||
observedToolCallNames: ['member_work_sync_status'],
|
||||
observedVisibleMessageId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyInbox: null,
|
||||
visibleReplyCorrelation: null,
|
||||
lastReason: 'member_work_sync_report_required',
|
||||
diagnostics: ['member_work_sync_report_required'],
|
||||
createdAt: '2026-05-18T08:31:00.000Z',
|
||||
updatedAt: '2026-05-18T08:31:45.000Z',
|
||||
};
|
||||
const failedRecord = {
|
||||
...record,
|
||||
status: 'failed_terminal',
|
||||
failedAt: '2026-05-18T08:32:00.000Z',
|
||||
updatedAt: '2026-05-18T08:32:00.000Z',
|
||||
};
|
||||
const ledger = {
|
||||
markFailedTerminal: vi.fn(async () => failedRecord),
|
||||
markNextAttemptScheduled: vi.fn(),
|
||||
};
|
||||
const harness = svc as unknown as {
|
||||
scheduleOpenCodePromptDeliveryWatchdog: ReturnType<typeof vi.fn>;
|
||||
logOpenCodePromptDeliveryEvent: ReturnType<typeof vi.fn>;
|
||||
scheduleOpenCodePromptLedgerFollowUp(input: {
|
||||
ledger: typeof ledger;
|
||||
ledgerRecord: typeof record;
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
retry: boolean;
|
||||
reason: string;
|
||||
}): Promise<typeof failedRecord>;
|
||||
};
|
||||
harness.scheduleOpenCodePromptDeliveryWatchdog = vi.fn();
|
||||
harness.logOpenCodePromptDeliveryEvent = vi.fn();
|
||||
|
||||
const nextRecord = await harness.scheduleOpenCodePromptLedgerFollowUp({
|
||||
ledger,
|
||||
ledgerRecord: record,
|
||||
teamName: 'team-a',
|
||||
memberName: 'atlas',
|
||||
retry: true,
|
||||
reason: 'member_work_sync_report_required',
|
||||
});
|
||||
|
||||
expect(nextRecord).toBe(failedRecord);
|
||||
expect(ledger.markFailedTerminal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: record.id,
|
||||
reason: 'member_work_sync_report_required',
|
||||
})
|
||||
);
|
||||
expect(harness.logOpenCodePromptDeliveryEvent).toHaveBeenCalledWith(
|
||||
'opencode_prompt_delivery_terminal_failure',
|
||||
failedRecord,
|
||||
expect.objectContaining({
|
||||
reason: 'member_work_sync_report_required',
|
||||
retry: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses stamped OpenCode session-refresh evidence instead of stale historical diagnostics', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).scheduleOpenCodePromptDeliveryWatchdog = vi.fn();
|
||||
|
|
@ -16725,8 +16821,7 @@ describe('TeamProvisioningService', () => {
|
|||
return launchIdentity;
|
||||
});
|
||||
(svc as any).buildTeamRuntimeLaunchArgsPlan = vi.fn(async (input) => ({
|
||||
fastModeArgs:
|
||||
input.launchIdentity === launchIdentity ? ['--test-codex-fast-mode'] : [],
|
||||
fastModeArgs: input.launchIdentity === launchIdentity ? ['--test-codex-fast-mode'] : [],
|
||||
runtimeTurnSettledHookArgs: [],
|
||||
providerArgs: [],
|
||||
settingsArgs: [],
|
||||
|
|
@ -21326,7 +21421,8 @@ describe('TeamProvisioningService', () => {
|
|||
status: 'failed',
|
||||
lastAttemptAt: Date.parse(acceptedAt),
|
||||
lastObservedAt: Date.parse(failureAt),
|
||||
failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.',
|
||||
failureReason:
|
||||
'Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before submitted-confirmation timeout (3m). Last transport stage: bootstrap_submitted',
|
||||
},
|
||||
],
|
||||
failureAt
|
||||
|
|
@ -22260,9 +22356,10 @@ describe('TeamProvisioningService', () => {
|
|||
expect(bobOutcome).toBeNull();
|
||||
// The transcript tail is parsed once and shared: a single cache entry for the
|
||||
// file rather than one parse per member.
|
||||
expect((svc as unknown as Record<string, Map<string, unknown>>).parsedBootstrapTranscriptTailCache.size).toBe(
|
||||
1
|
||||
);
|
||||
expect(
|
||||
(svc as unknown as Record<string, Map<string, unknown>>).parsedBootstrapTranscriptTailCache
|
||||
.size
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('caches persisted bootstrap transcript outcome lookup between close polling reads', async () => {
|
||||
|
|
@ -24523,12 +24620,10 @@ describe('TeamProvisioningService', () => {
|
|||
scheduled: true,
|
||||
reason: 'scheduled',
|
||||
}));
|
||||
const sendMessageToRun = vi.fn(
|
||||
async (targetRun: LeadRelayPriorityTestRun, message: string) => {
|
||||
deliveredPrompt = message;
|
||||
targetRun.leadRelayCapture?.resolveOnce('');
|
||||
}
|
||||
);
|
||||
const sendMessageToRun = vi.fn(async (targetRun: LeadRelayPriorityTestRun, message: string) => {
|
||||
deliveredPrompt = message;
|
||||
targetRun.leadRelayCapture?.resolveOnce('');
|
||||
});
|
||||
|
||||
harness.runs.set(run.runId, run);
|
||||
harness.aliveRunByTeam.set(teamName, run.runId);
|
||||
|
|
@ -25854,23 +25949,22 @@ describe('TeamProvisioningService', () => {
|
|||
it('does not keep healed confirmed-bootstrap status alive when refreshed runtime metadata is an error', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const harness = privateHarness(svc);
|
||||
harness.getLiveTeamAgentRuntimeMetadata = vi.fn(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
new Map([
|
||||
[
|
||||
'tom',
|
||||
{
|
||||
alive: false,
|
||||
model: 'sonnet',
|
||||
livenessKind: 'not_found',
|
||||
pidSource: 'process_table',
|
||||
runtimeDiagnostic: 'Runtime process crashed',
|
||||
runtimeDiagnosticSeverity: 'error',
|
||||
},
|
||||
],
|
||||
])
|
||||
)
|
||||
harness.getLiveTeamAgentRuntimeMetadata = vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Map([
|
||||
[
|
||||
'tom',
|
||||
{
|
||||
alive: false,
|
||||
model: 'sonnet',
|
||||
livenessKind: 'not_found',
|
||||
pidSource: 'process_table',
|
||||
runtimeDiagnostic: 'Runtime process crashed',
|
||||
runtimeDiagnosticSeverity: 'error',
|
||||
},
|
||||
],
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
const result = await harness.attachLiveRuntimeMetadataToStatuses('signal-ops', {
|
||||
|
|
|
|||
|
|
@ -1630,6 +1630,81 @@ Messages:
|
|||
expect(payload).toContain('Please retry with logging enabled.');
|
||||
});
|
||||
|
||||
it('prioritizes member work-sync nudges over older ordinary member relay rows', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
seedMemberInbox(teamName, 'alice', [
|
||||
...Array.from({ length: 11 }, (_, index) => ({
|
||||
from: 'team-lead',
|
||||
text: `Routine relay row ${index + 1}.`,
|
||||
timestamp: `2026-02-23T10:${String(index).padStart(2, '0')}:00.000Z`,
|
||||
read: false,
|
||||
messageId: `m-ordinary-${index + 1}`,
|
||||
})),
|
||||
{
|
||||
from: 'system',
|
||||
text: 'Call member_work_sync_status, then member_work_sync_report.',
|
||||
timestamp: '2026-02-23T10:30:00.000Z',
|
||||
read: false,
|
||||
messageId: 'm-work-sync-late',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
},
|
||||
]);
|
||||
|
||||
const { writeSpy } = attachAliveRun(service, teamName);
|
||||
const relayed = await service.relayMemberInboxMessages(teamName, 'alice');
|
||||
|
||||
expect(relayed).toBe(10);
|
||||
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||
expect(payload).toContain('1) From: system');
|
||||
expect(payload).toContain('MessageId: m-work-sync-late');
|
||||
expect(payload).toContain('Message kind: member_work_sync_nudge');
|
||||
expect(payload).not.toContain('MessageId: m-ordinary-11');
|
||||
});
|
||||
|
||||
it('retries a work-sync nudge after member relay times out before stdin write completes', async () => {
|
||||
vi.useFakeTimers();
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
try {
|
||||
seedConfig(teamName);
|
||||
seedMemberInbox(teamName, 'alice', [
|
||||
{
|
||||
from: 'system',
|
||||
text: 'Call member_work_sync_status, then member_work_sync_report.',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'm-work-sync-retry',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
},
|
||||
]);
|
||||
|
||||
const { writeSpy } = attachAliveRun(service, teamName);
|
||||
writeSpy.mockImplementationOnce(() => true);
|
||||
|
||||
const firstRelay = service.relayMemberInboxMessages(teamName, 'alice');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await expect(firstRelay).resolves.toBe(0);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
|
||||
const secondRelay = await service.relayMemberInboxMessages(teamName, 'alice');
|
||||
|
||||
expect(secondRelay).toBe(1);
|
||||
expect(writeSpy).toHaveBeenCalledTimes(2);
|
||||
const secondPayload = String(writeSpy.mock.calls[1]?.[0] ?? '');
|
||||
expect(secondPayload).toContain('MessageId: m-work-sync-retry');
|
||||
expect(secondPayload).toContain('Message kind: member_work_sync_nudge');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('marks exact teammate relay copies with relayOfMessageId', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
@ -3445,6 +3520,9 @@ Messages:
|
|||
},
|
||||
]);
|
||||
const deliverSpy = vi.spyOn(service, 'deliverOpenCodeMemberMessage');
|
||||
const logSpy = vi
|
||||
.spyOn(service as any, 'logOpenCodePromptDeliveryEvent')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack');
|
||||
const expectedReason = 'opencode_inbox_attachment_payload_unavailable: att-1';
|
||||
|
|
@ -3469,6 +3547,18 @@ Messages:
|
|||
status: 'failed_terminal',
|
||||
lastReason: expectedReason,
|
||||
});
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
'opencode_prompt_delivery_terminal_failure',
|
||||
expect.objectContaining({
|
||||
inboxMessageId: 'opencode-attachment-1',
|
||||
status: 'failed_terminal',
|
||||
lastReason: expectedReason,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
attachmentPayloadUnavailable: true,
|
||||
reason: expectedReason,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rebuilds missing OpenCode prompt ledger rows from unread inbox on startup scan', async () => {
|
||||
|
|
@ -3721,6 +3811,224 @@ Messages:
|
|||
}
|
||||
});
|
||||
|
||||
it('keeps an already-read work-sync nudge pending when it is queued behind an active relay', async () => {
|
||||
vi.useFakeTimers();
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
try {
|
||||
hoisted.files.set(
|
||||
`/mock/teams/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
seedMemberInbox(teamName, 'jack', [
|
||||
{
|
||||
from: 'bob',
|
||||
to: 'jack',
|
||||
text: 'Older watcher message.',
|
||||
timestamp: '2026-02-23T17:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'opencode-inflight-old',
|
||||
},
|
||||
]);
|
||||
|
||||
const oldDeliveryStarted = createDeferred<void>();
|
||||
const releaseOldDelivery = createDeferred<void>();
|
||||
vi.spyOn(service, 'deliverOpenCodeMemberMessage').mockImplementation(
|
||||
async (_teamName, input) => {
|
||||
if (input.messageId === 'opencode-inflight-old') {
|
||||
oldDeliveryStarted.resolve(undefined);
|
||||
await releaseOldDelivery.promise;
|
||||
}
|
||||
return { delivered: true, diagnostics: [] };
|
||||
}
|
||||
);
|
||||
const wakeSpy = vi
|
||||
.spyOn(service, 'scheduleOpenCodeMemberInboxDeliveryWake')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const watcherRelay = service.relayOpenCodeMemberInboxMessages(teamName, 'jack');
|
||||
await oldDeliveryStarted.promise;
|
||||
seedMemberInbox(teamName, 'jack', [
|
||||
{
|
||||
from: 'bob',
|
||||
to: 'jack',
|
||||
text: 'Older watcher message.',
|
||||
timestamp: '2026-02-23T17:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'opencode-inflight-old',
|
||||
},
|
||||
{
|
||||
from: 'system',
|
||||
to: 'jack',
|
||||
text: 'Call member_work_sync_status, then member_work_sync_report.',
|
||||
timestamp: '2026-02-23T17:00:01.000Z',
|
||||
read: true,
|
||||
messageId: 'work-sync-read-queued',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.relayOpenCodeMemberInboxMessages(teamName, 'jack', {
|
||||
onlyMessageId: 'work-sync-read-queued',
|
||||
source: 'watchdog',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
attempted: 1,
|
||||
delivered: 0,
|
||||
failed: 0,
|
||||
lastDelivery: {
|
||||
delivered: true,
|
||||
accepted: false,
|
||||
responsePending: true,
|
||||
reason: 'opencode_work_sync_read_commit_waiting_for_active_relay',
|
||||
},
|
||||
});
|
||||
expect(wakeSpy).toHaveBeenCalledWith({
|
||||
teamName,
|
||||
memberName: 'jack',
|
||||
messageId: 'work-sync-read-queued',
|
||||
delayMs: 500,
|
||||
});
|
||||
|
||||
releaseOldDelivery.resolve(undefined);
|
||||
await watcherRelay;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('times out a hung existing OpenCode member relay in-flight lock', async () => {
|
||||
vi.useFakeTimers();
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
const relayKey = `opencode:${teamName}:jack`;
|
||||
try {
|
||||
(
|
||||
service as unknown as {
|
||||
openCodeMemberInboxRelayInFlight: Map<string, Promise<unknown>>;
|
||||
}
|
||||
).openCodeMemberInboxRelayInFlight.set(relayKey, new Promise(() => undefined));
|
||||
|
||||
const relay = service.relayOpenCodeMemberInboxMessages(teamName, 'jack');
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
|
||||
await expect(relay).resolves.toMatchObject({
|
||||
attempted: 0,
|
||||
delivered: 0,
|
||||
failed: 1,
|
||||
lastDelivery: {
|
||||
delivered: false,
|
||||
accepted: false,
|
||||
responsePending: false,
|
||||
reason: 'opencode_member_inbox_relay_timed_out',
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(
|
||||
service as unknown as {
|
||||
openCodeMemberInboxRelayInFlight: Map<string, Promise<unknown>>;
|
||||
}
|
||||
).openCodeMemberInboxRelayInFlight.has(relayKey)
|
||||
).toBe(false);
|
||||
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
|
||||
'opencode_member_inbox_relay_timed_out'
|
||||
);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('times out a hung existing lead relay in-flight lock', async () => {
|
||||
vi.useFakeTimers();
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
try {
|
||||
(
|
||||
service as unknown as {
|
||||
leadInboxRelayInFlight: Map<string, Promise<number>>;
|
||||
}
|
||||
).leadInboxRelayInFlight.set(teamName, new Promise(() => undefined));
|
||||
|
||||
const relay = service.relayLeadInboxMessages(teamName);
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
|
||||
await expect(relay).resolves.toBe(0);
|
||||
expect(
|
||||
(
|
||||
service as unknown as {
|
||||
leadInboxRelayInFlight: Map<string, Promise<number>>;
|
||||
}
|
||||
).leadInboxRelayInFlight.has(teamName)
|
||||
).toBe(false);
|
||||
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
|
||||
'lead_inbox_relay_timed_out'
|
||||
);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('times out a hung existing member relay in-flight lock', async () => {
|
||||
vi.useFakeTimers();
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
const relayKey = `${teamName}:alice`;
|
||||
try {
|
||||
(
|
||||
service as unknown as {
|
||||
memberInboxRelayInFlight: Map<string, Promise<number>>;
|
||||
}
|
||||
).memberInboxRelayInFlight.set(relayKey, new Promise(() => undefined));
|
||||
|
||||
const relay = service.relayMemberInboxMessages(teamName, 'alice');
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
|
||||
await expect(relay).resolves.toBe(0);
|
||||
expect(
|
||||
(
|
||||
service as unknown as {
|
||||
memberInboxRelayInFlight: Map<string, Promise<number>>;
|
||||
}
|
||||
).memberInboxRelayInFlight.has(relayKey)
|
||||
).toBe(false);
|
||||
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
|
||||
'member_inbox_relay_timed_out'
|
||||
);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not convert non-timeout member relay failures into timeout results', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
const relayKey = `${teamName}:alice`;
|
||||
const rejected = Promise.reject(new Error('relay failed'));
|
||||
rejected.catch(() => undefined);
|
||||
(
|
||||
service as unknown as {
|
||||
memberInboxRelayInFlight: Map<string, Promise<number>>;
|
||||
}
|
||||
).memberInboxRelayInFlight.set(relayKey, rejected);
|
||||
|
||||
await expect(service.relayMemberInboxMessages(teamName, 'alice')).rejects.toThrow(
|
||||
'relay failed'
|
||||
);
|
||||
expect(vi.mocked(console.warn)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats an already-read specific OpenCode inbox row as delivered for UI-send relay', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
@ -3762,6 +4070,68 @@ Messages:
|
|||
expect(deliverSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not treat an already-read work-sync nudge as delivered without the work-sync proof path', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
hoisted.files.set(
|
||||
`/mock/teams/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
seedMemberInbox(teamName, 'jack', [
|
||||
{
|
||||
from: 'system',
|
||||
to: 'jack',
|
||||
text: 'Call member_work_sync_status, then member_work_sync_report.',
|
||||
timestamp: '2026-02-23T17:02:00.000Z',
|
||||
read: true,
|
||||
messageId: 'work-sync-read-1',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
taskRefs: [{ taskId: 'task-1', teamName }],
|
||||
},
|
||||
]);
|
||||
const deliverSpy = vi.spyOn(service, 'deliverOpenCodeMemberMessage').mockResolvedValue({
|
||||
delivered: true,
|
||||
accepted: false,
|
||||
responsePending: true,
|
||||
reason: 'member_work_sync_report_required',
|
||||
diagnostics: ['member_work_sync_report_required'],
|
||||
});
|
||||
|
||||
const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack', {
|
||||
onlyMessageId: 'work-sync-read-1',
|
||||
source: 'watchdog',
|
||||
});
|
||||
|
||||
expect(deliverSpy).toHaveBeenCalledWith(
|
||||
teamName,
|
||||
expect.objectContaining({
|
||||
memberName: 'jack',
|
||||
messageId: 'work-sync-read-1',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
})
|
||||
);
|
||||
expect(relay).toMatchObject({
|
||||
attempted: 1,
|
||||
delivered: 0,
|
||||
failed: 0,
|
||||
lastDelivery: {
|
||||
delivered: true,
|
||||
accepted: false,
|
||||
responsePending: true,
|
||||
reason: 'member_work_sync_report_required',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('routes watcher inbox changes for OpenCode members through direct runtime relay', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
@ -4357,7 +4727,10 @@ Messages:
|
|||
],
|
||||
})
|
||||
);
|
||||
hoisted.files.set(`${teamsBasePath}/${teamName}/inboxes/${memberName}.json`, JSON.stringify([]));
|
||||
hoisted.files.set(
|
||||
`${teamsBasePath}/${teamName}/inboxes/${memberName}.json`,
|
||||
JSON.stringify([])
|
||||
);
|
||||
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({
|
||||
ok: true,
|
||||
canonicalMemberName: memberName,
|
||||
|
|
@ -4415,7 +4788,10 @@ Messages:
|
|||
],
|
||||
})
|
||||
);
|
||||
hoisted.files.set(`${teamsBasePath}/${teamName}/inboxes/${memberName}.json`, JSON.stringify([]));
|
||||
hoisted.files.set(
|
||||
`${teamsBasePath}/${teamName}/inboxes/${memberName}.json`,
|
||||
JSON.stringify([])
|
||||
);
|
||||
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({
|
||||
ok: true,
|
||||
canonicalMemberName: memberName,
|
||||
|
|
|
|||
|
|
@ -30,13 +30,14 @@ function createDeferred<T>(): Deferred<T> {
|
|||
}
|
||||
|
||||
async function flushAsyncWork(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe('TeamReconcileDrainScheduler', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
mockYieldToEventLoop.mockReset();
|
||||
});
|
||||
|
|
@ -176,6 +177,72 @@ describe('TeamReconcileDrainScheduler', () => {
|
|||
scheduler.dispose();
|
||||
});
|
||||
|
||||
it('times out a hung run so pending team reconciles can continue', async () => {
|
||||
vi.useFakeTimers();
|
||||
mockYieldToEventLoop.mockResolvedValue(undefined);
|
||||
const hungRun = createDeferred<void>();
|
||||
const run = vi
|
||||
.fn<(teamName: string, trigger: TeamReconcileTrigger) => Promise<void>>()
|
||||
.mockImplementationOnce(async () => {
|
||||
await hungRun.promise;
|
||||
})
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const scheduler = createTeamReconcileDrainScheduler({
|
||||
run,
|
||||
runTimeoutMs: 10,
|
||||
});
|
||||
|
||||
scheduler.schedule('team-a', { source: 'inbox', detail: 'inboxes/alice.json' });
|
||||
await flushAsyncWork();
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
|
||||
scheduler.schedule('team-a', { source: 'task', detail: 'task-2.json' });
|
||||
await flushAsyncWork();
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(run).toHaveBeenNthCalledWith(2, 'team-a', {
|
||||
source: 'task',
|
||||
detail: 'task-2.json',
|
||||
});
|
||||
|
||||
scheduler.dispose();
|
||||
});
|
||||
|
||||
it('retries the timed out trigger when no newer event arrived', async () => {
|
||||
vi.useFakeTimers();
|
||||
mockYieldToEventLoop.mockResolvedValue(undefined);
|
||||
const hungRun = createDeferred<void>();
|
||||
const run = vi
|
||||
.fn<(teamName: string, trigger: TeamReconcileTrigger) => Promise<void>>()
|
||||
.mockImplementationOnce(async () => {
|
||||
await hungRun.promise;
|
||||
})
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const scheduler = createTeamReconcileDrainScheduler({
|
||||
run,
|
||||
runTimeoutMs: 10,
|
||||
});
|
||||
|
||||
scheduler.schedule('team-a', { source: 'inbox', detail: 'inboxes/alice.json' });
|
||||
await flushAsyncWork();
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(run).toHaveBeenNthCalledWith(2, 'team-a', {
|
||||
source: 'inbox',
|
||||
detail: 'inboxes/alice.json',
|
||||
});
|
||||
|
||||
scheduler.dispose();
|
||||
});
|
||||
|
||||
it('does not lose a new event that arrives while a failed pass is yielding', async () => {
|
||||
const yieldGate = createDeferred<void>();
|
||||
mockYieldToEventLoop.mockImplementationOnce(() => yieldGate.promise).mockResolvedValue(undefined);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,20 @@ import { describe, expect, it, vi } from 'vitest';
|
|||
import { ActiveTeamRegistry } from '../../../../../src/main/services/team/stallMonitor/ActiveTeamRegistry';
|
||||
|
||||
describe('ActiveTeamRegistry', () => {
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
it('activates a team on lead-activity and enables stall-monitor tracking', async () => {
|
||||
const tracker = {
|
||||
enableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })),
|
||||
|
|
@ -99,6 +113,92 @@ describe('ActiveTeamRegistry', () => {
|
|||
await expect(registry.listActiveTeams()).resolves.toEqual(['beta']);
|
||||
});
|
||||
|
||||
it('retries activation when enabling stall-monitor tracking fails', async () => {
|
||||
const tracker = {
|
||||
enableTracking: vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('tracker unavailable'))
|
||||
.mockResolvedValueOnce({ projectFingerprint: null, logSourceGeneration: null }),
|
||||
disableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })),
|
||||
};
|
||||
const registry = new ActiveTeamRegistry(
|
||||
{ listAliveProcessTeams: vi.fn(async () => ['demo']) },
|
||||
tracker as never
|
||||
);
|
||||
|
||||
registry.noteTeamChange({
|
||||
type: 'lead-activity',
|
||||
teamName: 'demo',
|
||||
detail: 'active',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(tracker.enableTracking).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
|
||||
'Failed to enable stall-monitor tracking for demo'
|
||||
);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
await expect(registry.listActiveTeams()).resolves.toEqual([]);
|
||||
|
||||
await registry.reconcile();
|
||||
|
||||
expect(tracker.enableTracking).toHaveBeenCalledTimes(2);
|
||||
await expect(registry.listActiveTeams()).resolves.toEqual(['demo']);
|
||||
});
|
||||
|
||||
it('does not re-add a team when pending activation finishes after stop', async () => {
|
||||
const activation = createDeferred<{
|
||||
projectFingerprint: string | null;
|
||||
logSourceGeneration: string | null;
|
||||
}>();
|
||||
const tracker = {
|
||||
enableTracking: vi.fn(() => activation.promise),
|
||||
disableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })),
|
||||
};
|
||||
const registry = new ActiveTeamRegistry(
|
||||
{ listAliveProcessTeams: vi.fn(async () => []) },
|
||||
tracker as never
|
||||
);
|
||||
|
||||
registry.noteTeamChange({
|
||||
type: 'lead-activity',
|
||||
teamName: 'demo',
|
||||
detail: 'active',
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(tracker.enableTracking).toHaveBeenCalledWith('demo', 'stall_monitor');
|
||||
});
|
||||
|
||||
await registry.stop();
|
||||
activation.resolve({ projectFingerprint: null, logSourceGeneration: null });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(tracker.disableTracking).toHaveBeenCalledWith('demo', 'stall_monitor');
|
||||
});
|
||||
await expect(registry.listActiveTeams()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('does not activate a team when a reconcile resumes after stop', async () => {
|
||||
const aliveTeams = createDeferred<string[]>();
|
||||
const tracker = {
|
||||
enableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })),
|
||||
disableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })),
|
||||
};
|
||||
const registry = new ActiveTeamRegistry(
|
||||
{ listAliveProcessTeams: vi.fn(() => aliveTeams.promise) },
|
||||
tracker as never
|
||||
);
|
||||
|
||||
const reconcilePromise = registry.reconcile();
|
||||
await registry.stop();
|
||||
aliveTeams.resolve(['demo']);
|
||||
await reconcilePromise;
|
||||
|
||||
expect(tracker.enableTracking).not.toHaveBeenCalled();
|
||||
await expect(registry.listActiveTeams()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('does not re-enable tracking for teams that are already active during reconcile', async () => {
|
||||
const tracker = {
|
||||
enableTracking: vi.fn(async () => ({ projectFingerprint: null, logSourceGeneration: null })),
|
||||
|
|
|
|||
|
|
@ -49,6 +49,99 @@ describe('TeamTaskStallJournal', () => {
|
|||
expect(secondReady).toEqual([evaluation]);
|
||||
});
|
||||
|
||||
it('allows the same stalled epoch to alert again after the cooldown expires', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-journal-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await fs.mkdir(path.join(tmpDir, 'teams', 'demo'), { recursive: true });
|
||||
|
||||
const journal = new TeamTaskStallJournal({ alertCooldownMs: 10 * 60_000 });
|
||||
const evaluation = {
|
||||
status: 'alert',
|
||||
taskId: 'task-a',
|
||||
branch: 'work',
|
||||
signal: 'turn_ended_after_touch',
|
||||
epochKey: 'task-a:epoch-1',
|
||||
reason: 'Potential work stall',
|
||||
} as const;
|
||||
|
||||
await journal.reconcileScan({
|
||||
teamName: 'demo',
|
||||
evaluations: [evaluation],
|
||||
activeTaskIds: ['task-a'],
|
||||
now: '2026-04-19T12:00:00.000Z',
|
||||
});
|
||||
await expect(
|
||||
journal.reconcileScan({
|
||||
teamName: 'demo',
|
||||
evaluations: [evaluation],
|
||||
activeTaskIds: ['task-a'],
|
||||
now: '2026-04-19T12:01:00.000Z',
|
||||
})
|
||||
).resolves.toEqual([evaluation]);
|
||||
await journal.markAlerted('demo', 'task-a:epoch-1', '2026-04-19T12:01:00.000Z');
|
||||
|
||||
await expect(
|
||||
journal.reconcileScan({
|
||||
teamName: 'demo',
|
||||
evaluations: [evaluation],
|
||||
activeTaskIds: ['task-a'],
|
||||
now: '2026-04-19T12:05:00.000Z',
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
journal.reconcileScan({
|
||||
teamName: 'demo',
|
||||
evaluations: [evaluation],
|
||||
activeTaskIds: ['task-a'],
|
||||
now: '2026-04-19T12:12:00.000Z',
|
||||
})
|
||||
).resolves.toEqual([evaluation]);
|
||||
});
|
||||
|
||||
it('does not suppress a stalled epoch forever when alertedAt is in the future', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-journal-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
const teamDir = path.join(tmpDir, 'teams', 'demo');
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'stall-monitor-journal.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
epochKey: 'task-a:epoch-1',
|
||||
teamName: 'demo',
|
||||
taskId: 'task-a',
|
||||
branch: 'work',
|
||||
signal: 'turn_ended_after_touch',
|
||||
state: 'alerted',
|
||||
consecutiveScans: 2,
|
||||
createdAt: '2026-04-19T12:00:00.000Z',
|
||||
updatedAt: '2026-04-19T12:01:00.000Z',
|
||||
alertedAt: '2026-04-19T13:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const journal = new TeamTaskStallJournal({ alertCooldownMs: 10 * 60_000 });
|
||||
const evaluation = {
|
||||
status: 'alert',
|
||||
taskId: 'task-a',
|
||||
branch: 'work',
|
||||
signal: 'turn_ended_after_touch',
|
||||
epochKey: 'task-a:epoch-1',
|
||||
reason: 'Potential work stall',
|
||||
} as const;
|
||||
|
||||
await expect(
|
||||
journal.reconcileScan({
|
||||
teamName: 'demo',
|
||||
evaluations: [evaluation],
|
||||
activeTaskIds: ['task-a'],
|
||||
now: '2026-04-19T12:05:00.000Z',
|
||||
})
|
||||
).resolves.toEqual([evaluation]);
|
||||
});
|
||||
|
||||
it('does not prune journal entries outside an explicit task scope', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-journal-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
|
@ -102,6 +195,64 @@ describe('TeamTaskStallJournal', () => {
|
|||
expect(saved.map((entry) => entry.epochKey)).toEqual(['task-codex:epoch-1']);
|
||||
});
|
||||
|
||||
it('backfills member name on existing stall entries before alerting', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-journal-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
const teamDir = path.join(tmpDir, 'teams', 'demo');
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
const journalPath = path.join(teamDir, 'stall-monitor-journal.json');
|
||||
await fs.writeFile(
|
||||
journalPath,
|
||||
JSON.stringify([
|
||||
{
|
||||
epochKey: 'task-a:epoch-1',
|
||||
teamName: 'demo',
|
||||
taskId: 'task-a',
|
||||
branch: 'work',
|
||||
signal: 'turn_ended_after_touch',
|
||||
state: 'suspected',
|
||||
consecutiveScans: 1,
|
||||
createdAt: '2026-04-19T12:00:00.000Z',
|
||||
updatedAt: '2026-04-19T12:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const journal = new TeamTaskStallJournal();
|
||||
const evaluation = {
|
||||
status: 'alert',
|
||||
taskId: 'task-a',
|
||||
memberName: 'bob',
|
||||
branch: 'work',
|
||||
signal: 'turn_ended_after_touch',
|
||||
epochKey: 'task-a:epoch-1',
|
||||
reason: 'Potential work stall',
|
||||
} as const;
|
||||
|
||||
await expect(
|
||||
journal.reconcileScan({
|
||||
teamName: 'demo',
|
||||
evaluations: [evaluation],
|
||||
activeTaskIds: ['task-a'],
|
||||
now: '2026-04-19T12:10:00.000Z',
|
||||
})
|
||||
).resolves.toEqual([evaluation]);
|
||||
|
||||
const saved = JSON.parse(await fs.readFile(journalPath, 'utf8')) as Array<{
|
||||
epochKey: string;
|
||||
memberName?: string;
|
||||
state: string;
|
||||
}>;
|
||||
expect(saved).toEqual([
|
||||
expect.objectContaining({
|
||||
epochKey: 'task-a:epoch-1',
|
||||
memberName: 'bob',
|
||||
state: 'alert_ready',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('recovers from an invalid journal file on the next scan', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-journal-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,32 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import { TeamTaskStallMonitor } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallMonitor';
|
||||
|
||||
function neverResolves(): Promise<never> {
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushAsyncWork(): Promise<void> {
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe('TeamTaskStallMonitor', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
|
|
@ -113,6 +139,200 @@ describe('TeamTaskStallMonitor', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('times out a hung scan so later stall scans continue', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000');
|
||||
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1');
|
||||
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '1');
|
||||
|
||||
const snapshotSource = {
|
||||
getSnapshot: vi.fn().mockImplementationOnce(neverResolves).mockResolvedValueOnce(null),
|
||||
};
|
||||
const monitor = new TeamTaskStallMonitor(
|
||||
{
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(async () => undefined),
|
||||
noteTeamChange: vi.fn(),
|
||||
listActiveTeams: vi.fn(async () => ['demo']),
|
||||
} as never,
|
||||
snapshotSource as never,
|
||||
{ evaluateWork: vi.fn(), evaluateReview: vi.fn() } as never,
|
||||
{ reconcileScan: vi.fn(), markAlerted: vi.fn() } as never,
|
||||
{ notifyLead: vi.fn(), notifyOpenCodeOwners: vi.fn() } as never,
|
||||
{ scanTimeoutMs: 10 }
|
||||
);
|
||||
|
||||
monitor.start();
|
||||
await vi.advanceTimersByTimeAsync(3_010);
|
||||
expect(snapshotSource.getSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
|
||||
'task stall monitor scan timed out after 10ms'
|
||||
);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_001);
|
||||
expect(snapshotSource.getSnapshot).toHaveBeenCalledTimes(2);
|
||||
|
||||
await monitor.stop();
|
||||
});
|
||||
|
||||
it('does not let one stuck team block stall scans for other active teams', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000');
|
||||
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1');
|
||||
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '1');
|
||||
|
||||
const task = {
|
||||
id: 'task-healthy',
|
||||
displayId: 'beef1234',
|
||||
subject: 'Healthy team task',
|
||||
};
|
||||
const readyEvaluation = {
|
||||
status: 'alert',
|
||||
taskId: 'task-healthy',
|
||||
branch: 'work',
|
||||
signal: 'turn_ended_after_touch',
|
||||
epochKey: 'task-healthy:epoch',
|
||||
reason: 'Potential work stall.',
|
||||
};
|
||||
const snapshotSource = {
|
||||
getSnapshot: vi.fn(async (teamName: string) => {
|
||||
if (teamName === 'stuck') {
|
||||
return neverResolves();
|
||||
}
|
||||
return {
|
||||
teamName: 'healthy',
|
||||
inProgressTasks: [task],
|
||||
reviewOpenTasks: [],
|
||||
allTasksById: new Map([['task-healthy', task]]),
|
||||
};
|
||||
}),
|
||||
};
|
||||
const journal = {
|
||||
reconcileScan: vi.fn(async () => [readyEvaluation]),
|
||||
markAlerted: vi.fn(async () => undefined),
|
||||
};
|
||||
const notifier = {
|
||||
notifyLead: vi.fn(async () => undefined),
|
||||
notifyOpenCodeOwners: vi.fn(async () => []),
|
||||
};
|
||||
const monitor = new TeamTaskStallMonitor(
|
||||
{
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(async () => undefined),
|
||||
noteTeamChange: vi.fn(),
|
||||
listActiveTeams: vi.fn(async () => ['stuck', 'healthy']),
|
||||
} as never,
|
||||
snapshotSource as never,
|
||||
{
|
||||
evaluateWork: vi.fn(() => readyEvaluation),
|
||||
evaluateReview: vi.fn(),
|
||||
} as never,
|
||||
journal as never,
|
||||
notifier as never,
|
||||
{ scanTimeoutMs: 100 }
|
||||
);
|
||||
|
||||
monitor.start();
|
||||
await vi.advanceTimersByTimeAsync(3_100);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
|
||||
'task stall monitor scan timed out after 100ms'
|
||||
);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
expect(snapshotSource.getSnapshot).toHaveBeenCalledWith('stuck');
|
||||
expect(snapshotSource.getSnapshot).toHaveBeenCalledWith('healthy');
|
||||
expect(notifier.notifyLead).toHaveBeenCalledWith(
|
||||
'healthy',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
taskId: 'task-healthy',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(journal.markAlerted).toHaveBeenCalledWith(
|
||||
'healthy',
|
||||
'task-healthy:epoch',
|
||||
expect.any(String)
|
||||
);
|
||||
|
||||
await monitor.stop();
|
||||
});
|
||||
|
||||
it('ignores late side effects from a scan that already timed out', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000');
|
||||
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1');
|
||||
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '1');
|
||||
|
||||
const staleJournalScan = createDeferred<unknown[]>();
|
||||
const readyEvaluation = {
|
||||
status: 'alert',
|
||||
taskId: 'task-a',
|
||||
branch: 'work',
|
||||
signal: 'turn_ended_after_touch',
|
||||
epochKey: 'task-a:epoch',
|
||||
reason: 'Potential work stall.',
|
||||
};
|
||||
const task = { id: 'task-a', displayId: 'abcd1234', subject: 'Task A' };
|
||||
const notifier = {
|
||||
notifyLead: vi.fn(async () => undefined),
|
||||
notifyOpenCodeOwners: vi.fn(async () => []),
|
||||
};
|
||||
const journal = {
|
||||
reconcileScan: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => staleJournalScan.promise)
|
||||
.mockResolvedValueOnce([]),
|
||||
markAlerted: vi.fn(async () => undefined),
|
||||
};
|
||||
const monitor = new TeamTaskStallMonitor(
|
||||
{
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(async () => undefined),
|
||||
noteTeamChange: vi.fn(),
|
||||
listActiveTeams: vi.fn(async () => ['demo']),
|
||||
} as never,
|
||||
{
|
||||
getSnapshot: vi.fn(async () => ({
|
||||
teamName: 'demo',
|
||||
inProgressTasks: [task],
|
||||
reviewOpenTasks: [],
|
||||
allTasksById: new Map([['task-a', task]]),
|
||||
})),
|
||||
} as never,
|
||||
{
|
||||
evaluateWork: vi.fn(() => readyEvaluation),
|
||||
evaluateReview: vi.fn(),
|
||||
} as never,
|
||||
journal as never,
|
||||
notifier as never,
|
||||
{ scanTimeoutMs: 10 }
|
||||
);
|
||||
|
||||
monitor.start();
|
||||
await vi.advanceTimersByTimeAsync(3_010);
|
||||
expect(journal.reconcileScan).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
|
||||
'task stall monitor scan timed out after 10ms'
|
||||
);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_001);
|
||||
expect(journal.reconcileScan).toHaveBeenCalledTimes(2);
|
||||
|
||||
staleJournalScan.resolve([readyEvaluation]);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(notifier.notifyLead).not.toHaveBeenCalled();
|
||||
expect(journal.markAlerted).not.toHaveBeenCalled();
|
||||
|
||||
await monitor.stop();
|
||||
});
|
||||
|
||||
it('defaults to OpenCode owner remediation without duplicate lead alerts when remediation is accepted', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000');
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ interface StoreState {
|
|||
};
|
||||
codex: {
|
||||
preferredAuthMode: 'auto' | 'chatgpt' | 'api_key';
|
||||
customProvider: {
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -115,6 +120,25 @@ vi.mock('@renderer/components/ui/button', () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/checkbox', () => ({
|
||||
Checkbox: ({
|
||||
checked,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}) =>
|
||||
React.createElement('input', {
|
||||
type: 'checkbox',
|
||||
checked: Boolean(checked),
|
||||
disabled,
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onCheckedChange?.(event.currentTarget.checked),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/dialog', () => ({
|
||||
Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
|
||||
open ? React.createElement('div', { 'data-testid': 'dialog' }, children) : null,
|
||||
|
|
@ -282,6 +306,13 @@ function createCodexProvider(
|
|||
Boolean(overrides?.authenticated ?? true) || Boolean(overrides?.apiKeyConfigured)
|
||||
? 'ready_api_key'
|
||||
: 'missing_auth',
|
||||
customProvider: {
|
||||
enabled: false,
|
||||
active: false,
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
issueMessage: null,
|
||||
},
|
||||
...overrides?.codex,
|
||||
},
|
||||
},
|
||||
|
|
@ -487,6 +518,11 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
|||
},
|
||||
codex: {
|
||||
preferredAuthMode: 'auto',
|
||||
customProvider: {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -518,6 +554,10 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
|||
codex: {
|
||||
...storeState.appConfig.providerConnections.codex,
|
||||
...(nextProviderConnections.codex ?? {}),
|
||||
customProvider: {
|
||||
...storeState.appConfig.providerConnections.codex.customProvider,
|
||||
...(nextProviderConnections.codex?.customProvider ?? {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -997,6 +1037,166 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
|||
expect(host.textContent).toContain('Connect ChatGPT');
|
||||
});
|
||||
|
||||
it('saves a Codex custom provider profile and reuses OPENAI_API_KEY storage', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onRefreshProvider = vi.fn(() => Promise.resolve(undefined));
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(ProviderRuntimeSettingsDialog, {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
providers: [
|
||||
createCodexProvider({
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
apiKeyConfigured: false,
|
||||
apiKeySource: null,
|
||||
apiKeySourceLabel: null,
|
||||
}),
|
||||
],
|
||||
initialProviderId: 'codex',
|
||||
onSelectBackend: vi.fn(),
|
||||
onRefreshProvider,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Custom API endpoint');
|
||||
const enabledInput = host.querySelector(
|
||||
'[data-testid="codex-custom-provider-panel"] input[type="checkbox"]'
|
||||
) as HTMLInputElement | null;
|
||||
const baseUrlInput = host.querySelector(
|
||||
'[data-testid="codex-custom-provider-base-url"]'
|
||||
) as HTMLInputElement | null;
|
||||
const modelInput = host.querySelector(
|
||||
'[data-testid="codex-custom-provider-model"]'
|
||||
) as HTMLInputElement | null;
|
||||
const apiKeyInput = host.querySelector(
|
||||
'[data-testid="codex-custom-provider-api-key"]'
|
||||
) as HTMLInputElement | null;
|
||||
expect(enabledInput).not.toBeNull();
|
||||
expect(baseUrlInput).not.toBeNull();
|
||||
expect(modelInput).not.toBeNull();
|
||||
expect(apiKeyInput).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
enabledInput!.click();
|
||||
setInputValue(baseUrlInput!, 'https://gateway.example.com/v1');
|
||||
setInputValue(modelInput!, 'gateway-codex-model');
|
||||
setInputValue(apiKeyInput!, 'sk-test');
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(host, 'Save endpoint').click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.saveApiKey).toHaveBeenCalledWith({
|
||||
id: undefined,
|
||||
name: 'Codex API Key',
|
||||
envVarName: 'OPENAI_API_KEY',
|
||||
value: 'sk-test',
|
||||
scope: 'user',
|
||||
});
|
||||
expect(storeState.updateConfig).toHaveBeenCalledWith('providerConnections', {
|
||||
codex: {
|
||||
preferredAuthMode: 'api_key',
|
||||
customProvider: {
|
||||
enabled: true,
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
model: 'gateway-codex-model',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(codexAccountHookState.refresh).toHaveBeenCalledWith({
|
||||
includeRateLimits: true,
|
||||
forceRefreshToken: true,
|
||||
});
|
||||
expect(onRefreshProvider).toHaveBeenCalledWith('codex');
|
||||
});
|
||||
|
||||
it('disables Codex custom provider without deleting its saved key or profile fields', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onRefreshProvider = vi.fn(() => Promise.resolve(undefined));
|
||||
storeState.appConfig.providerConnections.codex = {
|
||||
preferredAuthMode: 'api_key',
|
||||
customProvider: {
|
||||
enabled: true,
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
model: 'gateway-codex-model',
|
||||
},
|
||||
};
|
||||
storeState.apiKeys = [
|
||||
{
|
||||
id: 'openai-key',
|
||||
envVarName: 'OPENAI_API_KEY',
|
||||
scope: 'user',
|
||||
name: 'Codex API Key',
|
||||
maskedValue: 'sk-...xyz',
|
||||
},
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(ProviderRuntimeSettingsDialog, {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
providers: [
|
||||
createCodexProvider({
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
configuredAuthMode: 'api_key',
|
||||
apiKeyConfigured: true,
|
||||
apiKeySource: 'stored',
|
||||
apiKeySourceLabel: 'Stored in app',
|
||||
codex: {
|
||||
preferredAuthMode: 'api_key',
|
||||
effectiveAuthMode: 'api_key',
|
||||
customProvider: {
|
||||
enabled: true,
|
||||
active: true,
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
model: 'gateway-codex-model',
|
||||
issueMessage: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
initialProviderId: 'codex',
|
||||
onSelectBackend: vi.fn(),
|
||||
onRefreshProvider,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('sk-...xyz');
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(host, 'Disable').click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.updateConfig).toHaveBeenCalledWith('providerConnections', {
|
||||
codex: {
|
||||
customProvider: {
|
||||
enabled: false,
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
model: 'gateway-codex-model',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(storeState.deleteApiKey).not.toHaveBeenCalled();
|
||||
expect(onRefreshProvider).toHaveBeenCalledWith('codex');
|
||||
});
|
||||
|
||||
it('explains the missing Codex ChatGPT login without mixing it up with the detected API key', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
|
|||
|
|
@ -255,6 +255,11 @@ function makeAppConfig(multimodelEnabled: boolean): AppConfig {
|
|||
},
|
||||
codex: {
|
||||
preferredAuthMode: 'auto',
|
||||
customProvider: {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue