Setting the file. One moment.
Run Controller · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page async function appendLine
— line 45
This file
Number 45.139
Position 139 of 182
Type JavaScript
Size 32 KB
Lines 508 lib/ run-controller.js
JavaScript · 508 lines · 32 KB
,
writeJsonAtomic
,
updateDecisionObject
,
transitionRun
}
=
require
(
'./orchestration-state.js'
);
9 const { determineNextStep } = require ( './orchestration-router.js' );
10 const { validateMigrationCompletion , loadMigrationCompletionInputs } = require ( './migration-completion.js' );
11 const { pendingUserActionGate } = require ( './user-actions.js' );
12 const { applySiteTypePair , resolveSiteTypePair } = require ( './destination-choice.js' );
13 const { normalizeTargetSiteStrategy } = require ( './orchestration-decisions.js' );
14 const { readEnvFile , upsertEnvFile } = require ( './config-env.js' );
15 const { chooseAdapter , launch , reconcileWorkers , requestCancel } = require ( './agent-adapters/index.js' );
16 const { readTaskState , writeTaskState , createTaskState , selectRoute , startRoute , waitForInput , startDirectAgent , finishDirectAgent , finishWorkers , recordWorker , finishCurrent , reconcileTaskState } = require ( './task-state.js' );
17
18 const SCHEMA_VERSION = 1 ;
19 const RUNNER_DIR = [ 'state' , 'runner' ];
20
21 const iso = () => new Date (). toISOString ();
22 const runId = () => `rpr_${ crypto . randomUUID () }` ;
23 const runnerDir = ( projectDir ) => path. join (projectDir, ... RUNNER_DIR );
24 const activePath = ( projectDir ) => path. join ( runnerDir (projectDir), 'active.json' );
25 const historyPath = ( projectDir , id ) => path. join ( runnerDir (projectDir), 'runs' , `${ id }.json` );
26 const defaultEventsPath = ( projectDir ) => path. join ( runnerDir (projectDir), 'events.ndjson' );
27 const defaultVendorEventsPath = ( projectDir ) => path. join ( runnerDir (projectDir), 'vendor-events.ndjson' );
28 const cancelPath = ( projectDir ) => path. join ( runnerDir (projectDir), 'cancel-request.json' );
29
30 async function readJson ( filePath ) {
31 try { return JSON . parse ( await fs. readFile (filePath, 'utf8' )); } catch (error) { if (error.code === 'ENOENT' ) return null ; throw error; }
32 }
33
34 async function ensureInitialArtifacts ( projectDir ) {
35 const artifacts = await loadArtifacts (projectDir);
36 if (artifacts.run) return artifacts;
37 return initArtifacts (projectDir);
38 }
39
40 function safeRelative ( projectDir , item ) {
41 const relative = path. relative (projectDir, item);
42 return relative && ! relative. startsWith ( '..' ) && ! path. isAbsolute (relative) ? relative. replaceAll (path.sep, '/' ) : null ;
43 }
44
45 async function appendLine ( filePath , value ) {
46 await fs. mkdir (path. dirname (filePath), { recursive: true });
47 await fs. appendFile (filePath, `${ JSON . stringify ( value ) } \n ` , 'utf8' );
48 }
49
50 async function findRun ( rootDir , id ) {
51 if ( ! / ^ rpr_ [0-9a-f-] +$ / . test (id)) return null ;
52 // Projects normally sit one level beneath the migrations root. A bounded recursive
53 // lookup also keeps existing runs observable after a project was nested accidentally.
54 const pending = [{ directory: rootDir, depth: 0 }];
55 while (pending. length ) {
56 const { directory , depth } = pending. pop ();
57 const direct = await readJson ( historyPath (directory, id));
58 if (direct) return direct;
59 if (depth >= 3 ) continue ;
60 let entries;
61 try { entries = await fs. readdir (directory, { withFileTypes: true }); } catch { continue ; }
62 for ( const entry of entries) {
63 if (entry. isDirectory () && ! [ 'state' , 'orchestration' , 'completion' , 'execution' , 'website' ]. includes (entry.name)) {
64 pending. push ({ directory: path. join (directory, entry.name), depth: depth + 1 });
65 }
66 }
67 }
68 return null ;
69 }
70
71 async function readActive ( projectDir ) { return readJson ( activePath (projectDir)); }
72
73 async function applyInvocationInputs ( projectDir , inputs ) {
74 const artifacts = await ensureInitialArtifacts (projectDir);
75 const decisions = artifacts.decisions;
76 // `--destination existing:<site-id>` is a strategy plus the site to adopt; only the strategy is a decision.
77 const existingDestination = typeof inputs.destination === 'string' ? / ^ existing:( . + ) $ / . exec (inputs.destination) : null ;
78 const destinationStrategy = existingDestination ? 'existing' : inputs.destination;
79 const mappings = {
80 sourceUrl: inputs.sourceUrl,
81 sourceMode: inputs.sourceMode,
82 fileInputPaths: inputs.fileInputPaths,
83 deliveryMode: inputs.deliveryMode,
84 targetSiteStrategy: destinationStrategy === undefined ? undefined : normalizeTargetSiteStrategy (destinationStrategy),
85 managementImportMode: inputs.managementImportMode,
86 notificationMute: inputs.notificationMute,
87 ... (inputs.oneClick ? { automationMode: 'one_click' } : {}),
88 ... (inputs.dryRun ? { dryRun: true } : {}),
89 };
90 let changed = false ;
91 for ( const [ key , value ] of Object. entries (mappings)) {
92 if (value === undefined ) continue ;
93 const existing = decisions[key] && decisions[key].value;
94 if (existing !== undefined && JSON . stringify (existing) !== JSON . stringify (value)) {
95 throw new Error ( `CLI input conflicts with existing user decision: ${ key }` );
96 }
97 if (existing === undefined ) { updateDecisionObject (decisions, key, value, { source: 'user' }); changed = true ; }
98 }
99 // The site-type pair lands in the same single decisions write. A Harmony
100 // flag with no business type records `unknown` (source not_asked) and raises no request.
101 if (inputs.siteType !== undefined ) {
102 const pair = resolveSiteTypePair ({
103 siteType: inputs.siteType,
104 businessType: inputs.siteType === 'harmony' ? (inputs.siteBusinessType || 'unknown' ) : inputs.siteBusinessType,
105 businessTypeSource: inputs.siteType === 'harmony' && ! inputs.siteBusinessType ? 'not_asked' : 'user' ,
106 });
107 try {
108 if ( applySiteTypePair (decisions, pair, { rationale: 'replatform CLI --site-type' }).changed) changed = true ;
109 } catch (error) {
110 throw new Error ( `CLI input conflicts with existing user decision: targetSiteType (${ error . message })` );
111 }
112 } else if (inputs.siteBusinessType !== undefined ) {
113 throw new Error ( 'site-business-type is valid only with --site-type harmony' );
114 }
115 if (existingDestination) {
116 const wixEnvPath = path. join (projectDir, 'config' , 'wix.env' );
117 let current = {};
118 try { current = await readEnvFile (wixEnvPath); } catch (error) { if ( ! error || error.code !== 'ENOENT' ) throw error; }
119 const recorded = String (current. WIX_SITE_ID || '' ). trim ();
120 if (recorded && recorded !== existingDestination[ 1 ]) throw new Error ( 'CLI input conflicts with existing user decision: WIX_SITE_ID' );
121 await upsertEnvFile (wixEnvPath, { WIX_SITE_STRATEGY: 'existing' , WIX_SITE_ID: existingDestination[ 1 ], ... (inputs.notificationMute ? { WIX_MUTE_NOTIFICATIONS: inputs.notificationMute } : {}) });
122 }
123 if (changed) await writeJsonAtomic (path. join (projectDir, 'orchestration' , 'decisions.json' ), decisions);
124 return artifacts;
125 }
126
127 async function resumeStaleTelemetry ( projectDir ) {
128 try {
129 const { resumeIfStale } = require ( '../resources/rp-telemetry/lib/telemetry-recorder.js' );
130 return await resumeIfStale (projectDir, { trigger: 'supervisor' });
131 } catch (error) {
132 return { resumed: false , reason: 'error' , trigger: 'supervisor' , error: String ((error && error.message) || error) };
133 }
134 }
135
136 function bootstrap ( projectDir ) {
137 // Only this already-validated absolute path is variable. Intake values stay in artifacts.
138 return `Use the installed wix-replatform skill for the migration project at ${ projectDir }. Read durable orchestration artifacts and the latest accepted CLI invocation, route, then perform only the selected stage. Do not claim completion from prose. When human input or review is required, create a durable user action and end this turn; never use an interactive question UI or wait for terminal input.` ;
139 }
140
141 // A pending action is intentionally included in the normalized stream. A consumer must be
142 // able to render and answer a checkpoint from JSONL alone, without first reading a project file.
143 // Keep this to operator-facing fields; do not expose effects or a later answer here.
144 function userActionSummary ( request ) {
145 return {
146 requestId: request.requestId,
147 kind: request.kind,
148 stage: request.stage,
149 title: request.title,
150 prompt: request.prompt,
151 response: request.response,
152 choices: request.choices,
153 artifactRefs: request.artifactRefs,
154 };
155 }
156
157 function makeResult ({ id , projectDir , outcome , adapter = null , route = null , completion = null , nextAction = null , request = null }) {
158 return {
159 schemaVersion: SCHEMA_VERSION ,
160 runId: id,
161 project: path. basename (projectDir),
162 outcome,
163 agent: adapter ? { id: adapter.id, version: adapter.version || null , sessionId: null , selection: adapter.selection || null } : null ,
164 router: route ? { state: route.nextState, reason: route.reason, nextResource: route.nextResource || null ,
165 ... (route.reason === 'run_blocked' ? { resumeFrom: route.resumeFrom, resumeWith: route.resumeWith } : {}),
166 } : null ,
167 completion: { validated: Boolean (completion && completion.ok), artifact: completion && completion.ok ? 'completion/migration-completion.json' : null },
168 artifacts: request ? request.artifactRefs. map (( ref ) => ref.path) : route?.reason === 'run_blocked' ? [ 'orchestration/run.json' ] : [],
169 ... (request ? {
170 requestId: request.requestId,
171 kind: request.kind,
172 choices: request.choices,
173 userAction: userActionSummary (request),
174 } : {}),
175 ... (route?.blocker ? {blocker:route.blocker} : {}),
176 nextAction,
177 };
178 }
179
180 function exitCode ( result ) {
181 return ({ completed: 0 , waiting_for_user: 3 , blocked: 4 , canceled: 5 , interrupted: 5 , timed_out: 6 })[result.outcome] ?? 1 ;
182 }
183
184 class RunController {
185 constructor ( projectDir , options = {}) {
186 this .projectDir = path. resolve (projectDir);
187 this .options = { agent: 'auto' , maxAgentTurns: 12 , maxNoProgressContinuations: 1 , gracePeriodMs: 60000 , workerStartupTimeoutMs: 120000 , ... options };
188 this .id = options.runId || runId ();
189 this .eventFiles = new Set ([options.eventsFile || defaultEventsPath ( this .projectDir)]);
190 this .vendorEventsPath = options.vendorEvents || defaultVendorEventsPath ( this .projectDir);
191 this .lastObservationAt = Date. now ();
192 this .child = null ;
193 this .adapter = null ;
194 this .taskState = null ;
195 this .closed = false ;
196 }
197
198 async emit ( type , state , message , extra = {}) {
199 const event = { schemaVersion: SCHEMA_VERSION , eventId: `evt_${ crypto . randomUUID () }` , timestamp: iso (), runId: this .id, type, state, message, ... extra };
200 for ( const filePath of this .eventFiles) await appendLine (filePath, event);
201 this .options. onEvent ?.(event);
202 return event;
203 }
204
205 async writeRecord ( patch = {}) {
206 // `startedAt` and `telemetryResume` are carried on the controller and included in every
207 // write, because the record is rebuilt from scratch each time. `startedAt` in active.json is
208 // what an explicit telemetry `start` inside this run reads to join the session this run
209 // opened (one supervisor process is one telemetry session).
210 const record = { schemaVersion: SCHEMA_VERSION , runId: this .id, projectDir: this .projectDir, pid: process.pid, childPid: this .child?.pid || null , adapter: this .adapter ? { id: this .adapter.id, version: this .adapter.version || null , selection: this .adapter.selection || null } : null , eventPath: [ ... this .eventFiles][ 0 ], status: 'running' , updatedAt: iso (), ... ( this .startedAt ? { startedAt: this .startedAt } : {}), ... ( this .telemetryResume ? { telemetryResume: this .telemetryResume } : {}), ... patch };
211 await fs. mkdir (path. dirname ( historyPath ( this .projectDir, this .id)), { recursive: true });
212 await writeJsonAtomic ( historyPath ( this .projectDir, this .id), record);
213 if ( ! this .closed) await writeJsonAtomic ( activePath ( this .projectDir), record);
214 return record;
215 }
216
217 async updateTasks ( change ) {
218 if ( ! this .taskState) return null ;
219 change ( this .taskState);
220 this .taskState = await writeTaskState ( this .projectDir, this .taskState);
221 return this .taskState;
222 }
223
224 async recordInvocation () {
225 const filePath = path. join ( this .projectDir, 'orchestration' , 'cli-invocations.ndjson' );
226 const safe = [ 'agent' , 'sourceUrl' , 'sourceMode' , 'fileInputPaths' , 'deliveryMode' , 'destination' , 'managementImportMode' , 'siteType' , 'siteBusinessType' , 'notificationMute' , 'oneClick' , 'dryRun' ]. reduce (( out , key ) => {
227 if ( this .options[key] !== undefined ) out[key] = this .options[key]; return out;
228 }, {});
229 await appendLine (filePath, { schemaVersion: SCHEMA_VERSION , runId: this .id, acceptedAt: iso (), options: safe });
230 }
231
232 async cancellationRequested () { return readJson ( cancelPath ( this .projectDir)); }
233
234 async setOrchestrationCancellation ( status ) {
235 const artifacts = await loadArtifacts ( this .projectDir);
236 if ( ! artifacts.run) return ;
237 const run = transitionRun (artifacts.run, { state: status, activePhase: 'orchestration' , status, needsUserInput: false , needsLlm: false });
238 await writeJsonAtomic (path. join ( this .projectDir, 'orchestration' , 'run.json' ), run);
239 }
240
241 async finish ( result ) {
242 if ( this .closed) return result;
243 this .closed = true ;
244 if ( ! result.agent && this .adapter) {
245 result = { ... result, agent: { id: this .adapter.id || null , version: this .adapter.version || null , sessionId: null , selection: this .adapter.selection || null } };
246 }
247 await this . updateTasks (( state ) => finishCurrent (state, result.outcome === 'waiting_for_user' ? 'waiting_for_input' : result.outcome === 'timed_out' ? 'interrupted' : result.outcome));
248 await this . emit ( 'result' , result.outcome, 'Run result.' , { result });
249 await this . writeRecord ({ status: result.outcome, result, endedAt: iso () });
250 try { await fs. unlink ( activePath ( this .projectDir)); } catch (error) { if (error.code !== 'ENOENT' ) throw error; }
251 return result;
252 }
253
254 async waitForChild () {
255 return new Promise (( resolve ) => this .child. once ( 'close' , ( code , signal ) => resolve ({ code, signal })));
256 }
257
258 async run () {
259 await fs. mkdir ( this .projectDir, { recursive: true });
260 const alreadyActive = await readActive ( this .projectDir);
261 if (alreadyActive && alreadyActive.runId !== this .id && alreadyActive.status === 'running' ) {
262 throw new Error ( `project already has an active run: ${ alreadyActive . runId }` );
263 }
264 await applyInvocationInputs ( this .projectDir, this .options);
265 const existing = await loadArtifacts ( this .projectDir);
266 const resumeHold = this .options.resume && this .options.resumeBlocked !== false ;
267 if ( this .options.resume && (existing.run?.status !== 'blocked' || resumeHold) && [ 'canceled' , 'interrupted' , 'cancel_requested' , 'blocked' ]. includes (existing.run?.status)) {
268 const resumed = transitionRun (existing.run, { state: 'initialized' , activePhase: 'orchestration' , status: 'running' , needsUserInput: false , needsLlm: false });
269 await writeJsonAtomic (path. join ( this .projectDir, 'orchestration' , 'run.json' ), resumed);
270 try { await fs. unlink ( cancelPath ( this .projectDir)); } catch (error) { if (error.code !== 'ENOENT' ) throw error; }
271 }
272 // Once per supervisor process, before the first loop iteration: a supervisor start is a new
273 // execution, so an open telemetry journal records a new session (or joins one opened in the
274 // last minute). Never from the loop, the router or `status`. Fail-soft and silent: the
275 // result lives only in this run's record, never in the event stream or on screen.
276 this .startedAt = iso ();
277 this .telemetryResume = existing.run?.status === 'blocked' && ! resumeHold
278 ? { resumed: false , reason: 'run_blocked' , trigger: 'supervisor' }
279 : await resumeStaleTelemetry ( this .projectDir);
280 await this . recordInvocation ();
281 await this . writeRecord ();
282 const persistedTaskState = await readTaskState ( this .projectDir);
283 this .taskState = persistedTaskState?.runId === this .id ? await reconcileTaskState ( this .projectDir, persistedTaskState) : createTaskState ( this .id);
284 await writeTaskState ( this .projectDir, this .taskState);
285 await this . emit ( 'started' , 'running' , 'Supervisor started.' , { evidence: { kind: 'process' , ref: String (process.pid) } });
286 const started = Date. now ();
287 this .startedAtMs = started;
288 let turns = 0 ;
289 let sameRoute = 0 ;
290 let priorRoute = null ;
291 const workerStartupRetries = new Map ();
292 while (turns <= this .options.maxAgentTurns) {
293 const cancellation = await this . cancellationRequested ();
294 if (cancellation) return this . cancel (cancellation);
295 if ( this .options.wallTimeoutMs && Date. now () - started > this .options.wallTimeoutMs) {
296 return this . finish ( makeResult ({ id: this .id, projectDir: this .projectDir, outcome: 'timed_out' , nextAction: 'Resume with replatform resume <project> after investigating the timeout.' }));
297 }
298 const artifacts = await loadArtifacts ( this .projectDir);
299 // An explicit operator hold precedes pending requests and stale-request recovery.
300 const actionGate = artifacts.run?.status === 'blocked' ? null : await pendingUserActionGate ( this .projectDir);
301 if (actionGate?.request) {
302 const request = actionGate.request;
303 await this . updateTasks (( state ) => waitForInput (state, request));
304 const route = { nextState: 'awaiting_user_action' , reason: 'pending_user_action' , nextResource: null };
305 await this . emit ( 'user_action_requested' , 'waiting_for_user' , request.prompt, {
306 requestId: request.requestId,
307 title: request.title,
308 userAction: userActionSummary (request),
309 evidence: { kind: 'user_action' , ref: `orchestration/user-actions/${ request . requestId }.json` },
310 });
311 await this . emit ( 'waiting_for_user' , 'waiting_for_user' , 'Waiting for a durable user action answer.' , { requestId: request.requestId });
312 const response = request.response.mode === 'choice'
313 ? `--choice <${ request . choices . map (( choice ) => choice . id ). join ( '|' ) }>`
314 : request.response.mode === 'text' ? '--text "<answer>"' : '--acknowledge' ;
315 return this . finish ( makeResult ({ id: this .id, projectDir: this .projectDir, outcome: 'waiting_for_user' , route, request, nextAction: `replatform answer --project ${ this . projectDir } --request-id ${ request . requestId } ${ response } --continue` }));
316 }
317 const route = await determineNextStep ( this .projectDir, artifacts);
318 await this . updateTasks (( state ) => selectRoute (state, route));
319 await this . emit ( 'routed' , 'running' , route.reason, { evidence: { kind: 'router' , ref: route.nextResource || route.reason }, route });
320 if ( ! route.ok) return this . finish ( makeResult ({ id: this .id, projectDir: this .projectDir, outcome: 'failed' , route, nextAction: 'Inspect orchestration artifacts and repair the reported validation error.' }));
321 // The existing-site mute question is a durable request, not an agent turn: the next
322 // pass finds it pending and waits for `replatform answer`.
323 if (route.reason === 'missing_notification_mute_decision' ) {
324 await require ( './notification-mute.js' ). ensureNotificationMuteRequest ( this .projectDir);
325 continue ;
326 }
327 if (route.nextState === 'completed' ) {
328 const completionInputs = await loadMigrationCompletionInputs ( this .projectDir);
329 const completion = validateMigrationCompletion (completionInputs.completion, completionInputs);
330 if (completion.ok) return this . finish ( makeResult ({ id: this .id, projectDir: this .projectDir, outcome: 'completed' , route, completion }));
331 }
332 if ([ 'blocked' , 'canceled' , 'interrupted' , 'cancel_requested' ]. includes (route.nextState)) {
333 const outcome = route.nextState === 'cancel_requested' ? 'interrupted' : route.nextState;
334 return this . finish ( makeResult ({ id: this .id, projectDir: this .projectDir, outcome, route, nextAction: route.reason === 'run_blocked' ? route.blocker.requiredInput : route.nextState === 'blocked' ? 'Inspect the route reason and satisfy the named prerequisite.' : 'Use replatform resume <project> when ready.' }));
335 }
336 const routeKey = `${ route . nextState }:${ route . reason }:${ route . nextResource || ''}` ;
337 sameRoute = routeKey === priorRoute ? sameRoute + 1 : 0 ;
338 priorRoute = routeKey;
339 if (sameRoute > this .options.maxNoProgressContinuations) {
340 return this . finish ( makeResult ({ id: this .id, projectDir: this .projectDir, outcome: 'blocked' , route, nextAction: 'The selected stage made no durable progress; inspect its artifacts before resuming.' }));
341 }
342 if (turns >= this .options.maxAgentTurns) return this . finish ( makeResult ({ id: this .id, projectDir: this .projectDir, outcome: 'timed_out' , route, nextAction: 'Maximum agent turns reached; resume after inspecting artifacts.' }));
343 if ( ! this .adapter) {
344 this .adapter = chooseAdapter ( this .options.agent, { agentPath: this .options.agentPath });
345 if ( ! this .adapter.ok) return this . finish ( makeResult ({ id: this .id, projectDir: this .projectDir, outcome: 'failed' , route, nextAction: 'Run replatform doctor and authenticate an installed supported agent.' }));
346 }
347 turns += 1 ;
348 let directAgentId = null ;
349 const pendingWorkers = new Map ();
350 const activeWorkers = new Map ();
351 let workerReports = Promise . resolve ();
352 const queueWorkerReport = ( report ) => {
353 workerReports = workerReports. then ( async () => {
354 let accepted = false ;
355 await this . updateTasks (( state ) => { const task = state.tasks. find (( item ) => item.state === 'running' ); accepted = Boolean (task) && recordWorker (state, task.taskId, this .adapter.id, report); });
356 if ( ! accepted) await this . emit ( 'protocol_error' , 'running' , 'Ignored malformed agent worker report.' , { evidence: { kind: 'adapter_protocol' , ref: this .adapter.id } });
357 });
358 };
359 // Mark the route running before launch: a fast adapter can report a child during
360 // launch itself, and that report must not race the durable task creation below.
361 await this . updateTasks (( state ) => startRoute (state, route));
362 this .child = launch ( this .adapter.id, { cwd: this .projectDir, executablePath: this .options.agentPath, prompt: bootstrap ( this .projectDir), onWorker : ( worker ) => {
363 const reports = Array. isArray (worker) ? worker : [worker];
364 for ( const report of reports) {
365 if (report.state === 'starting' ) pendingWorkers. set (report.workerId, { ... report, firstSeenAt: pendingWorkers. get (report.workerId)?.firstSeenAt || Date. now () });
366 else {
367 const firstSeenAt = pendingWorkers. get (report.workerId)?.firstSeenAt || activeWorkers. get (report.workerId)?.firstSeenAt || Date. now ();
368 pendingWorkers. delete (report.workerId);
369 // A worker the stream reports as running (even as its first report) is polled for activity until it
370 // ends, so its output keeps counting toward the idle timeout. `firstSeenAt` bounds the handoff search.
371 if (report.state === 'running' ) activeWorkers. set (report.workerId, { ... report, firstSeenAt });
372 else activeWorkers. delete (report.workerId);
373 }
374 queueWorkerReport (report);
375 }
376 }, onEvent : async ( event ) => {
377 this .lastObservationAt = Date. now ();
378 if ( this .vendorEventsPath && event.type === 'vendor_event' ) await appendLine ( this .vendorEventsPath, event.raw);
379 await this . emit (event.type === 'vendor_event' ? 'progress' : 'progress' , 'running' , 'Observed agent output.' , { evidence: { kind: event.type, ref: this .adapter.id } });
380 } });
381 await this . updateTasks (( state ) => {
382 const task = startRoute (state, route);
383 directAgentId = startDirectAgent (state, task, this .adapter.id, this .child.pid).agentId;
384 });
385 await this . writeRecord ({ childPid: this .child.pid, childProcessGroup: this .child.pid });
386 await this . emit ( 'agent_started' , 'running' , `Started ${ this . adapter . id } agent turn (${ this . adapter . selection ?. reason || 'selection reason unavailable'}).` , { agent: { id: this .adapter.id, selection: this .adapter.selection || null }, evidence: { kind: 'process' , ref: String ( this .child.pid) } });
387 const childDone = this . waitForChild ();
388 const heartbeat = this .options.statusIntervalMs ? setInterval ( async () => {
389 const elapsed = Date. now () - this .lastObservationAt;
390 if (elapsed >= this .options.statusIntervalMs) await this . emit ( 'heartbeat' , 'running' , 'Agent process is alive; no newer progress was observed.' , { lastObservedAt: new Date ( this .lastObservationAt). toISOString (), elapsedSinceObservationMs: elapsed, evidence: { kind: 'process' , ref: String ( this .child?.pid) } });
391 }, Math. max ( 1000 , this .options.statusIntervalMs)) : null ;
392 let finished = null ;
393 let workerStartupTimeout = null ;
394 // A second terminal communicates through the durable request file. Polling that
395 // file is intentionally mundane: it works for foreground and detached ownership
396 // without introducing a daemon socket or an interactive stdin channel.
397 while ( ! finished) {
398 finished = await Promise . race ([
399 childDone,
400 new Promise (( resolve ) => setTimeout (() => resolve ( null ), 100 )),
401 ]);
402 if ( ! finished && await this . cancellationRequested ()) {
403 if (heartbeat) clearInterval (heartbeat);
404 return this . cancel ( await this . cancellationRequested ());
405 }
406 const wallExpired = this .options.wallTimeoutMs && Date. now () - this .startedAtMs > this .options.wallTimeoutMs;
407 const idleExpired = this .options.idleTimeoutMs && Date. now () - this .lastObservationAt > this .options.idleTimeoutMs;
408 if ( ! finished && (pendingWorkers.size > 0 || activeWorkers.size > 0 )) {
409 const reconciled = await reconcileWorkers ( this .adapter.id, [ ... pendingWorkers. values (), ... activeWorkers. values ()], { cwd: this .projectDir, env: this .options.env || process.env });
410 for ( const report of reconciled) {
411 // A worker that is writing session output or a handoff is observable progress. A running worker
412 // keeps being polled until it ends: without this a parent waiting on a long worker is cancelled
413 // at the idle timeout while the worker is busy.
414 if (Number. isFinite (report.lastActivityAt)) this .lastObservationAt = Math. max ( this .lastObservationAt, report.lastActivityAt);
415 if (report.state === 'starting' ) continue ;
416 const wasActive = activeWorkers. has (report.workerId);
417 pendingWorkers. delete (report.workerId);
418 if (report.state === 'running' ) {
419 activeWorkers. set (report.workerId, report);
420 if (wasActive) continue ; // already recorded as running; only its activity time mattered
421 } else {
422 activeWorkers. delete (report.workerId);
423 }
424 queueWorkerReport (report);
425 }
426 }
427 const stalledWorker = [ ... pendingWorkers. values ()]. find (( worker ) => Date. now () - worker.firstSeenAt >= this .options.workerStartupTimeoutMs);
428 if ( ! finished && stalledWorker) {
429 workerStartupTimeout = stalledWorker;
430 await this . emit ( 'worker_start_timeout' , 'running' , `Codex worker ${ stalledWorker . workerId } did not leave startup state within ${ this . options . workerStartupTimeoutMs }ms.` , {
431 workerId: stalledWorker.workerId,
432 workerState: stalledWorker.state,
433 evidence: { kind: 'vendor_event' , ref: this .adapter.id },
434 });
435 requestCancel ( this .child);
436 const stopped = await Promise . race ([childDone, new Promise (( resolve ) => setTimeout (() => resolve ( null ), this .options.gracePeriodMs))]);
437 if ( ! stopped) {
438 try { process. kill ( - this .child.pid, 'SIGKILL' ); } catch { this .child. kill ( 'SIGKILL' ); }
439 }
440 break ;
441 }
442 if ( ! finished && (wallExpired || idleExpired)) {
443 if (heartbeat) clearInterval (heartbeat);
444 requestCancel ( this .child);
445 const stopped = await Promise . race ([childDone, new Promise (( resolve ) => setTimeout (() => resolve ( null ), this .options.gracePeriodMs))]);
446 if ( ! stopped) {
447 try { process. kill ( - this .child.pid, 'SIGKILL' ); } catch { this .child. kill ( 'SIGKILL' ); }
448 }
449 return this . finish ( makeResult ({ id: this .id, projectDir: this .projectDir, outcome: 'timed_out' , route, nextAction: wallExpired ? 'The configured wall-clock timeout elapsed; inspect diagnostics and resume.' : 'Agent made no observable progress before the idle timeout; inspect diagnostics and resume.' }));
450 }
451 }
452 if (heartbeat) clearInterval (heartbeat);
453 this .child = null ;
454 await workerReports;
455 const childOutcome = workerStartupTimeout ? 'failed' : finished.code === 0 ? 'completed' : 'failed' ;
456 await this . updateTasks (( state ) => {
457 finishDirectAgent (state, directAgentId, childOutcome);
458 if (childOutcome !== 'completed' ) finishWorkers (state, state.tasks. find (( item ) => item.state === 'running' )?.taskId, 'failed' );
459 });
460 if (workerStartupTimeout) {
461 const retries = workerStartupRetries. get (routeKey) || 0 ;
462 if (retries < 1 ) {
463 workerStartupRetries. set (routeKey, retries + 1 );
464 // The retry is a bounded recovery path even when the caller disabled ordinary
465 // same-route continuations. The next pass must reach launch once.
466 sameRoute = - 1 ;
467 await this . emit ( 'agent_retrying' , 'running' , 'Retrying the route once after a sub-worker failed to start.' , { workerId: workerStartupTimeout.workerId, retry: retries + 1 , evidence: { kind: 'adapter_protocol' , ref: this .adapter.id } });
468 continue ;
469 }
470 return this . finish ( makeResult ({ id: this .id, projectDir: this .projectDir, outcome: 'failed' , route, nextAction: `A sub-worker remained in startup state after one retry (${ workerStartupTimeout . workerId }); inspect vendor diagnostics and relaunch the run.` }));
471 }
472 if (finished.code !== 0 ) return this . finish ( makeResult ({ id: this .id, projectDir: this .projectDir, outcome: 'failed' , route, nextAction: `Agent exited with ${ finished . code ?? finished . signal }; inspect vendor diagnostics and resume.` }));
473 }
474 return this . finish ( makeResult ({ id: this .id, projectDir: this .projectDir, outcome: 'timed_out' }));
475 }
476
477 async cancel ( request ) {
478 await this . setOrchestrationCancellation ( 'cancel_requested' );
479 await this . emit ( 'cancel_requested' , 'cancel_requested' , request.reason || 'Cancellation requested.' );
480 if ( ! this .child) {
481 await this . setOrchestrationCancellation ( 'canceled' );
482 await this . emit ( 'canceled' , 'canceled' , 'Cancellation confirmed; no active child remained.' );
483 return this . finish ( makeResult ({ id: this .id, projectDir: this .projectDir, outcome: 'canceled' , nextAction: 'Use replatform resume <project> when ready.' }));
484 }
485 requestCancel ( this .child);
486 const grace = request.gracePeriodMs || this .options.gracePeriodMs;
487 const done = await Promise . race ([ this . waitForChild (), new Promise (( resolve ) => setTimeout (() => resolve ( null ), grace))]);
488 if (done) {
489 await this . setOrchestrationCancellation ( 'canceled' );
490 await this . emit ( 'canceled' , 'canceled' , 'Agent exited during cancellation grace period.' );
491 return this . finish ( makeResult ({ id: this .id, projectDir: this .projectDir, outcome: 'canceled' }));
492 }
493 try { process. kill ( - this .child.pid, 'SIGKILL' ); } catch { this .child. kill ( 'SIGKILL' ); }
494 await this . setOrchestrationCancellation ( 'interrupted' );
495 await this . emit ( 'interrupted' , 'interrupted' , 'Agent required forced termination.' );
496 return this . finish ( makeResult ({ id: this .id, projectDir: this .projectDir, outcome: 'interrupted' }));
497 }
498 }
499
500 async function requestCancellation ( projectDir , { reason = 'canceled by operator' , gracePeriodMs = 60000 } = {}) {
501 const active = await readActive (projectDir);
502 if ( ! active || active.status !== 'running' ) throw new Error ( 'no controllable active run exists' );
503 const request = { schemaVersion: SCHEMA_VERSION , runId: active.runId, requestedAt: iso (), reason, gracePeriodMs };
504 await writeJsonAtomic ( cancelPath (projectDir), request);
505 return { active, request };
506 }
507
508 module . exports = { SCHEMA_VERSION, RunController, runnerDir, activePath, historyPath, defaultEventsPath, defaultVendorEventsPath, readActive, findRun, requestCancellation, applyInvocationInputs, makeResult, exitCode, safeRelative, readJson, userActionSummary };