Setting the file. One moment.
Subagent Handoff · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page
Number 45.154
Position 154 of 182
Type JavaScript
Size 9 KB
Lines 179 lib/ subagent-handoff.js
JavaScript · 179 lines · 9 KB
require
(
'node:path'
);
13 const readline = require ( 'node:readline' );
14 const { summarizeWriteLog } = require ( './write-log-summary.js' );
15 const { listUserActions } = require ( './user-actions.js' );
16
17 const SCHEMA_VERSION = 1 ;
18 const HANDOFF_DIR = [ 'state' , 'handoffs' ];
19 const DEFAULT_CAP_BYTES = 8 * 1024 ;
20 const OUTCOMES = new Set ([ 'done' , 'failed' , 'budget_exceeded' , 'needs_decision' , 'resume_limit' , 'qa_findings' ]);
21 const CROSSWALK_FILE = 'state/crosswalk/crosswalk.ndjson' ;
22
23 function handoffDir ( projectDir ) { return path. join (projectDir, ... HANDOFF_DIR ); }
24
25 function safeStage ( stage ) {
26 const value = String (stage || '' ). trim ();
27 if ( ! / ^ [A-Za-z0-9_.-] {1,80}$ / . test (value)) throw new Error ( `invalid --stage ${ JSON . stringify ( stage ) } (letters, digits, _ . - only)` );
28 return value;
29 }
30
31 function listHandoffs ( projectDir , stage ) {
32 const dir = handoffDir (projectDir);
33 if ( ! fs. existsSync (dir)) return [];
34 const prefix = `${ stage }-` ;
35 return fs. readdirSync (dir)
36 . filter (( name ) => name. startsWith (prefix) && name. endsWith ( '.json' ))
37 . map (( name ) => ({ name, round: Number (name. slice (prefix. length , - 5 )) }))
38 . filter (( entry ) => Number. isInteger (entry.round))
39 . sort (( a , b ) => a.round - b.round);
40 }
41
42 function readHandoff ( projectDir , stage , round ) {
43 const file = path. join ( handoffDir (projectDir), `${ stage }-${ round }.json` );
44 return JSON . parse (fs. readFileSync (file, 'utf8' ));
45 }
46
47 // A checkpoint is settled import progress: per entity, how many attempts are now `imported`, plus
48 // how many crosswalk rows exist, plus the completion-report status. Bytes are not progress: an
49 // importer appends a `started` row before every write, so a crash on the same record would always
50 // look like movement.
51 const PROGRESS_STATUSES = new Set ([ 'imported' ]);
52
53 async function countLines ( file ) {
54 if ( ! fs. existsSync (file)) return 0 ;
55 let count = 0 ;
56 const lines = readline. createInterface ({ input: fs. createReadStream (file, { encoding: 'utf8' }), crlfDelay: Infinity });
57 for await ( const line of lines) if (line. trim ()) count += 1 ;
58 return count;
59 }
60
61 async function checkpointOf ( projectDir ) {
62 const ledger = await summarizeWriteLog (projectDir, { log: 'attempts' });
63 const settled = {};
64 for ( const [ entity , statuses ] of Object. entries (ledger.byEntity || {})) {
65 const done = Object. entries (statuses). filter (([ status ]) => PROGRESS_STATUSES . has (status)). reduce (( sum , [, n ]) => sum + n, 0 );
66 if (done) settled[entity] = done;
67 }
68 const report = path. join (projectDir, 'execution' , 'completion-report.json' );
69 let reportStatus = null ;
70 if (fs. existsSync (report)) {
71 try { reportStatus = JSON . parse (fs. readFileSync (report, 'utf8' )).status || null ; } catch { reportStatus = 'unreadable' ; }
72 }
73 return { settled, crosswalkRows: await countLines (path. join (projectDir, CROSSWALK_FILE )), completionReportStatus: reportStatus };
74 }
75
76 function sameCheckpoint ( a , b ) {
77 return Boolean (a && b) && JSON . stringify (a.settled) === JSON . stringify (b.settled)
78 && a.crosswalkRows === b.crosswalkRows && a.completionReportStatus === b.completionReportStatus;
79 }
80
81 function readFindings ( findingsFile ) {
82 if ( ! findingsFile) return [];
83 const raw = fs. readFileSync (findingsFile, 'utf8' );
84 try {
85 const parsed = JSON . parse (raw);
86 const list = Array. isArray (parsed) ? parsed : (Array. isArray (parsed.findings) ? parsed.findings : [parsed]);
87 return list. map (( item ) => ( typeof item === 'string' ? item : JSON . stringify (item)));
88 } catch {
89 return raw. split ( / \r ? \n / ). map (( line ) => line. replace ( / ^ \s * [-*]\s * / , '' ). trim ()). filter (Boolean);
90 }
91 }
92
93 function sizeOf ( value ) { return Buffer. byteLength ( JSON . stringify (value, null , 2 )); }
94
95 // Shrink in a fixed order until the file fits: samples first, QA findings last (they are the
96 // reason the round exists). Every cut is recorded so the next agent knows to ask for more.
97 function fitToCap ( handoff , capBytes ) {
98 const cuts = [];
99 const steps = [
100 () => { if (handoff.writeLog && handoff.writeLog.topErrors) { delete handoff.writeLog.topErrors; cuts. push ( 'writeLog.topErrors' ); return true ; } return false ; },
101 () => { if (handoff.changedFiles. length > 20 ) { handoff.changedFilesOmitted = handoff.changedFiles. length - 20 ; handoff.changedFiles = handoff.changedFiles. slice ( 0 , 20 ); cuts. push ( 'changedFiles' ); return true ; } return false ; },
102 () => { if (handoff.pendingUserActions. length > 5 ) { handoff.pendingUserActionsOmitted = handoff.pendingUserActions. length - 5 ; handoff.pendingUserActions = handoff.pendingUserActions. slice ( 0 , 5 ); cuts. push ( 'pendingUserActions' ); return true ; } return false ; },
103 () => { if (handoff.writeLog && handoff.writeLog.byEntity) { handoff.writeLog = { totals: handoff.writeLog.totals, runs: handoff.writeLog.runs, attempts: handoff.writeLog.attempts }; cuts. push ( 'writeLog.byEntity' ); return true ; } return false ; },
104 () => { if (handoff.notes && handoff.notes. length > 400 ) { handoff.notes = `${ handoff . notes . slice ( 0 , 400 ) }…` ; cuts. push ( 'notes' ); return true ; } return false ; },
105 () => {
106 const long = handoff.qaFindings. some (( f ) => f. length > 400 );
107 if ( ! long) return false ;
108 handoff.qaFindings = handoff.qaFindings. map (( f ) => (f. length > 400 ? `${ f . slice ( 0 , 400 ) }…` : f));
109 cuts. push ( 'qaFindings.truncatedEach' );
110 return true ;
111 },
112 () => { if (handoff.qaFindings. length > 10 ) { handoff.qaFindingsOmitted = handoff.qaFindings. length - 10 ; handoff.qaFindings = handoff.qaFindings. slice ( 0 , 10 ); cuts. push ( 'qaFindings.count' ); return true ; } return false ; },
113 ];
114 for ( const step of steps) {
115 if ( sizeOf (handoff) <= capBytes) break ;
116 step ();
117 }
118 if (cuts. length ) handoff.trimmed = { capBytes, cuts, fullSourcesNote: 'Cut items are still in their source files; read them with write-log-summary.js or the QA result file, never the raw ledger.' };
119 return handoff;
120 }
121
122 async function writeHandoff ( projectDir , options = {}) {
123 const stage = safeStage (options.stage);
124 const outcome = options.outcome || options.reason || 'done' ;
125 if ( ! OUTCOMES . has (outcome)) throw new Error ( `invalid outcome ${ outcome } (expected ${ [ ... OUTCOMES ]. join ( ', ' ) })` );
126 const capBytes = Number (options.capBytes) > 0 ? Number (options.capBytes) : DEFAULT_CAP_BYTES ;
127
128 const previous = listHandoffs (projectDir, stage);
129 const round = previous. length ? previous[previous. length - 1 ].round + 1 : 1 ;
130 const checkpoint = await checkpointOf (projectDir);
131
132 // Repeated budget rounds that moved no durable state are a thrash signal, never a stop.
133 let budgetRoundsWithoutProgress = 0 ;
134 if (outcome === 'budget_exceeded' ) {
135 budgetRoundsWithoutProgress = 1 ;
136 for ( let i = previous. length - 1 ; i >= 0 ; i -= 1 ) {
137 const earlier = readHandoff (projectDir, stage, previous[i].round);
138 if (earlier.outcome !== 'budget_exceeded' || ! sameCheckpoint (earlier.checkpoint, checkpoint)) break ;
139 budgetRoundsWithoutProgress += 1 ;
140 }
141 }
142
143 const ledger = await summarizeWriteLog (projectDir, { log: 'attempts' });
144 const writeLog = ledger.missing ? null : { attempts: ledger.attempts, runs: ledger.runs, totals: ledger.totals, byEntity: ledger.byEntity, topErrors: ledger.topErrors };
145
146 let pendingUserActions = [];
147 try {
148 pendingUserActions = ( await listUserActions (projectDir, { status: 'pending' })). map (( row ) => ({ requestId: row.requestId, kind: row.kind || row.type || null , title: row.title || null }));
149 } catch { pendingUserActions = [{ requestId: null , title: 'user-action index unreadable; run rp-user-action-recovery' }]; }
150
151 const handoff = {
152 schemaVersion: SCHEMA_VERSION ,
153 ... (options.workerId || process.env. CODEX_THREAD_ID ? { workerId: String (options.workerId || process.env. CODEX_THREAD_ID ) } : {}),
154 stage,
155 resource: options.resource || null ,
156 round,
157 outcome,
158 writtenAt: new Date (). toISOString (),
159 nextDispatch: 'fresh subagent; pass only resource, project dir and this file' ,
160 checkpoint,
161 budgetRoundsWithoutProgress,
162 qaFindings: readFindings (options.findingsFile),
163 changedFiles: (options.changedFiles || []). map (String),
164 pendingUserActions,
165 writeLog,
166 notes: options.notes ? String (options.notes) : null ,
167 };
168 fitToCap (handoff, capBytes);
169
170 const dir = handoffDir (projectDir);
171 fs. mkdirSync (dir, { recursive: true });
172 const file = path. join (dir, `${ stage }-${ round }.json` );
173 const tmp = `${ file }.tmp-${ process . pid }` ;
174 fs. writeFileSync (tmp, `${ JSON . stringify ( handoff , null , 2 ) } \n ` );
175 fs. renameSync (tmp, file);
176 return { file, handoff, bytes: fs. statSync (file).size };
177 }
178
179 module . exports = { writeHandoff, readHandoff, listHandoffs, checkpointOf, sameCheckpoint, DEFAULT_CAP_BYTES, OUTCOMES };