@@ -126,7 +126,7 @@ function parseLoopEvent(e) {
126126
127127function PipelineHeader ( {
128128 phase, running, liveAgent, counts, intervention, turnMeter,
129- loopState, continueAsk, onContinue,
129+ loopState, continueAsk, escalateAsk , onContinue,
130130 pipeOrder, pipeMeta, pipeGroups, slots,
131131} ) {
132132 const order = pipeOrder && pipeOrder . length ? pipeOrder : DEFAULT_PIPE ;
@@ -135,7 +135,7 @@ function PipelineHeader({
135135 const idx = pipeIndex ( phase , order ) ;
136136 const total = order . length ;
137137 const looping = ! ! ( loopState && ! LOOP_DONE_ACTIONS . has ( loopState . action ) ) ;
138- const awaiting = ! ! ( continueAsk || ( loopState && loopState . awaiting ) ) ;
138+ const awaiting = ! ! ( continueAsk || escalateAsk || ( loopState && loopState . awaiting ) ) ;
139139 const rewindIdx = looping ? pipeIndex ( loopState . to || phase , order ) : - 1 ;
140140 const effectiveIdx = looping && rewindIdx >= 0 ? Math . min ( idx , rewindIdx ) : idx ;
141141 const doneCount = phase === "done" && ! looping ? total : Math . max ( 0 , effectiveIdx ) ;
@@ -791,6 +791,7 @@ function App() {
791791 const [ clarifyAnswers , setClarifyAnswers ] = useState ( { } ) ;
792792 const [ planAsk , setPlanAsk ] = useState ( null ) ;
793793 const [ continueAsk , setContinueAsk ] = useState ( null ) ;
794+ const [ escalateAsk , setEscalateAsk ] = useState ( null ) ;
794795 const [ loopState , setLoopState ] = useState ( null ) ;
795796 const [ pipelineView , setPipelineView ] = useState ( null ) ;
796797 const [ pipeDraft , setPipeDraft ] = useState ( null ) ;
@@ -1027,6 +1028,9 @@ function App() {
10271028 } ) ;
10281029 setClarifyAnswers ( seed ) ;
10291030 showToast ( "Scope interview — choose options or use recommended" ) ;
1031+ } else if ( ask && ask . kind === "escalate" ) {
1032+ setEscalateAsk ( ask ) ;
1033+ showToast ( "⚠ " + ( ask . task_id || "Task" ) + " needs your decision" ) ;
10301034 } else if ( ask && ask . kind === "continue" ) {
10311035 setContinueAsk ( ask ) ;
10321036 showToast ( "Retries exhausted — continue another wave?" ) ;
@@ -1043,16 +1047,24 @@ function App() {
10431047 setClarifyAsk ( null ) ;
10441048 setPlanAsk ( null ) ;
10451049 setContinueAsk ( null ) ;
1050+ setEscalateAsk ( null ) ;
10461051 setShellAsk ( null ) ;
10471052 }
10481053 if ( e . kind === "intervention" ) {
10491054 const banner = {
10501055 code : e . scope || "quality" ,
10511056 message : e . message || "harness intervention" ,
10521057 detail : e . output || "" ,
1058+ taskId : e . task_id || "" ,
10531059 } ;
10541060 setIntervention ( banner ) ;
10551061 showToast ( "⚠ " + banner . message ) ;
1062+ if ( banner . code === "escalate" ) {
1063+ // Recover modal if KindAsk raced / was missed.
1064+ api ( "/api/escalate/pending" ) . then ( ( p ) => {
1065+ if ( p && p . pending && p . ask ) setEscalateAsk ( p . ask ) ;
1066+ } ) . catch ( ( ) => { } ) ;
1067+ }
10561068 }
10571069 if ( e . kind === "loop" ) {
10581070 const loop = parseLoopEvent ( e ) ;
@@ -1128,6 +1140,7 @@ function App() {
11281140 setPhase ( "init" ) ;
11291141 setLoopState ( null ) ;
11301142 setContinueAsk ( null ) ;
1143+ setEscalateAsk ( null ) ;
11311144 setIntervention ( null ) ;
11321145 setNav ( "run" ) ; // jump to Live so progress is obvious
11331146 try {
@@ -1359,6 +1372,30 @@ function App() {
13591372 showToast ( labels [ action ] || action ) ;
13601373 }
13611374
1375+ async function submitEscalate ( action ) {
1376+ if ( ! escalateAsk ) return ;
1377+ await api ( "/api/escalate/answer" , {
1378+ method : "POST" ,
1379+ body : JSON . stringify ( { action } ) ,
1380+ } ) ;
1381+ const taskId = escalateAsk . task_id || "" ;
1382+ setEscalateAsk ( null ) ;
1383+ setIntervention ( null ) ;
1384+ setLoopState ( ( prev ) => prev ? {
1385+ ...prev ,
1386+ awaiting : false ,
1387+ action : "escalate_resolved" ,
1388+ reason : ( taskId || "task" ) + " → " + action ,
1389+ } : prev ) ;
1390+ const labels = {
1391+ re_scope : "Left in backlog for re-scope" ,
1392+ retry : "Retrying task" ,
1393+ mark_done : "Marked done" ,
1394+ abort : "Task aborted" ,
1395+ } ;
1396+ showToast ( ( taskId ? taskId + ": " : "" ) + ( labels [ action ] || action ) ) ;
1397+ }
1398+
13621399 async function saveConfig ( patch ) {
13631400 // Send only patch keys — never round-trip Public() config (api_key "***", etc.).
13641401 const body = { ...patch } ;
@@ -1583,6 +1620,7 @@ function App() {
15831620 turnMeter = { turnMeter }
15841621 loopState = { loopState }
15851622 continueAsk = { continueAsk }
1623+ escalateAsk = { escalateAsk }
15861624 onContinue = { submitContinue }
15871625 pipeOrder = { livePipe . order }
15881626 pipeMeta = { livePipe . meta }
@@ -2503,6 +2541,39 @@ function App() {
25032541 < option value = "off" > off</ option >
25042542 </ select >
25052543 </ label >
2544+ < label > Escalate ask
2545+ < select
2546+ value = { config . escalate_ask || "ask" }
2547+ onChange = { ( e ) => saveConfig ( { escalate_ask : e . target . value } ) }
2548+ >
2549+ < option value = "ask" > ask (pause for decision)</ option >
2550+ < option value = "auto" > auto (retry once)</ option >
2551+ < option value = "off" > off (leave in backlog)</ option >
2552+ </ select >
2553+ </ label >
2554+ < label > Escalate timeout (sec)
2555+ < input
2556+ type = "number"
2557+ min = { 5 }
2558+ max = { 600 }
2559+ defaultValue = {
2560+ typeof config . escalate_ask_timeout === "number"
2561+ ? Math . max ( 5 , Math . round ( config . escalate_ask_timeout / 1e9 ) )
2562+ : 30
2563+ }
2564+ onBlur = { ( e ) => saveConfig ( { escalate_ask_timeout_sec : Number ( e . target . value ) } ) }
2565+ />
2566+ </ label >
2567+ < label > Continue ask
2568+ < select
2569+ value = { config . continue_ask || "ask" }
2570+ onChange = { ( e ) => saveConfig ( { continue_ask : e . target . value } ) }
2571+ >
2572+ < option value = "ask" > ask (after QA exhausted)</ option >
2573+ < option value = "auto" > auto (one more wave)</ option >
2574+ < option value = "off" > off</ option >
2575+ </ select >
2576+ </ label >
25062577 < label > Shell permission
25072578 < select
25082579 value = { config . shell_permission || "allow" }
@@ -2655,6 +2726,45 @@ function App() {
26552726 </ div >
26562727 ) : null }
26572728
2729+ { escalateAsk ? (
2730+ < div className = "modal-backdrop escalate-modal" style = { {
2731+ position : "fixed" , inset : 0 , background : "rgba(0,0,0,0.5)" , zIndex : 85 ,
2732+ display : "flex" , alignItems : "center" , justifyContent : "center" , padding : 16 ,
2733+ } } >
2734+ < div className = "card" style = { {
2735+ maxWidth : 640 , width : "100%" , maxHeight : "85vh" , overflow : "auto" ,
2736+ background : "var(--panel)" , border : "1px solid var(--border)" , borderRadius : 12 , padding : 16 ,
2737+ } } >
2738+ < h2 style = { { marginTop : 0 } } > ⚠ Human review needed</ h2 >
2739+ < p className = "lead" > { escalateAsk . summary || "Task escalated after max retries." } </ p >
2740+ < p style = { { color : "var(--muted)" , fontSize : "0.84rem" } } >
2741+ Pipeline is paused on this task
2742+ { escalateAsk . timeout_sec ? ` · timeout ${ escalateAsk . timeout_sec } s → re-scope` : "" }
2743+ </ p >
2744+ < p style = { { fontSize : "0.85rem" } } >
2745+ < strong > { escalateAsk . task_id } </ strong >
2746+ { escalateAsk . title ? ` — ${ escalateAsk . title } ` : "" }
2747+ { escalateAsk . role ? ` · @${ escalateAsk . role } ` : "" }
2748+ </ p >
2749+ { ( escalateAsk . files || [ ] ) . length > 0 ? (
2750+ < p style = { { fontSize : "0.8rem" } } > < strong > Files:</ strong > { escalateAsk . files . join ( ", " ) } </ p >
2751+ ) : null }
2752+ { escalateAsk . detail ? (
2753+ < pre style = { {
2754+ whiteSpace : "pre-wrap" , fontSize : 12 , maxHeight : 180 , overflow : "auto" ,
2755+ background : "var(--bg)" , padding : 10 , borderRadius : 8 ,
2756+ } } > { escalateAsk . detail } </ pre >
2757+ ) : null }
2758+ < div className = "row" style = { { gap : 8 , justifyContent : "flex-end" , marginTop : 12 , flexWrap : "wrap" } } >
2759+ < button className = "ghost danger" onClick = { ( ) => submitEscalate ( "abort" ) } > Abort task</ button >
2760+ < button className = "ghost" onClick = { ( ) => submitEscalate ( "re_scope" ) } > Re-scope / fix later</ button >
2761+ < button className = "ghost" onClick = { ( ) => submitEscalate ( "mark_done" ) } > Mark done</ button >
2762+ < button onClick = { ( ) => submitEscalate ( "retry" ) } > Retry now</ button >
2763+ </ div >
2764+ </ div >
2765+ </ div >
2766+ ) : null }
2767+
26582768 { continueAsk ? (
26592769 < div className = "modal-backdrop" style = { {
26602770 position : "fixed" , inset : 0 , background : "rgba(0,0,0,0.45)" , zIndex : 83 ,
@@ -2713,7 +2823,9 @@ function App() {
27132823 { health ?. root ? health . root . replace ( / ^ \/ U s e r s \/ [ ^ / ] + / , "~" ) : "…" }
27142824 </ span >
27152825 < span className = "footer-live" >
2716- { continueAsk || ( loopState && loopState . awaiting )
2826+ { escalateAsk
2827+ ? `⏳ escalate ${ escalateAsk . task_id || "" } ?`
2828+ : continueAsk || ( loopState && loopState . awaiting )
27172829 ? "⏳ continue/abort?"
27182830 : loopState && ! LOOP_DONE_ACTIONS . has ( loopState . action )
27192831 ? `↺ ${ LOOP_LABELS [ loopState . action ] || "loop" } ${ loopState . wave ? " W" + loopState . wave : "" } `
0 commit comments