Setting the file. One moment.
Import Supervise · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page function liveClaim
— line 142
This file
Number 45.16
Position 16 of 182
Type JavaScript
Size 16 KB
Lines 311 scripts/ import-supervise.js
JavaScript · 311 lines · 16 KB
15 // 10 needs_decision -- a pending user action, the auth stop (75) or an awaiting-user exit (3)
16 // 11 resume_limit -- the same settled progress failed --max-resumes times in a row, or
17 // --max-launches launches happened in this call
18 // 1 supervisor error -- bad arguments or the supervisor itself failed
19 // 128+n -- the supervisor got signal n; it forwarded it to the import and waited
20 //
21 // It never starts a second import: a claim file records the child it launched, and a later call
22 // that finds that child still alive attaches to it (polls only) instead of spawning. Every relaunch
23 // is the same entrypoint with the same arguments, which resumes from the attempt journal and
24 // crosswalk exactly as a manual re-run would.
25
26 const fs = require ( 'node:fs' );
27 const os = require ( 'node:os' );
28 const path = require ( 'node:path' );
29 const { spawn , spawnSync } = require ( 'node:child_process' );
30 const { checkpointOf , sameCheckpoint } = require ( '../lib/subagent-handoff.js' );
31 const { listPendingBlockingUserActions } = require ( '../lib/user-actions.js' );
32 const { isProcessAlive } = require ( '../lib/local-state.js' );
33
34 const HEALTH_CHECK = path. join (__dirname, 'execution-health-check.js' );
35 const EXIT = { complete: 0 , needs_decision: 10 , resume_limit: 11 , error: 1 };
36 const AUTH_STOP_EXIT = 75 ; // wix-auth.js WIX_AUTH_EXIT_CODE
37 const AWAITING_USER_EXIT = 3 ;
38 const SIGNALS = { SIGHUP: 1 , SIGINT: 2 , SIGTERM: 15 };
39 const HEARTBEAT_RE = / \b (withHeartbeat | startHeartbeat) \b / ;
40
41 function usage () {
42 return `Usage:
43 node skills/wix-replatform/scripts/import-supervise.js --project <dir>
44 [--entry src/import/run-import.js] [--script <health-check script id>]
45 [--poll 3m] [--stale-after 5m] [--max-resumes 3] [--max-launches 10] [--backoff 30s]
46 [-- <entrypoint args>]
47
48 Runs the entrypoint (relative to --project) until it completes, needs a decision, or stops making
49 settled progress. If an import it launched earlier is still running, it attaches to that one.
50 Run it in the host's background mode; after a timed-out call, just run it again.
51 Prints one JSON line. Exit 0 complete, 10 needs_decision, 11 resume_limit, 1 error.
52 ` ;
53 }
54
55 const DURATION_RE = / ^ ( \d + (?: \. \d + ) ? )(ms | s | m | h) ?$ / i ;
56 const UNIT = { ms: 1 , s: 1000 , m: 60000 , h: 3600000 };
57 function durationMs ( value , label ) {
58 const match = DURATION_RE . exec ( String (value). trim ());
59 if ( ! match) throw new Error ( `invalid ${ label }: ${ value }` );
60 return Number (match[ 1 ]) * UNIT [(match[ 2 ] || 'ms' ). toLowerCase ()];
61 }
62
63 function parseArgs ( argv ) {
64 const args = { entry: 'src/import/run-import.js' , poll: '3m' , staleAfter: '5m' , maxResumes: '3' , maxLaunches: '10' , backoff: '30s' , passthrough: [] };
65 for ( let i = 0 ; i < argv. length ; i += 1 ) {
66 const token = argv[i];
67 if (token === '--' ) { args.passthrough = argv. slice (i + 1 ); break ; }
68 if (token === '--help' || token === '-h' ) { args.help = true ; continue ; }
69 if ( ! token. startsWith ( '--' )) throw new Error ( `unexpected argument: ${ token }` );
70 const key = token. slice ( 2 ). replace ( /-( [a-z] )/ g , ( _ , c ) => c. toUpperCase ());
71 const value = argv[i + 1 ];
72 if (value === undefined ) throw new Error ( `missing value for ${ token }` );
73 args[key] = value;
74 i += 1 ;
75 }
76 if ( ! args.script) args.script = path. basename (args.entry, '.js' );
77 return args;
78 }
79
80 function health ( projectDir , script , staleAfter ) {
81 const result = spawnSync (process.execPath, [ HEALTH_CHECK , projectDir, '--script' , script, '--stale-after' , staleAfter], { encoding: 'utf8' });
82 try { return JSON . parse (result.stdout); } catch { return { status: 'unknown' , detail: (result.stderr || '' ). trim (). slice ( 0 , 300 ) }; }
83 }
84
85 async function pendingActions ( projectDir ) {
86 try { return ( await listPendingBlockingUserActions (projectDir)). map (( request ) => request.requestId); } catch { return [ 'unreadable' ]; }
87 }
88
89 // Without heartbeats a long healthy loop looks `stalled`; killing it would cut it mid-write every
90 // few minutes. Generated entrypoints are thin wrappers and the write loops (with their heartbeats)
91 // live elsewhere under src/, so look through the entrypoint's directory and the project's src/
92 // tree once, before the first launch.
93 const HEARTBEAT_SCAN_MAX_FILES = 2000 ;
94 function hasHeartbeat ( projectDir , entryPath ) {
95 const roots = [path. dirname (entryPath), path. join (projectDir, 'src' )];
96 const seen = new Set ();
97 const stack = [ ... roots];
98 let scanned = 0 ;
99 const check = ( file ) => { try { return HEARTBEAT_RE . test (fs. readFileSync (file, 'utf8' )); } catch { return false ; } };
100 if ( check (entryPath)) return true ;
101 while (stack. length && scanned < HEARTBEAT_SCAN_MAX_FILES ) {
102 const dir = stack. pop ();
103 if (seen. has (dir)) continue ;
104 seen. add (dir);
105 let entries = [];
106 try { entries = fs. readdirSync (dir, { withFileTypes: true }); } catch { continue ; }
107 for ( const entry of entries) {
108 if (entry.name === 'node_modules' || entry.name. startsWith ( '.' )) continue ;
109 const full = path. join (dir, entry.name);
110 if (entry. isDirectory ()) stack. push (full);
111 else if ( / \. (c | m) ? js $ / . test (entry.name)) {
112 scanned += 1 ;
113 if ( check (full)) return true ;
114 }
115 }
116 }
117 return false ;
118 }
119
120 // The claim says which child this supervisor launched, so a later call can attach to it.
121 function claimPath ( projectDir , script ) {
122 return path. join (projectDir, 'state' , 'supervise' , `${ script . replace ( / [ ^ A-Za-z0-9_.-] / g , '_' ) }.json` );
123 }
124 function readClaim ( projectDir , script ) {
125 try { return JSON . parse (fs. readFileSync ( claimPath (projectDir, script), 'utf8' )); } catch { return null ; }
126 }
127 function writeClaim ( projectDir , script , claim ) {
128 const file = claimPath (projectDir, script);
129 fs. mkdirSync (path. dirname (file), { recursive: true });
130 fs. writeFileSync ( `${ file }.tmp` , JSON . stringify (claim));
131 fs. renameSync ( `${ file }.tmp` , file);
132 }
133 // A recorded pid can be reused after a reboot. Where /proc exists, the process must still be
134 // running our entrypoint; elsewhere a live pid on this host is the best evidence available.
135 function runsEntry ( pid , entry ) {
136 try {
137 return fs. readFileSync ( `/proc/${ pid }/cmdline` , 'utf8' ). split ( ' \0 ' ). some (( arg ) => arg. endsWith (entry));
138 } catch (error) {
139 return ! (error && error.code === 'ENOENT' && fs. existsSync ( '/proc/self' ));
140 }
141 }
142 function liveClaim ( projectDir , script ) {
143 const claim = readClaim (projectDir, script);
144 // pid must be positive: process.kill(0|-n, ...) signals a whole process group, never one import.
145 return claim && claim.host === os. hostname () && Number. isInteger (claim.pid) && claim.pid > 0 && isProcessAlive (claim.pid)
146 && runsEntry (claim.pid, claim.entry || '' ) ? claim : null ;
147 }
148
149 const sleep = ( ms ) => new Promise (( resolve ) => { setTimeout (resolve, ms); });
150
151 let activeChild = null ;
152 // The pid of an import this supervisor attached to (it did not spawn it, so there is no child handle).
153 let attachedPid = null ;
154 let stopSignal = null ;
155
156 // Without heartbeats a `stalled` verdict is not trusted, but a run that has looked stalled for this
157 // many stale windows in a row is treated as hung: stopped, and returned to an agent.
158 const NO_HEARTBEAT_STALL_FACTOR = 6 ;
159
160 function runOnce ({ projectDir , entry , passthrough , pollMs , script , staleAfter , killOnStall }) {
161 const hungAfterMs = durationMs (staleAfter, '--stale-after' ) * NO_HEARTBEAT_STALL_FACTOR ;
162 return new Promise (( resolve ) => {
163 // The entrypoint's own output goes to a log file, never into the agent's context.
164 fs. mkdirSync (path. join (projectDir, 'logs' ), { recursive: true });
165 const log = fs. openSync (path. join (projectDir, 'logs' , 'import-supervise.log' ), 'a' );
166 const child = spawn (process.execPath, [path. join (projectDir, entry), ... passthrough], { cwd: projectDir, stdio: [ 'ignore' , log, 'pipe' ] });
167 activeChild = child;
168 if (child.pid) writeClaim (projectDir, script, { pid: child.pid, host: os. hostname (), supervisorPid: process.pid, entry, startedAt: new Date (). toISOString () });
169 let stderrTail = '' ;
170 child.stderr. on ( 'data' , ( chunk ) => { try { fs. writeSync (log, chunk); } catch { /* log closed */ } stderrTail = `${ stderrTail }${ chunk }` . slice ( - 2000 ); });
171 let killedForStall = false ;
172 let stallSeenWithoutHeartbeat = false ;
173 let hungWithoutHeartbeat = false ;
174 let stalledSince = null ;
175 let lastHealth = null ;
176 const stop = () => {
177 child. kill ( 'SIGTERM' );
178 setTimeout (() => { if (child.exitCode === null ) child. kill ( 'SIGKILL' ); }, 10000 ). unref ();
179 };
180 const timer = setInterval (() => {
181 lastHealth = health (projectDir, script, staleAfter);
182 if (lastHealth.status !== 'stalled' || child.exitCode !== null ) { stalledSince = null ; return ; }
183 if (killOnStall) { killedForStall = true ; stop (); return ; }
184 stallSeenWithoutHeartbeat = true ;
185 stalledSince = stalledSince || Date. now ();
186 if (Date. now () - stalledSince >= hungAfterMs && ! hungWithoutHeartbeat) { hungWithoutHeartbeat = true ; stop (); }
187 }, pollMs);
188 const done = ( result ) => {
189 clearInterval (timer);
190 try { fs. closeSync (log); } catch { /* already closed */ }
191 activeChild = null ;
192 resolve ({ ... result, killedForStall, stallSeenWithoutHeartbeat, hungWithoutHeartbeat, lastHealth, stderrTail: stderrTail. trim (). split ( / \r ? \n / ). slice ( - 3 ). join ( ' | ' ). slice ( 0 , 400 ) });
193 };
194 child. on ( 'close' , ( code , signal ) => done ({ code, signal }));
195 child. on ( 'error' , ( error ) => { stderrTail = String (error.message); done ({ code: null , signal: null }); });
196 });
197 }
198
199 // Attach to a child an earlier supervisor call launched and that is still alive: poll only.
200 async function attach ({ projectDir , claim , pollMs , script , staleAfter , killOnStall }) {
201 const hungAfterMs = durationMs (staleAfter, '--stale-after' ) * NO_HEARTBEAT_STALL_FACTOR ;
202 let killedForStall = false ;
203 let hungWithoutHeartbeat = false ;
204 let stalledSince = null ;
205 attachedPid = claim.pid;
206 while ( isProcessAlive (claim.pid)) {
207 if (stopSignal) { attachedPid = null ; return { code: null , signal: stopSignal, attached: true }; }
208 const verdict = health (projectDir, script, staleAfter);
209 if (verdict.status !== 'stalled' ) stalledSince = null ;
210 else if (killOnStall && ! killedForStall) {
211 killedForStall = true ;
212 try { process. kill (claim.pid, 'SIGTERM' ); } catch { /* already gone */ }
213 } else if ( ! killOnStall) {
214 stalledSince = stalledSince || Date. now ();
215 if (Date. now () - stalledSince >= hungAfterMs && ! hungWithoutHeartbeat) {
216 hungWithoutHeartbeat = true ;
217 try { process. kill (claim.pid, 'SIGTERM' ); } catch { /* already gone */ }
218 }
219 }
220 await sleep (Math. min (pollMs, 5000 ));
221 }
222 attachedPid = null ;
223 return { code: null , signal: null , attached: true , killedForStall, hungWithoutHeartbeat, stderrTail: '' };
224 }
225
226 async function supervise ( args ) {
227 const projectDir = path. resolve (args.project);
228 const entryPath = path. join (projectDir, args.entry);
229 if ( ! fs. existsSync (entryPath)) throw new Error ( `entrypoint not found: ${ args . entry }` );
230 const pollMs = durationMs (args.poll, '--poll' );
231 const backoffMs = durationMs (args.backoff, '--backoff' );
232 const maxResumes = Math. max ( 1 , Number (args.maxResumes) || 3 );
233 const maxLaunches = Math. max ( 1 , Number (args.maxLaunches) || 10 );
234 const killOnStall = hasHeartbeat (projectDir, entryPath);
235 const started = Date. now ();
236 let launches = 0 ;
237 let attachedRuns = 0 ;
238 let sameCheckpointFailures = 0 ;
239 let lastFailureCheckpoint = null ;
240 const extra = killOnStall ? {} : { heartbeatMissing: true };
241
242 for (;;) {
243 if (stopSignal) return { outcome: 'stopped' , signal: stopSignal, launches, attachedRuns, ... extra };
244 let run;
245 const claim = launches === 0 && attachedRuns === 0 ? liveClaim (projectDir, args.script) : null ;
246 if (claim) {
247 attachedRuns += 1 ;
248 run = await attach ({ projectDir, claim, pollMs, script: args.script, staleAfter: args.staleAfter, killOnStall });
249 } else {
250 if (launches >= maxLaunches) {
251 return { outcome: 'resume_limit' , launches, attachedRuns, elapsedMs: Date. now () - started, reason: 'launch_cap' , maxLaunches, ... extra };
252 }
253 launches += 1 ;
254 run = await runOnce ({ projectDir, entry: args.entry, passthrough: args.passthrough, pollMs, script: args.script, staleAfter: args.staleAfter, killOnStall });
255 }
256 if (stopSignal) return { outcome: 'stopped' , signal: stopSignal, launches, attachedRuns, ... extra };
257 const base = { launches, attachedRuns, elapsedMs: Date. now () - started, lastExit: run.code, lastSignal: run.signal, ... extra };
258 const pending = await pendingActions (projectDir);
259 if (run.hungWithoutHeartbeat) {
260 return { outcome: 'resume_limit' , ... base, reason: 'stalled_without_heartbeat' , lastError: run.stderrTail || null };
261 }
262
263 if ((run.code === 0 || run.attached) && ! pending. length ) {
264 const verdict = health (projectDir, args.script, args.staleAfter);
265 if (verdict.status === 'complete' ) return { outcome: 'complete' , ... base, runId: verdict.runId || null };
266 }
267 if (pending. length || run.code === AUTH_STOP_EXIT || run.code === AWAITING_USER_EXIT ) {
268 return { outcome: 'needs_decision' , ... base, reason: run.code === AUTH_STOP_EXIT ? 'auth_stop' : (pending. length ? 'pending_user_action' : 'awaiting_user_action' ), userActionIds: pending };
269 }
270
271 // Only `imported` records count as progress. A resume that advances only
272 // `succeeded_unverified` or `deferred` rows can reach the limit; that is intended -- it goes to
273 // a fresh Developer, which is the right reader for a run that keeps ending unsettled.
274 const checkpoint = await checkpointOf (projectDir);
275 if (lastFailureCheckpoint && sameCheckpoint (lastFailureCheckpoint, checkpoint)) sameCheckpointFailures += 1 ;
276 else sameCheckpointFailures = 1 ;
277 lastFailureCheckpoint = checkpoint;
278 const errorClass = run.killedForStall ? 'stalled' : (run.attached ? 'attached_run_ended_not_complete' : (run.code === 0 ? 'exited_0_not_complete' : `exit_${ run . code === null ? run . signal || 'spawn_error' : run . code }` ));
279 if (sameCheckpointFailures >= maxResumes) {
280 return { outcome: 'resume_limit' , ... base, reason: errorClass, sameCheckpointFailures, lastError: run.stderrTail || null , checkpoint };
281 }
282 process.stderr. write ( `import-supervise: ${ errorClass }; resuming (${ sameCheckpointFailures }/${ maxResumes } without new settled progress) \n ` );
283 await sleep (backoffMs);
284 if (stopSignal) return { outcome: 'stopped' , signal: stopSignal, launches, attachedRuns, ... extra };
285 }
286 }
287
288 async function main () {
289 const args = parseArgs (process.argv. slice ( 2 ));
290 if (args.help) { process.stdout. write ( usage ()); return ; }
291 if ( ! args.project) throw new Error ( `--project is required \n ${ usage () }` );
292 // A host that stops the tool call stops this process. Forward the signal so the import stops
293 // with it (and resumes on the next call) instead of running on unsupervised.
294 for ( const name of Object. keys ( SIGNALS )) {
295 process. on (name, () => {
296 stopSignal = name;
297 if (activeChild && activeChild.exitCode === null ) activeChild. kill (name);
298 // An attached import has no child handle; stopping the supervisor must stop it too, or it runs on
299 // unsupervised (the next call resumes it).
300 if (attachedPid > 0 ) { try { process. kill (attachedPid, name); } catch { /* already gone */ } }
301 });
302 }
303 const result = await supervise (args);
304 process.stdout. write ( `${ JSON . stringify ({ ok: result.outcome !== 'stopped' , ... result }) } \n ` );
305 process.exitCode = result.outcome === 'stopped' ? 128 + SIGNALS [result.signal] : EXIT [result.outcome];
306 }
307
308 main (). catch (( error ) => {
309 process.stdout. write ( `${ JSON . stringify ({ ok: false , outcome: 'error' , error: String (error && error.message ? error.message : error). slice ( 0 , 400 ) }) } \n ` );
310 process. exit ( EXIT .error);
311 });