fix(bridge): approval ENOENT race condition + multi-choice button grouping #task-276 #task-277

This commit is contained in:
2026-03-10 06:32:20 +09:00
parent 373c0f7ddc
commit aab1cfba27
8 changed files with 371 additions and 77 deletions

View File

@@ -471,7 +471,7 @@ function startObserverHttpBridge(): Promise<number> {
source: 'dom_observer',
};
fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2));
logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" ctx="${(data.description || '').substring(0, 50)}"`);
logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" btns=${(data.buttons || []).length} ctx="${(data.description || '').substring(0, 50)}"`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, request_id: rid }));
} catch (e: any) {
@@ -885,10 +885,14 @@ function generateApprovalObserverScript(_port: number): string {
{re:/^Accept$/i, type:'agent_step'},
{re:/^Allow/i, type:'permission'},
{re:/^Approve/i, type:'agent_step'},
{re:/^Deny$/i, type:'permission'},
{re:/^Retry$/i, type:'error_recovery'},
{re:/^Dismiss$/i, type:'error_recovery'},
];
// ALL actionable button patterns (for grouping siblings in same container)
var ALL_ACTION_RE=[/^Run/i,/^Accept/i,/^Reject/i,/^Allow/i,/^Deny/i,/^Approve/i,/^Cancel$/i,/^Retry$/i,/^Dismiss$/i,/^Stop$/i,/^Decline$/i];
// Reject button patterns for finding the counterpart
var REJECT_RE=[/^reject$/i,/^reject all$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i,/^dismiss$/i];
@@ -933,6 +937,41 @@ function generateApprovalObserverScript(_port: number): string {
return desc.substring(0,500);
}
// ── Find common container of related buttons ──
function findButtonContainer(btn){
return btn.closest('[class*="step"]')
||btn.closest('[class*="action"]')
||btn.closest('[class*="tool"]')
||btn.closest('[class*="cascade"]')
||btn.closest('[class*="message"]')
||btn.closest('[class*="dialog"]')
||btn.closest('[class*="notification"]')
||btn.parentElement;
}
// ── Collect all actionable sibling buttons from a container ──
function collectSiblingButtons(container,triggerBtn){
if(!container)return [];
var siblings=container.querySelectorAll('button');
var result=[];
for(var i=0;i<siblings.length;i++){
var sb=siblings[i];
if(sb.disabled||sb.hidden)continue;
try{if(!sb.offsetParent&&sb.style.display!=='fixed')continue;}catch(e){}
var stxt=(sb.textContent||'').trim();
stxt=stxt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/,'').trim();
if(!stxt)continue;
// Check if this button matches any actionable pattern
var isAction=false;
for(var a=0;a<ALL_ACTION_RE.length;a++){
if(ALL_ACTION_RE[a].test(stxt)){isAction=true;break;}
}
if(!isAction)continue;
result.push({btn:sb,text:stxt,isPrimary:(sb===triggerBtn)});
}
return result;
}
// ── Find the React app container (Antigravity's main UI root) ──
function findPanel(){
// Priority order of panel selectors (most specific first)
@@ -951,6 +990,7 @@ function generateApprovalObserverScript(_port: number): string {
}
// ── Core scan — finds actionable buttons and reports to bridge ──
// Groups related buttons from same container into a single pending
function scan(){
if(!_ready)return;
var now=Date.now();
@@ -988,78 +1028,122 @@ function generateApprovalObserverScript(_port: number): string {
}
if(!matchedType)continue;
// Generate stable ID
var bid=btnId(b,matchedType);
if(_sent[bid])continue;
// Generate stable ID for the GROUP (use container-based key)
var container=findButtonContainer(b);
var groupKey=matchedType+'|group|'+(container?(container.textContent||'').substring(0,40).replace(/\\s+/g,' '):'none');
if(_sent[groupKey])continue;
// Extract context
// Collect ALL related buttons from the same container
var siblings=collectSiblingButtons(container,b);
if(siblings.length===0)siblings=[{btn:b,text:txt,isPrimary:true}];
// Build buttons array for multi-choice support
var buttonsArr=[];
var btnRefs=[];
var bidList=[];
for(var si=0;si<siblings.length;si++){
var sb=siblings[si];
var sbid=btnId(sb.btn,matchedType);
buttonsArr.push({text:sb.text,index:si,is_primary:sb.isPrimary});
btnRefs.push(sb.btn);
bidList.push(sbid);
}
// Extract context from trigger button
var desc=extractContext(b);
var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6);
// Mark as sent
_sent[bid]={rid:rid,ts:now};
log('DETECTED '+matchedType+': "'+txt+'" → pending to bridge');
// Mark entire group as sent
_sent[groupKey]={rid:rid,ts:now};
for(var mk=0;mk<bidList.length;mk++){_sent[bidList[mk]]={rid:rid,ts:now};}
log('DETECTED GROUP '+matchedType+': '+buttonsArr.map(function(x){return '"'+x.text+'"';}).join(', ')+' → pending to bridge');
// Send to bridge (closure to capture refs)
(function(rid2,b2,bid2,txt2,desc2,type2){
(function(rid2,btnRefs2,bidList2,groupKey2,txt2,desc2,type2,buttonsArr2){
var payload={
request_id:rid2,
command:txt2,
description:desc2,
step_type:type2,
buttons:buttonsArr2
};
fetch(BASE+'/pending',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
request_id:rid2,
command:txt2,
description:desc2,
step_type:type2
})
body:JSON.stringify(payload)
}).then(function(r){return r.json();}).then(function(d){
log('Pending created: '+d.request_id+' for "'+txt2+'"');
pollResponse(d.request_id,b2,bid2);
log('Pending created: '+d.request_id+' for group ['+buttonsArr2.map(function(x){return x.text;}).join(', ')+']');
pollResponseGroup(d.request_id,btnRefs2,bidList2,groupKey2);
}).catch(function(e){
log('POST error: '+e.message);
delete _sent[bid2];
delete _sent[groupKey2];
for(var di=0;di<bidList2.length;di++){delete _sent[bidList2[di]];}
});
})(rid,b,bid,txt,desc,matchedType);
})(rid,btnRefs,bidList,groupKey,txt,desc,matchedType,buttonsArr);
// Process ONE button per scan cycle (avoid flooding)
// Process ONE button GROUP per scan cycle (avoid flooding)
return;
}
} // end searchRoots loop
}
// ── Poll for Discord response ──
function pollResponse(rid,btn,bid){
// ── Poll for Discord response (multi-button group aware) ──
function pollResponseGroup(rid,btnRefs,bidList,groupKey){
var polls=0;
var maxPolls=600; // 5 minutes at 500ms interval
var timer=setInterval(function(){
polls++;
// Check if button is still in DOM (step may have been resolved by other means)
if(!document.body.contains(btn)){
log('Button removed from DOM — stopping poll for '+rid);
// Check if ANY button in the group is still in DOM
var anyAlive=false;
for(var ai=0;ai<btnRefs.length;ai++){
if(document.body.contains(btnRefs[ai])){anyAlive=true;break;}
}
if(!anyAlive){
log('All buttons removed from DOM — stopping poll for '+rid);
clearInterval(timer);
delete _sent[bid];
delete _sent[groupKey];
for(var ci=0;ci<bidList.length;ci++){delete _sent[bidList[ci]];}
return;
}
if(polls>maxPolls){
log('Poll timeout for '+rid);
clearInterval(timer);
delete _sent[bid];
delete _sent[groupKey];
for(var ti=0;ti<bidList.length;ti++){delete _sent[bidList[ti]];}
return;
}
fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){
if(d.waiting)return;
clearInterval(timer);
if(d.approved){
log('✅ APPROVED '+rid+' → clicking "'+((btn.textContent||'').trim())+'"');
btn.click();
var btnIdx=(typeof d.button_index==='number'&&d.button_index>=0)?d.button_index:-1;
if(btnIdx>=0&&btnIdx<btnRefs.length){
// Multi-choice: click specific button by index
var targetBtn=btnRefs[btnIdx];
var targetTxt=(targetBtn.textContent||'').trim();
log((d.approved?'✅':'❌')+' CHOICE '+rid+' → clicking button['+btnIdx+'] "'+targetTxt+'"');
targetBtn.click();
} else if(d.approved){
// Legacy single-button: click first (primary) button
var primaryBtn=btnRefs[0];
log('✅ APPROVED '+rid+' → clicking "'+((primaryBtn.textContent||'').trim())+'"');
primaryBtn.click();
} else {
// Legacy reject: find and click reject/deny button
log('❌ REJECTED '+rid+' → finding reject button');
clickRejectButton(btn);
clickRejectButton(btnRefs[0]);
}
delete _sent[bid];
delete _sent[groupKey];
for(var ri=0;ri<bidList.length;ri++){delete _sent[bidList[ri]];}
}).catch(function(){});
},500);
}
// Legacy pollResponse for backward compatibility (single button)
function pollResponse(rid,btn,bid){
pollResponseGroup(rid,[btn],[bid],bid);
}
// ── Find and click the reject/cancel counterpart button ──
function clickRejectButton(approveBtn){
// Walk up to find the container, then search for reject buttons
@@ -1686,7 +1770,13 @@ async function processResponseFile(filePath: string) {
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`);
// Cleanup response file
try { fs.unlinkSync(filePath); } catch { }
// CRITICAL: DOM observer responses must NOT be deleted here!
// The renderer polls GET /response/:rid to discover the approval.
// If we delete the file before the renderer polls, it gets ENOENT.
// The HTTP handler (/response/:rid) deletes after serving to renderer.
if (!isDomObserver) {
try { fs.unlinkSync(filePath); } catch { }
}
} catch (e: any) {
const log = `[RESPONSE] error: ${e.message}`;
console.log(`Gravity Bridge: ${log}`);