nx 23.2.1 → 23.3.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/command-line/format/format.js +2 -18
- package/dist/src/command-line/graph/graph.js +42 -3
- package/dist/src/command-line/init/implementation/format.js +2 -3
- package/dist/src/command-line/migrate/agentic/definitions.js +75 -19
- package/dist/src/command-line/migrate/agentic/format-command.d.ts +14 -0
- package/dist/src/command-line/migrate/agentic/format-command.js +44 -0
- package/dist/src/command-line/migrate/agentic/handoff-gitignore.d.ts +3 -3
- package/dist/src/command-line/migrate/agentic/handoff-gitignore.js +3 -3
- package/dist/src/command-line/migrate/agentic/handoff.d.ts +27 -17
- package/dist/src/command-line/migrate/agentic/handoff.js +107 -24
- package/dist/src/command-line/migrate/agentic/instruction-files.d.ts +21 -0
- package/dist/src/command-line/migrate/agentic/instruction-files.js +36 -0
- package/dist/src/command-line/migrate/agentic/prompts/fragments.d.ts +16 -0
- package/dist/src/command-line/migrate/agentic/prompts/fragments.js +70 -0
- package/dist/src/command-line/migrate/agentic/prompts/generic-validation.js +4 -3
- package/dist/src/command-line/migrate/agentic/prompts/system-prompt.d.ts +25 -1
- package/dist/src/command-line/migrate/agentic/prompts/system-prompt.js +57 -34
- package/dist/src/command-line/migrate/agentic/run-step.js +28 -4
- package/dist/src/command-line/migrate/agentic/runner.d.ts +17 -8
- package/dist/src/command-line/migrate/agentic/runner.js +110 -55
- package/dist/src/command-line/migrate/agentic/types.d.ts +23 -8
- package/dist/src/command-line/migrate/agentic/types.js +10 -7
- package/dist/src/command-line/migrate/migrate-commits.d.ts +1 -0
- package/dist/src/command-line/migrate/migrate-commits.js +9 -0
- package/dist/src/command-line/migrate/migrate-output.d.ts +3 -7
- package/dist/src/command-line/migrate/migrate-output.js +3 -7
- package/dist/src/command-line/migrate/migrate.js +21 -8
- package/dist/src/command-line/migrate/run/agent-output.d.ts +10 -0
- package/dist/src/command-line/migrate/run/agent-output.js +16 -2
- package/dist/src/command-line/migrate/run/agent-work-payload.d.ts +37 -0
- package/dist/src/command-line/migrate/run/agent-work-payload.js +142 -0
- package/dist/src/command-line/migrate/run/atomic-write.d.ts +8 -0
- package/dist/src/command-line/migrate/run/atomic-write.js +17 -0
- package/dist/src/command-line/migrate/run/index.d.ts +1 -1
- package/dist/src/command-line/migrate/run/issues.d.ts +106 -0
- package/dist/src/command-line/migrate/run/issues.js +980 -0
- package/dist/src/command-line/migrate/run/orchestrator.d.ts +1 -0
- package/dist/src/command-line/migrate/run/orchestrator.js +662 -105
- package/dist/src/command-line/migrate/run/run-state.d.ts +43 -8
- package/dist/src/command-line/migrate/run/run-state.js +184 -20
- package/dist/src/command-line/migrate/run/runbook.d.ts +17 -0
- package/dist/src/command-line/migrate/run/runbook.js +97 -0
- package/dist/src/command-line/migrate/run/state-lock.d.ts +2 -2
- package/dist/src/command-line/migrate/run/state-lock.js +2 -2
- package/dist/src/command-line/migrate/run/state-machine.d.ts +5 -1
- package/dist/src/command-line/migrate/run/state-machine.js +27 -1
- package/dist/src/command-line/migrate/run/worker.js +206 -56
- package/dist/src/command-line/nx-cloud/connect/connect-to-nx-cloud.js +5 -1
- package/dist/src/command-line/release/publish.js +11 -4
- package/dist/src/command-line/release/utils/remote-release-clients/github.js +3 -3
- package/dist/src/command-line/release/utils/remote-release-clients/gitlab.js +3 -3
- package/dist/src/command-line/release/utils/remote-release-clients/remote-release-client.d.ts +2 -0
- package/dist/src/command-line/release/utils/remote-release-clients/remote-release-client.js +35 -0
- package/dist/src/command-line/yargs-utils/shared-options.d.ts +1 -1
- package/dist/src/command-line/yargs-utils/shared-options.js +22 -4
- package/dist/src/config/workspace-json-project-json.d.ts +2 -0
- package/dist/src/core/graph/main.js +1 -1
- package/dist/src/daemon/client/client.d.ts +4 -2
- package/dist/src/daemon/client/client.js +20 -11
- package/dist/src/daemon/message-types/run-tasks-execution-hooks.d.ts +3 -2
- package/dist/src/daemon/server/handle-hash-tasks.d.ts +8 -2
- package/dist/src/daemon/server/handle-hash-tasks.js +16 -3
- package/dist/src/daemon/server/handle-outputs-changes.d.ts +2 -2
- package/dist/src/daemon/server/handle-outputs-changes.js +21 -6
- package/dist/src/daemon/server/handle-tasks-execution-hooks.d.ts +3 -2
- package/dist/src/daemon/server/outputs-tracking.d.ts +6 -0
- package/dist/src/daemon/server/outputs-tracking.js +18 -0
- package/dist/src/daemon/server/project-graph-incremental-recomputation.d.ts +22 -7
- package/dist/src/daemon/server/project-graph-incremental-recomputation.js +99 -69
- package/dist/src/daemon/server/restart-checks.d.ts +22 -0
- package/dist/src/daemon/server/restart-checks.js +77 -0
- package/dist/src/daemon/server/server.js +28 -18
- package/dist/src/daemon/server/shutdown-utils.d.ts +0 -5
- package/dist/src/daemon/server/shutdown-utils.js +3 -26
- package/dist/src/hasher/hash-task.js +20 -16
- package/dist/src/hasher/native-task-hasher-impl.d.ts +8 -0
- package/dist/src/hasher/native-task-hasher-impl.js +46 -3
- package/dist/src/hasher/task-hasher.d.ts +9 -0
- package/dist/src/hasher/task-hasher.js +14 -0
- package/dist/src/native/index.d.ts +202 -21
- package/dist/src/native/index.js +3 -3
- package/dist/src/native/native-bindings.js +3 -1
- package/dist/src/native/nx.wasm32-wasi.debug.wasm +0 -0
- package/dist/src/native/nx.wasm32-wasi.wasm +0 -0
- package/dist/src/plugins/js/lock-file/pnpm-parser.js +1 -1
- package/dist/src/plugins/js/lock-file/utils/pnpm-normalizer.d.ts +2 -0
- package/dist/src/plugins/js/lock-file/utils/pnpm-normalizer.js +22 -0
- package/dist/src/plugins/js/utils/register.js +2 -1
- package/dist/src/project-graph/plugins/isolation/isolated-plugin.js +2 -1
- package/dist/src/project-graph/plugins/isolation/messaging.d.ts +3 -2
- package/dist/src/project-graph/plugins/isolation/plugin-worker.js +5 -1
- package/dist/src/project-graph/plugins/loaded-nx-plugin.d.ts +3 -2
- package/dist/src/project-graph/plugins/loaded-nx-plugin.js +5 -1
- package/dist/src/project-graph/plugins/task-results-stub.d.ts +50 -0
- package/dist/src/project-graph/plugins/task-results-stub.js +111 -0
- package/dist/src/project-graph/plugins/tasks-execution-hooks.d.ts +3 -2
- package/dist/src/project-graph/plugins/tasks-execution-hooks.js +2 -1
- package/dist/src/project-graph/project-graph.js +2 -0
- package/dist/src/tasks-runner/batch/run-batch.js +15 -0
- package/dist/src/tasks-runner/cache.d.ts +35 -0
- package/dist/src/tasks-runner/cache.js +40 -0
- package/dist/src/tasks-runner/create-task-graph.js +1 -0
- package/dist/src/tasks-runner/default-tasks-runner.js +1 -1
- package/dist/src/tasks-runner/forked-process-task-runner.d.ts +1 -1
- package/dist/src/tasks-runner/forked-process-task-runner.js +8 -7
- package/dist/src/tasks-runner/init-tasks-runner.js +5 -1
- package/dist/src/tasks-runner/is-tui-enabled.js +4 -3
- package/dist/src/tasks-runner/life-cycle.d.ts +7 -0
- package/dist/src/tasks-runner/life-cycle.js +7 -0
- package/dist/src/tasks-runner/life-cycles/summary-terminal-output-life-cycle.d.ts +35 -0
- package/dist/src/tasks-runner/life-cycles/summary-terminal-output-life-cycle.js +146 -0
- package/dist/src/tasks-runner/prune-task-graph.d.ts +17 -0
- package/dist/src/tasks-runner/prune-task-graph.js +45 -0
- package/dist/src/tasks-runner/run-command.js +14 -3
- package/dist/src/tasks-runner/running-tasks/batch-process.d.ts +93 -19
- package/dist/src/tasks-runner/running-tasks/batch-process.js +184 -88
- package/dist/src/tasks-runner/task-orchestrator.d.ts +64 -2
- package/dist/src/tasks-runner/task-orchestrator.js +232 -46
- package/dist/src/tasks-runner/terminal-output-path.d.ts +11 -0
- package/dist/src/tasks-runner/terminal-output-path.js +18 -0
- package/dist/src/tasks-runner/utils.js +5 -2
- package/dist/src/utils/ab-testing.d.ts +3 -0
- package/dist/src/utils/ab-testing.js +11 -3
- package/dist/src/utils/chunkify.d.ts +1 -7
- package/dist/src/utils/chunkify.js +2 -8
- package/dist/src/utils/command-line-utils.d.ts +19 -0
- package/dist/src/utils/command-line-utils.js +10 -3
- package/dist/src/utils/formatters/index.d.ts +6 -0
- package/dist/src/utils/formatters/index.js +5 -0
- package/dist/src/utils/formatters/prettier.d.ts +0 -8
- package/dist/src/utils/formatters/prettier.js +4 -26
- package/dist/src/utils/installed-nx-version.js +2 -48
- package/dist/src/utils/min-release-age/packument.js +1 -1
- package/dist/src/utils/output.d.ts +22 -3
- package/dist/src/utils/output.js +28 -5
- package/dist/src/utils/package-json.d.ts +25 -0
- package/dist/src/utils/package-json.js +41 -6
- package/dist/src/utils/package-manager.d.ts +6 -0
- package/dist/src/utils/package-manager.js +10 -0
- package/dist/src/utils/provenance.js +20 -4
- package/dist/src/utils/workspace-context.d.ts +47 -3
- package/dist/src/utils/workspace-context.js +162 -10
- package/package.json +12 -12
- package/schemas/nx-schema.json +4 -0
- package/schemas/project-schema.json +4 -0
- package/dist/src/daemon/server/file-watching/route-workspace-changes.d.ts +0 -9
- package/dist/src/daemon/server/file-watching/route-workspace-changes.js +0 -76
- package/dist/src/daemon/server/watcher.d.ts +0 -18
- package/dist/src/daemon/server/watcher.js +0 -126
|
@@ -3,13 +3,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.runOrchestratorInit = runOrchestratorInit;
|
|
4
4
|
exports.runOrchestratorReconcile = runOrchestratorReconcile;
|
|
5
5
|
const child_process_1 = require("child_process");
|
|
6
|
+
const crypto_1 = require("crypto");
|
|
6
7
|
const fs_1 = require("fs");
|
|
7
8
|
const path_1 = require("path");
|
|
8
9
|
const fileutils_1 = require("../../../utils/fileutils");
|
|
10
|
+
const atomic_write_1 = require("./atomic-write");
|
|
9
11
|
const git_utils_1 = require("../../../utils/git-utils");
|
|
10
12
|
const versions_1 = require("../../../utils/versions");
|
|
11
13
|
const handoff_1 = require("../agentic/handoff");
|
|
14
|
+
const format_command_1 = require("../agentic/format-command");
|
|
12
15
|
const handoff_gitignore_1 = require("../agentic/handoff-gitignore");
|
|
16
|
+
const fragments_1 = require("../agentic/prompts/fragments");
|
|
13
17
|
const types_1 = require("../agentic/types");
|
|
14
18
|
const migrate_commits_1 = require("../migrate-commits");
|
|
15
19
|
const migrate_analytics_1 = require("../migrate-analytics");
|
|
@@ -22,6 +26,10 @@ const migration_shape_1 = require("../migration-shape");
|
|
|
22
26
|
const util_1 = require("./util");
|
|
23
27
|
const text_1 = require("../text");
|
|
24
28
|
const agent_output_1 = require("./agent-output");
|
|
29
|
+
const agent_work_payload_1 = require("./agent-work-payload");
|
|
30
|
+
const issues_1 = require("./issues");
|
|
31
|
+
const runbook_1 = require("./runbook");
|
|
32
|
+
const package_manager_1 = require("../../../utils/package-manager");
|
|
25
33
|
// The dark migrate orchestrator: drives a durable run one dispense at a time.
|
|
26
34
|
// An outer AI agent runs each dispensed command and re-invokes `nx migrate
|
|
27
35
|
// --run-id=<id>` to reconcile; there is no long-lived process.
|
|
@@ -29,8 +37,15 @@ const PLAN_SNAPSHOT_0 = 'plan-0.json';
|
|
|
29
37
|
// A running worker older than this may be hung; the still-running dispense
|
|
30
38
|
// escalates so the agent can verify or kill it.
|
|
31
39
|
const HANG_THRESHOLD_MS = 15 * 60 * 1000;
|
|
32
|
-
//
|
|
33
|
-
|
|
40
|
+
// Identical responses in a row before the dispense escalates to a
|
|
41
|
+
// 'no-progress' action. A still-running worker is exempt until the hang
|
|
42
|
+
// threshold: waiting on a live worker is not looping.
|
|
43
|
+
const NO_PROGRESS_THRESHOLD = 3;
|
|
44
|
+
// Rearms of one step before its failed/died dispense escalates: the retry
|
|
45
|
+
// guidance flips against retrying and the preselected `next` is withheld, so
|
|
46
|
+
// an agent that follows `next` blindly cannot retry forever. An explicit
|
|
47
|
+
// retry is still honored; the cap never refuses.
|
|
48
|
+
const REARM_ESCALATION_CAP = 3;
|
|
34
49
|
const INIT_CONTINUE_HINT = 're-run the command, or unset NX_MIGRATE_ORCHESTRATOR to use the standard migrate flow.';
|
|
35
50
|
function continueRunHint(runId) {
|
|
36
51
|
return `re-run the command to continue run '${runId}'.`;
|
|
@@ -66,7 +81,7 @@ function refuseUnsafeScratchExposure(exposure, thenWhat) {
|
|
|
66
81
|
}
|
|
67
82
|
}
|
|
68
83
|
async function runOrchestratorInit(input) {
|
|
69
|
-
const { root, migrationsJson, createCommits, commitPrefix, skipInstall, installedNxVersion, } = input;
|
|
84
|
+
const { root, migrationsJson, createCommits, commitPrefix, skipInstall, installedNxVersion, validate, } = input;
|
|
70
85
|
const planHash = (0, run_id_1.computePlanHash)(migrationsJson);
|
|
71
86
|
// An active run means a prior init already happened (e.g. it crashed before
|
|
72
87
|
// the agent's first reconcile); starting a second run would compete with it.
|
|
@@ -148,6 +163,8 @@ async function runOrchestratorInit(input) {
|
|
|
148
163
|
createCommits,
|
|
149
164
|
commitPrefix,
|
|
150
165
|
...(skipInstall ? { skipInstall: true } : {}),
|
|
166
|
+
validate: validate !== false,
|
|
167
|
+
runbookPath: runbook_1.RUNBOOK_FILE_NAME,
|
|
151
168
|
rounds: [
|
|
152
169
|
{
|
|
153
170
|
index: 0,
|
|
@@ -175,6 +192,10 @@ async function runOrchestratorInit(input) {
|
|
|
175
192
|
// crash in between must not leave an active run without its plan.
|
|
176
193
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
177
194
|
(0, fileutils_1.writeJsonFile)((0, path_1.join)(dir, PLAN_SNAPSHOT_0), migrationsJson);
|
|
195
|
+
// The runbook gets the same crash guarantee: a discoverable run always
|
|
196
|
+
// has the runbook a resume re-emits from disk. 'wx' creates without
|
|
197
|
+
// following links, so nothing pre-planted at the path can redirect it.
|
|
198
|
+
(0, fs_1.writeFileSync)((0, path_1.join)(dir, runbook_1.RUNBOOK_FILE_NAME), (0, runbook_1.renderRunbook)(runbookContext(root, runId, state)), { flag: 'wx' });
|
|
178
199
|
(0, run_state_1.createRun)(root, state);
|
|
179
200
|
return null;
|
|
180
201
|
});
|
|
@@ -182,7 +203,7 @@ async function runOrchestratorInit(input) {
|
|
|
182
203
|
resumeRun(root, winner.runId, winner.state);
|
|
183
204
|
return;
|
|
184
205
|
}
|
|
185
|
-
finishInit(root, dir, runId, state);
|
|
206
|
+
finishInit(root, dir, runId, state, 'created');
|
|
186
207
|
}
|
|
187
208
|
// Reads the newest active run, refusing one whose plan differs from the
|
|
188
209
|
// incoming plan; null when no run is active. Uninterpretable run dirs refuse
|
|
@@ -228,15 +249,19 @@ function resumeRun(root, runId, state) {
|
|
|
228
249
|
if (state.createCommits) {
|
|
229
250
|
assertScratchDirSafeForCommits(root, continueRunHint(runId));
|
|
230
251
|
}
|
|
252
|
+
// Read, repair, or refuse the runbook before the checkpoint retry and the
|
|
253
|
+
// analytics watermark: an invocation that cannot provide the run's
|
|
254
|
+
// contract must not first change git history or durable run state.
|
|
255
|
+
const runbook = ensureRunbook(root, dir, runId, state);
|
|
256
|
+
if (runbook === null) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
231
259
|
// A run flagged checkpointFailed gets one more chance to capture the
|
|
232
260
|
// pre-existing tree state before its first migration commit absorbs it.
|
|
233
261
|
const resumed = ensureCheckpoint(root, dir, state);
|
|
234
262
|
announceResume(runId, resumed);
|
|
235
|
-
finishInit(root, dir, runId, resumed);
|
|
263
|
+
finishInit(root, dir, runId, resumed, 'resumed', runbook);
|
|
236
264
|
}
|
|
237
|
-
// The dispense that follows says nothing about the steps already behind it, so
|
|
238
|
-
// a resumed run is otherwise indistinguishable from a fresh one that happens
|
|
239
|
-
// to start partway down the plan.
|
|
240
265
|
function announceResume(runId, state) {
|
|
241
266
|
const applied = state.steps.filter((s) => s.status === 'succeeded').length;
|
|
242
267
|
const skipped = state.steps.filter((s) => s.status === 'skipped').length;
|
|
@@ -303,9 +328,13 @@ function checkpointEntry(root, commitPrefix) {
|
|
|
303
328
|
}
|
|
304
329
|
return null;
|
|
305
330
|
}
|
|
306
|
-
//
|
|
307
|
-
// run
|
|
308
|
-
|
|
331
|
+
// No migration step is dispensed here: the agent reads the runbook first and
|
|
332
|
+
// asks for the run's current step by reconciling, so the contract always lands
|
|
333
|
+
// before the first command does.
|
|
334
|
+
function finishInit(root, dir, runId, state, origin,
|
|
335
|
+
// The runbook bytes when the caller already ensured them (the resume path,
|
|
336
|
+
// which must fail before its git and state side effects).
|
|
337
|
+
runbook) {
|
|
309
338
|
let current = state;
|
|
310
339
|
if (!current.analytics.startEmitted) {
|
|
311
340
|
// Claim the watermark on the fresh state first: of two concurrent inits
|
|
@@ -327,7 +356,96 @@ function finishInit(root, dir, runId, state) {
|
|
|
327
356
|
});
|
|
328
357
|
}
|
|
329
358
|
}
|
|
330
|
-
|
|
359
|
+
const content = runbook ?? ensureRunbook(root, dir, runId, current);
|
|
360
|
+
if (content === null) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
(0, agent_output_1.emitRunbookBlock)(runId, content);
|
|
364
|
+
const instructionLines = [
|
|
365
|
+
`Nx ${origin} migrate run ${runId}. No migration step ran in this response.`,
|
|
366
|
+
`Read the runbook above; it is the contract for driving this run. Then run the "next" command to get the run's current step.`,
|
|
367
|
+
...runbookFooterLines(root, runId),
|
|
368
|
+
];
|
|
369
|
+
const lines = (0, agent_output_1.safeLines)(instructionLines);
|
|
370
|
+
(0, agent_output_1.logToAgent)({ title: `nx migrate: run ${origin}`, bodyLines: lines });
|
|
371
|
+
(0, agent_output_1.emitStepBlock)(runId, '-', 'initialized', {
|
|
372
|
+
next: reconcileCommand(root, runId),
|
|
373
|
+
instructions: lines.join('\n'),
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
// A missing runbook is re-rendered only by the nx version that created the
|
|
377
|
+
// run: its content is version-locked, and a different nx re-rendering it would
|
|
378
|
+
// silently hand the agent a contract the run was not created under. Returns
|
|
379
|
+
// null after emitting the exit-0 refusal instead.
|
|
380
|
+
function ensureRunbook(root, dir, runId, state) {
|
|
381
|
+
const filePath = (0, path_1.join)(dir, state.runbookPath ?? runbook_1.RUNBOOK_FILE_NAME);
|
|
382
|
+
const stat = lstatRunbook(filePath);
|
|
383
|
+
if (stat?.isFile()) {
|
|
384
|
+
// The read is the proof the entry is usable: read errors (an unreadable
|
|
385
|
+
// mode, an I/O failure) propagate rather than letting an unreadable
|
|
386
|
+
// runbook pass the guard.
|
|
387
|
+
return (0, handoff_1.readInspectedFile)(filePath, stat, `${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${runId}/${runbook_1.RUNBOOK_FILE_NAME} was replaced while being read; ${continueRunHint(runId)}`);
|
|
388
|
+
}
|
|
389
|
+
if (stat) {
|
|
390
|
+
// A directory here is a corrupted run dir; erasing its contents as a side
|
|
391
|
+
// effect of a reconcile is not this code's call to make.
|
|
392
|
+
if (stat.isDirectory()) {
|
|
393
|
+
throw new Error(`${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${runId}/${runbook_1.RUNBOOK_FILE_NAME} is a directory, not the runbook file nx wrote there. Remove it, then ${continueRunHint(runId)}`);
|
|
394
|
+
}
|
|
395
|
+
// Any other non-regular entry (a symlink most likely) is cleared before the
|
|
396
|
+
// rename publish below: POSIX rename replaces it in place, but not every
|
|
397
|
+
// platform guarantees that, and a removal failure must propagate rather
|
|
398
|
+
// than leave the entry standing.
|
|
399
|
+
(0, fs_1.rmSync)(filePath, { force: true });
|
|
400
|
+
}
|
|
401
|
+
if (state.nxVersion !== versions_1.nxVersion) {
|
|
402
|
+
const reason = [
|
|
403
|
+
`The runbook for run '${runId}' is missing from ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${runId}, and this nx (${versions_1.nxVersion}) cannot re-render the one nx ${(0, text_1.singleLine)(state.nxVersion)} wrote.`,
|
|
404
|
+
`Restore the file (or, if the run was created before runbooks existed, re-run with the nx version that created it), or remove ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${runId} to abandon the run (migrations it already applied remain applied).`,
|
|
405
|
+
];
|
|
406
|
+
(0, agent_output_1.warnToAgent)({ title: reason[0], bodyLines: [reason[1]] });
|
|
407
|
+
(0, agent_output_1.emitStepBlock)(runId, '-', 'error', {
|
|
408
|
+
instructions: reason.join('\n'),
|
|
409
|
+
});
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
const content = (0, runbook_1.renderRunbook)(runbookContext(root, runId, state));
|
|
413
|
+
// A crashed repair leaves the path missing rather than truncated. 'wx'
|
|
414
|
+
// creates without following links, so anything planted at the temp name
|
|
415
|
+
// fails the repair instead of redirecting it.
|
|
416
|
+
(0, atomic_write_1.publishFileAtomically)(filePath, (tmpPath) => (0, fs_1.writeFileSync)(tmpPath, content, { flag: 'wx' }));
|
|
417
|
+
(0, state_lock_1.updateRunState)(dir, (fresh) => fresh.runbookPath ? null : { ...fresh, runbookPath: runbook_1.RUNBOOK_FILE_NAME });
|
|
418
|
+
(0, agent_output_1.warnToAgent)({
|
|
419
|
+
title: `The runbook for run '${runId}' was missing; it has been re-rendered.`,
|
|
420
|
+
});
|
|
421
|
+
return content;
|
|
422
|
+
}
|
|
423
|
+
// lstat that treats only a missing entry as null; other inspection failures
|
|
424
|
+
// (permissions, I/O) propagate rather than masquerading as "missing". Bigint
|
|
425
|
+
// stats so the inode identity compared above cannot lose precision.
|
|
426
|
+
function lstatRunbook(filePath) {
|
|
427
|
+
try {
|
|
428
|
+
return (0, fs_1.lstatSync)(filePath, { bigint: true });
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
if (e?.code === 'ENOENT') {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
throw e;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function runbookContext(root, runId, state) {
|
|
438
|
+
return {
|
|
439
|
+
runId,
|
|
440
|
+
packageManager: (0, package_manager_1.detectPackageManager)(root),
|
|
441
|
+
nxInvocation: `${(0, util_1.pmExecPrefix)(root)} nx`,
|
|
442
|
+
pmExec: (0, util_1.pmExecPrefix)(root),
|
|
443
|
+
reconcileCommand: reconcileCommand(root, runId),
|
|
444
|
+
createCommits: state.createCommits,
|
|
445
|
+
// The same `!== false` read the flag itself gets, so a run recorded
|
|
446
|
+
// without the field renders validation on.
|
|
447
|
+
validate: state.validate !== false,
|
|
448
|
+
};
|
|
331
449
|
}
|
|
332
450
|
async function runOrchestratorReconcile(input) {
|
|
333
451
|
const { root, runId, stepAction } = input;
|
|
@@ -349,6 +467,17 @@ async function runOrchestratorReconcile(input) {
|
|
|
349
467
|
if (state.createCommits) {
|
|
350
468
|
assertScratchDirSafeForCommits(root, continueRunHint(runId));
|
|
351
469
|
}
|
|
470
|
+
// The runbook is the run's persisted contract; read, repair, or refuse it
|
|
471
|
+
// before any fold or dispense, so the run can never advance while the
|
|
472
|
+
// contract is unavailable. A completed run, and an active run whose steps are
|
|
473
|
+
// all terminal, are exempt: no work can advance, the only thing left is the
|
|
474
|
+
// self-contained terminal response, and hiding it behind a missing contract
|
|
475
|
+
// would leave the run active forever.
|
|
476
|
+
if (state.status !== 'completed' &&
|
|
477
|
+
firstActionableStep(state) !== undefined &&
|
|
478
|
+
ensureRunbook(root, dir, runId, state) === null) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
352
481
|
// (a) fold handoffs into prompt outcomes (committing completed ones).
|
|
353
482
|
state = await foldHandoffs(root, dir, state);
|
|
354
483
|
// (b) reclassify running steps whose worker process is gone.
|
|
@@ -391,7 +520,22 @@ async function runOrchestratorReconcile(input) {
|
|
|
391
520
|
// new attempt. Losing the handoff without the rearm is safe: the step is
|
|
392
521
|
// still failed/died and the agent re-issues the action.
|
|
393
522
|
if (stepAction === 'retry' || stepAction === 'retry-clean') {
|
|
394
|
-
removeHandoff(dir, target.
|
|
523
|
+
removeHandoff(dir, target.id);
|
|
524
|
+
// A reset-backed retry that dropped the generator marker reruns the
|
|
525
|
+
// generator, so payloads stored by earlier attempts describe a run
|
|
526
|
+
// whose tree was reset away; remove them. Hygiene, not the correctness
|
|
527
|
+
// boundary: the lineage bound persisted with the next marker
|
|
528
|
+
// (generatorCompletedAtAttempt) is what keeps a later retry from
|
|
529
|
+
// re-handing a copy this best-effort removal missed. A plain retry
|
|
530
|
+
// keeps the files: its lineage is unbroken, and a retained retry
|
|
531
|
+
// re-hands the newest copy. Removed with the handoff, before the rearm
|
|
532
|
+
// is persisted: losing them without the rearm only costs a later
|
|
533
|
+
// emission its stored copy.
|
|
534
|
+
if (stepAction === 'retry-clean' &&
|
|
535
|
+
result.state.steps.find((s) => s.id === target.id)
|
|
536
|
+
.generatorCompleted !== true) {
|
|
537
|
+
(0, agent_work_payload_1.removeAgentWorkPayloads)(dir, target.id, target.attempt);
|
|
538
|
+
}
|
|
395
539
|
}
|
|
396
540
|
// Re-validate the transition against the fresh disk state: if a concurrent
|
|
397
541
|
// reconcile already resolved this step, surface the state machine's own
|
|
@@ -400,6 +544,7 @@ async function runOrchestratorReconcile(input) {
|
|
|
400
544
|
// against `state`, and a step that was re-armed and failed again in
|
|
401
545
|
// between is a different attempt those checks never saw.
|
|
402
546
|
let freshRejection;
|
|
547
|
+
let reopenedIssueUpdates = [];
|
|
403
548
|
const written = (0, state_lock_1.updateRunState)(dir, (fresh) => {
|
|
404
549
|
const reapplied = (0, state_machine_1.applyStepEvent)(fresh, {
|
|
405
550
|
type: 'stepAction',
|
|
@@ -411,10 +556,23 @@ async function runOrchestratorReconcile(input) {
|
|
|
411
556
|
freshRejection = reapplied.reason;
|
|
412
557
|
return null;
|
|
413
558
|
}
|
|
559
|
+
// The reset this action requires discarded the failed attempt's tree,
|
|
560
|
+
// so resolutions that attempt claimed and no landed commit carries are
|
|
561
|
+
// reverted with the rearm, in the same write.
|
|
562
|
+
let rearmed = reapplied.state;
|
|
563
|
+
if (stepAction === 'retry-clean') {
|
|
564
|
+
const reopened = (0, issues_1.reopenResolutionsForStep)(rearmed, target.id);
|
|
565
|
+
rearmed = reopened.state;
|
|
566
|
+
reopenedIssueUpdates = reopened.updates;
|
|
567
|
+
}
|
|
414
568
|
const next = installFailed
|
|
415
|
-
? (0, state_machine_1.markInstallFailed)(
|
|
416
|
-
:
|
|
417
|
-
|
|
569
|
+
? (0, state_machine_1.markInstallFailed)(rearmed, target.id)
|
|
570
|
+
: rearmed;
|
|
571
|
+
// An adopted commit absorbs uncovered failed steps the same way a fold
|
|
572
|
+
// commit does, so it carries their resolved issues too.
|
|
573
|
+
return entry
|
|
574
|
+
? appendCommit(next, (0, issues_1.attachIssueIdsToCommitEntry)(next, entry))
|
|
575
|
+
: next;
|
|
418
576
|
});
|
|
419
577
|
if (freshRejection) {
|
|
420
578
|
emitError(root, runId, entry?.kind === 'landed' && entry.sha
|
|
@@ -422,10 +580,35 @@ async function runOrchestratorReconcile(input) {
|
|
|
422
580
|
: freshRejection);
|
|
423
581
|
return;
|
|
424
582
|
}
|
|
583
|
+
if (reopenedIssueUpdates.length > 0) {
|
|
584
|
+
// Best-effort: run.json records the reverted dispositions and stays
|
|
585
|
+
// authoritative, so a failed append loses only the archive's trail record
|
|
586
|
+
// of the revert. The sink survives a throw: a shell rebuilt before the
|
|
587
|
+
// failure is durable and reads healthy on retry, so this pass must warn
|
|
588
|
+
// it.
|
|
589
|
+
const revertApplication = {
|
|
590
|
+
state: written,
|
|
591
|
+
newIssues: [],
|
|
592
|
+
updates: reopenedIssueUpdates,
|
|
593
|
+
};
|
|
594
|
+
const revertReconstructedIds = [];
|
|
595
|
+
try {
|
|
596
|
+
(0, issues_1.archiveIssues)(dir, revertApplication, revertReconstructedIds);
|
|
597
|
+
}
|
|
598
|
+
catch (e) {
|
|
599
|
+
(0, agent_output_1.warnToAgent)({
|
|
600
|
+
title: `The reverted issue resolutions for ${target.migrationId} could not be archived (${(0, util_1.summarizeError)(e)}).`,
|
|
601
|
+
bodyLines: [
|
|
602
|
+
`run.json stays authoritative for the dispositions; the archived files under the run's issues directory miss the revert records, so their last entries may still read resolved.`,
|
|
603
|
+
],
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
warnReconstructedArchives(revertReconstructedIds);
|
|
607
|
+
}
|
|
425
608
|
state = written;
|
|
426
609
|
}
|
|
427
610
|
// (d) choose and emit the next dispense.
|
|
428
|
-
advanceAndDispense(root, dir, runId
|
|
611
|
+
advanceAndDispense(root, dir, runId);
|
|
429
612
|
}
|
|
430
613
|
function buildSteps(sortedMigrations) {
|
|
431
614
|
return sortedMigrations.map((m, index) => ({
|
|
@@ -439,6 +622,16 @@ function buildSteps(sortedMigrations) {
|
|
|
439
622
|
}));
|
|
440
623
|
}
|
|
441
624
|
// --- reconcile phases -------------------------------------------------------
|
|
625
|
+
// The runbook points every later consumer at issues/<id>.json for the full
|
|
626
|
+
// details, so a rebuild from run-state fields (the reported detail is gone
|
|
627
|
+
// with the lost file) must not stay silent.
|
|
628
|
+
function warnReconstructedArchives(issueIds) {
|
|
629
|
+
if (issueIds.length === 0)
|
|
630
|
+
return;
|
|
631
|
+
(0, agent_output_1.warnToAgent)({
|
|
632
|
+
title: `The archived details for ${issueIds.join(', ')} were missing or unreadable and were rebuilt from the run state; the originally reported detail is lost.`,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
442
635
|
async function foldHandoffs(root, dir, state) {
|
|
443
636
|
let current = state;
|
|
444
637
|
// Step ids are fixed for the life of a run, so the ids come from the caller's
|
|
@@ -448,23 +641,80 @@ async function foldHandoffs(root, dir, state) {
|
|
|
448
641
|
const step = current.steps.find((s) => s.id === id);
|
|
449
642
|
if (step.status !== 'awaiting-prompt-outcome')
|
|
450
643
|
continue;
|
|
451
|
-
const result = (
|
|
644
|
+
const result = readStepHandoff(dir, step.id);
|
|
452
645
|
if (!result.ok)
|
|
453
646
|
continue; // still awaiting; the dispense asks to settle it
|
|
454
|
-
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
|
|
647
|
+
let promptOutcome = handoffToPromptOutcome(result.handoff);
|
|
648
|
+
// A skipped handoff completes a step whose generator changed the tree.
|
|
649
|
+
// Prompt-only and no-op hybrid steps remain skipped so they cannot commit
|
|
650
|
+
// unrelated pending diffs.
|
|
651
|
+
if (generatorChangesApplied(step) && promptOutcome.status === 'skipped') {
|
|
652
|
+
promptOutcome = { ...promptOutcome, status: 'completed' };
|
|
653
|
+
}
|
|
654
|
+
// Archive under the lock before committing or marking the step terminal.
|
|
655
|
+
// Failure leaves the handoff retryable instead of landing an unrecorded
|
|
656
|
+
// commit or a ledger entry without detail; replayed archive writes are
|
|
657
|
+
// idempotent.
|
|
658
|
+
let ready = false;
|
|
659
|
+
let archiveError = null;
|
|
660
|
+
const reconstructedIssueIds = [];
|
|
661
|
+
(0, state_lock_1.withRunStateLock)(dir, () => {
|
|
662
|
+
const fresh = (0, run_state_1.readRunState)(dir);
|
|
663
|
+
const applied = (0, state_machine_1.applyStepEvent)(fresh, {
|
|
664
|
+
type: 'foldPromptOutcome',
|
|
665
|
+
stepId: step.id,
|
|
666
|
+
attempt: step.attempt,
|
|
667
|
+
promptOutcome,
|
|
668
|
+
});
|
|
669
|
+
if (applied.kind === 'error')
|
|
670
|
+
return;
|
|
671
|
+
const issues = (0, issues_1.parseHandoffIssues)(result.handoff.extras, fresh, step);
|
|
672
|
+
if (issues.ok !== true)
|
|
673
|
+
return;
|
|
674
|
+
try {
|
|
675
|
+
// The sink survives a throw: a shell rebuilt before the failure is
|
|
676
|
+
// durable and reads healthy on the retry, so only this pass can
|
|
677
|
+
// warn that its original detail is gone.
|
|
678
|
+
(0, issues_1.archiveIssues)(dir, (0, issues_1.applyReportedIssues)(applied.state, step, issues.issues, issues.updates), reconstructedIssueIds);
|
|
679
|
+
}
|
|
680
|
+
catch (e) {
|
|
681
|
+
archiveError = e;
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
ready = true;
|
|
685
|
+
});
|
|
686
|
+
warnReconstructedArchives(reconstructedIssueIds);
|
|
687
|
+
if (archiveError !== null) {
|
|
688
|
+
(0, agent_output_1.warnToAgent)({
|
|
689
|
+
title: `The issue details reported by ${step.migrationId} could not be archived (${(0, util_1.summarizeError)(archiveError)}).`,
|
|
690
|
+
bodyLines: [
|
|
691
|
+
`The step's outcome was not folded; fix the underlying problem, then run the reconcile again.`,
|
|
692
|
+
],
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
if (!ready)
|
|
696
|
+
continue;
|
|
697
|
+
// Phase 2: the commit and the install are side effects, so they happen
|
|
698
|
+
// outside the lock; the transition, the issue application, and the
|
|
699
|
+
// ledger entry then land in one fresh-state write. A crash cannot leave
|
|
700
|
+
// the step settled with its commit forgotten.
|
|
459
701
|
const { entry, installFailed } = await foldLedgerEntry(root, dir, current, step, promptOutcome);
|
|
460
|
-
// The
|
|
702
|
+
// The write re-validates against fresh disk state, on the attempt this
|
|
461
703
|
// handoff was read for. That window is wide (a git commit plus a package
|
|
462
704
|
// install), and 'awaiting-prompt-outcome' recurs, so without the attempt
|
|
463
705
|
// check a concurrent reconcile's retry could take this outcome as its own.
|
|
464
706
|
// A dropped fold is equivalent to the crash-refold window: the commit
|
|
465
707
|
// landed but the ledger misses it.
|
|
708
|
+
// Written through the lock directly so the issue application is re-archived
|
|
709
|
+
// on the state the write actually lands on: a claim assigned between the
|
|
710
|
+
// phases can add an update record phase 1 never saw.
|
|
466
711
|
let folded = false;
|
|
467
|
-
|
|
712
|
+
let updateArchiveError = null;
|
|
713
|
+
let detailArchiveError = null;
|
|
714
|
+
let archivesDegraded = false;
|
|
715
|
+
const refoldReconstructedIds = [];
|
|
716
|
+
current = (0, state_lock_1.withRunStateLock)(dir, () => {
|
|
717
|
+
const fresh = (0, run_state_1.readRunState)(dir);
|
|
468
718
|
const applied = (0, state_machine_1.applyStepEvent)(fresh, {
|
|
469
719
|
type: 'foldPromptOutcome',
|
|
470
720
|
stepId: step.id,
|
|
@@ -472,18 +722,78 @@ async function foldHandoffs(root, dir, state) {
|
|
|
472
722
|
promptOutcome,
|
|
473
723
|
});
|
|
474
724
|
if (applied.kind === 'error')
|
|
475
|
-
return
|
|
725
|
+
return fresh;
|
|
726
|
+
const issues = (0, issues_1.parseHandoffIssues)(result.handoff.extras, fresh, step);
|
|
727
|
+
if (issues.ok !== true)
|
|
728
|
+
return fresh;
|
|
729
|
+
const application = (0, issues_1.applyReportedIssues)(applied.state, step, issues.issues, issues.updates);
|
|
730
|
+
try {
|
|
731
|
+
// Phase 1 wrote these files, so a reconstruction here means one
|
|
732
|
+
// vanished between the phases; the ids surface that loss. The sink
|
|
733
|
+
// survives a throw, so a shell rebuilt before a later batch failed
|
|
734
|
+
// still gets warned.
|
|
735
|
+
(0, issues_1.archiveIssues)(dir, application, refoldReconstructedIds);
|
|
736
|
+
}
|
|
737
|
+
catch (e) {
|
|
738
|
+
// A landed commit outranks the drop: refolding would re-attempt
|
|
739
|
+
// its commit against a clean tree as no-changes and lose the entry
|
|
740
|
+
// for good. Phase 1 already archived this handoff's records durably
|
|
741
|
+
// once; the fold proceeds and the loss is warned.
|
|
742
|
+
const intact = (0, issues_1.applicationArchivesIntact)(dir, application);
|
|
743
|
+
if (entry?.kind !== 'landed' && intact !== true) {
|
|
744
|
+
detailArchiveError = e;
|
|
745
|
+
return fresh;
|
|
746
|
+
}
|
|
747
|
+
updateArchiveError = e;
|
|
748
|
+
archivesDegraded = intact !== true;
|
|
749
|
+
}
|
|
476
750
|
folded = true;
|
|
477
751
|
const next = installFailed
|
|
478
|
-
? (0, state_machine_1.markInstallFailed)(
|
|
479
|
-
:
|
|
480
|
-
|
|
752
|
+
? (0, state_machine_1.markInstallFailed)(application.state, step.id)
|
|
753
|
+
: application.state;
|
|
754
|
+
// A landed commit carries the fixes of every issue resolved by a step it
|
|
755
|
+
// names: the folding step's own resolutions, and those of absorbed steps
|
|
756
|
+
// whose failed commit attempts left them unattached.
|
|
757
|
+
const written = entry
|
|
758
|
+
? appendCommit(next, (0, issues_1.attachIssueIdsToCommitEntry)(next, entry))
|
|
759
|
+
: next;
|
|
760
|
+
(0, run_state_1.writeRunState)(dir, written);
|
|
761
|
+
return written;
|
|
481
762
|
});
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
763
|
+
warnReconstructedArchives(refoldReconstructedIds);
|
|
764
|
+
if (detailArchiveError !== null) {
|
|
765
|
+
(0, agent_output_1.warnToAgent)({
|
|
766
|
+
title: `The issue details reported by ${step.migrationId} could not be archived (${(0, util_1.summarizeError)(detailArchiveError)}).`,
|
|
767
|
+
bodyLines: [
|
|
768
|
+
`The step's outcome was not folded; fix the underlying problem, then run the reconcile again.`,
|
|
769
|
+
],
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
if (updateArchiveError !== null) {
|
|
773
|
+
(0, agent_output_1.warnToAgent)(archivesDegraded
|
|
774
|
+
? {
|
|
775
|
+
title: `Some issue transition records for ${step.migrationId} could not be archived (${(0, util_1.summarizeError)(updateArchiveError)}).`,
|
|
776
|
+
bodyLines: [
|
|
777
|
+
`run.json stays authoritative for the dispositions; the archived files under the run's issues directory are missing or incomplete for this fold's issues, and its landed commit takes precedence over retrying the archive.`,
|
|
778
|
+
],
|
|
779
|
+
}
|
|
780
|
+
: {
|
|
781
|
+
title: `Re-archiving the issue records for ${step.migrationId} failed (${(0, util_1.summarizeError)(updateArchiveError)}).`,
|
|
782
|
+
bodyLines: [
|
|
783
|
+
`Nothing was lost: the fold's records were verified on disk and recorded in run.json. The failed write may point at a disk problem worth checking.`,
|
|
784
|
+
],
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
// A rejected fold leaves the handoff in place: it belongs to whichever
|
|
788
|
+
// attempt is on disk now, and that attempt's own reconcile still has to
|
|
789
|
+
// read it. The stored agent-work payloads go with a terminal outcome; a
|
|
790
|
+
// failed fold keeps them, since a retry re-hands the newest surviving copy.
|
|
791
|
+
if (folded) {
|
|
792
|
+
removeHandoff(dir, step.id);
|
|
793
|
+
if (promptOutcome.status !== 'failed') {
|
|
794
|
+
(0, agent_work_payload_1.removeAgentWorkPayloads)(dir, step.id, step.attempt);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
487
797
|
}
|
|
488
798
|
return current;
|
|
489
799
|
}
|
|
@@ -692,35 +1002,51 @@ async function commitForStep(root, dir, state, step) {
|
|
|
692
1002
|
};
|
|
693
1003
|
}
|
|
694
1004
|
// --- dispense ---------------------------------------------------------------
|
|
695
|
-
function advanceAndDispense(root, dir, runId
|
|
1005
|
+
function advanceAndDispense(root, dir, runId) {
|
|
1006
|
+
// Demote recorded issues no remaining step can claim before anything is
|
|
1007
|
+
// rendered. Done here, at the single choke point every dispense, retry
|
|
1008
|
+
// prompt, and completion goes through, on a fresh locked read: a step
|
|
1009
|
+
// turned terminal by a concurrent reconcile or worker since the caller's
|
|
1010
|
+
// read must not leave an unclaimable issue labeled recorded in a digest
|
|
1011
|
+
// or the completion report.
|
|
1012
|
+
const state = (0, state_lock_1.updateRunState)(dir, (fresh) => {
|
|
1013
|
+
const settled = (0, issues_1.settleUnclaimableIssues)(fresh);
|
|
1014
|
+
return settled === fresh ? null : settled;
|
|
1015
|
+
});
|
|
696
1016
|
const step = firstActionableStep(state);
|
|
697
1017
|
if (!step) {
|
|
698
1018
|
completeRun(root, dir, runId, state);
|
|
699
1019
|
return;
|
|
700
1020
|
}
|
|
1021
|
+
// Track the response streak before emitting: a crash in between costs at
|
|
1022
|
+
// most a count one ahead of what was emitted, and the escalation is
|
|
1023
|
+
// advisory. Only the step responses below count: the completion response
|
|
1024
|
+
// above repeats by design, and a rejected --step-action ends the reconcile
|
|
1025
|
+
// before reaching here, already naming its own fix.
|
|
1026
|
+
const noProgress = trackNoProgress(dir, step);
|
|
701
1027
|
switch (step.status) {
|
|
702
1028
|
case 'pending':
|
|
703
|
-
dispenseNextStep(root, dir, runId, state, step);
|
|
1029
|
+
dispenseNextStep(root, dir, runId, state, step, noProgress);
|
|
704
1030
|
break;
|
|
705
1031
|
case 'dispensed':
|
|
706
1032
|
// Re-entry before the worker advanced the step; re-emit its command.
|
|
707
|
-
emitNextStep(root, runId, step);
|
|
1033
|
+
emitNextStep(root, runId, state, step, noProgress);
|
|
708
1034
|
break;
|
|
709
1035
|
case 'failed':
|
|
710
|
-
emitRetryFailed(root, runId, state, step);
|
|
1036
|
+
emitRetryFailed(root, runId, state, step, noProgress);
|
|
711
1037
|
break;
|
|
712
1038
|
case 'died':
|
|
713
|
-
emitDied(root, runId, state, step);
|
|
1039
|
+
emitDied(root, runId, state, step, noProgress);
|
|
714
1040
|
break;
|
|
715
1041
|
case 'running':
|
|
716
|
-
emitStillRunning(root, runId, step);
|
|
1042
|
+
emitStillRunning(root, runId, step, noProgress);
|
|
717
1043
|
break;
|
|
718
1044
|
case 'awaiting-prompt-outcome':
|
|
719
|
-
emitAwaitPrompt(root, dir, runId, step);
|
|
1045
|
+
emitAwaitPrompt(root, dir, runId, step, noProgress);
|
|
720
1046
|
break;
|
|
721
1047
|
case 'succeeded':
|
|
722
1048
|
case 'skipped':
|
|
723
|
-
// firstActionableStep already excludes these via
|
|
1049
|
+
// firstActionableStep already excludes these via TERMINAL_STEP_STATUSES;
|
|
724
1050
|
// landing here means an already-terminal step slipped through
|
|
725
1051
|
// unclassified rather than being left to stall the run silently.
|
|
726
1052
|
throw new Error(`Orchestrator could not dispense step '${step.id}': step is already ${step.status}.`);
|
|
@@ -733,9 +1059,45 @@ function advanceAndDispense(root, dir, runId, state) {
|
|
|
733
1059
|
}
|
|
734
1060
|
}
|
|
735
1061
|
function firstActionableStep(state) {
|
|
736
|
-
return state.steps.find((s) => !
|
|
1062
|
+
return state.steps.find((s) => !run_state_1.TERMINAL_STEP_STATUSES.has(s.status));
|
|
737
1063
|
}
|
|
738
|
-
|
|
1064
|
+
// The fingerprint digests the whole persisted state (minus the streak itself),
|
|
1065
|
+
// so any durable change resets the count with no per-field enumeration to fall
|
|
1066
|
+
// out of date. A response whose own production writes state after the streak
|
|
1067
|
+
// is tracked fingerprints the pre-write state, so its first repeat resets
|
|
1068
|
+
// rather than increments.
|
|
1069
|
+
function responseFingerprint(state) {
|
|
1070
|
+
return (0, crypto_1.createHash)('sha256')
|
|
1071
|
+
.update(JSON.stringify({ ...state, noProgress: undefined }))
|
|
1072
|
+
.digest('hex');
|
|
1073
|
+
}
|
|
1074
|
+
// A young, parseably-started worker neither increments nor resets the
|
|
1075
|
+
// actionable-response streak. Unproven age counts normally.
|
|
1076
|
+
function trackNoProgress(dir, step) {
|
|
1077
|
+
if (step.status === 'running') {
|
|
1078
|
+
const startedAt = step.startedAt ? Date.parse(step.startedAt) : NaN;
|
|
1079
|
+
if (Number.isFinite(startedAt) &&
|
|
1080
|
+
Date.now() - startedAt < HANG_THRESHOLD_MS) {
|
|
1081
|
+
return null;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
let streak;
|
|
1085
|
+
(0, state_lock_1.updateRunState)(dir, (fresh) => {
|
|
1086
|
+
const fingerprint = responseFingerprint(fresh);
|
|
1087
|
+
const prev = fresh.noProgress;
|
|
1088
|
+
streak =
|
|
1089
|
+
prev && prev.fingerprint === fingerprint
|
|
1090
|
+
? { ...prev, consecutiveCount: prev.consecutiveCount + 1 }
|
|
1091
|
+
: {
|
|
1092
|
+
fingerprint,
|
|
1093
|
+
consecutiveCount: 1,
|
|
1094
|
+
firstSeenAt: new Date().toISOString(),
|
|
1095
|
+
};
|
|
1096
|
+
return { ...fresh, noProgress: streak };
|
|
1097
|
+
});
|
|
1098
|
+
return streak.consecutiveCount >= NO_PROGRESS_THRESHOLD ? streak : null;
|
|
1099
|
+
}
|
|
1100
|
+
function dispenseNextStep(root, dir, runId, state, step, noProgress) {
|
|
739
1101
|
// Read the pre-migration baselines (git and package.json reads) before the
|
|
740
1102
|
// lock; the dispense transition and the baselines then apply to the fresh
|
|
741
1103
|
// state in one write.
|
|
@@ -762,22 +1124,23 @@ function dispenseNextStep(root, dir, runId, state, step) {
|
|
|
762
1124
|
if (advancedElsewhere) {
|
|
763
1125
|
// Terminates: step statuses only advance, so each re-entry observes
|
|
764
1126
|
// strictly later state and lands in a non-pending branch of the dispatch.
|
|
765
|
-
advanceAndDispense(root, dir, runId
|
|
1127
|
+
advanceAndDispense(root, dir, runId);
|
|
766
1128
|
return;
|
|
767
1129
|
}
|
|
768
|
-
emitNextStep(root, runId, current.steps.find((s) => s.id === step.id));
|
|
1130
|
+
emitNextStep(root, runId, current, current.steps.find((s) => s.id === step.id), noProgress);
|
|
769
1131
|
}
|
|
770
|
-
function emitNextStep(root, runId, step) {
|
|
1132
|
+
function emitNextStep(root, runId, state, step, noProgress) {
|
|
771
1133
|
const migrationId = step.migrationId;
|
|
772
|
-
emit(runId, step, 'next-step', {
|
|
1134
|
+
emit(root, runId, step, 'next-step', {
|
|
773
1135
|
command: workerCommand(root, migrationId, runId),
|
|
774
1136
|
next: reconcileCommand(root, runId),
|
|
775
1137
|
instructionLines: [
|
|
776
1138
|
`Apply migration ${migrationId} by running the command below, then run the "next" command to record the outcome and get the next step.`,
|
|
1139
|
+
...(0, issues_1.renderIssueDigestLines)(state, step.id, runId),
|
|
777
1140
|
],
|
|
778
|
-
});
|
|
1141
|
+
}, noProgress);
|
|
779
1142
|
}
|
|
780
|
-
function emitRetryFailed(root, runId, state, step) {
|
|
1143
|
+
function emitRetryFailed(root, runId, state, step, noProgress) {
|
|
781
1144
|
const migrationId = step.migrationId;
|
|
782
1145
|
// A worker failure records its summary on the outcome; a prompt the agent
|
|
783
1146
|
// reported as failed carries the agent's own reason on the prompt outcome.
|
|
@@ -787,18 +1150,21 @@ function emitRetryFailed(root, runId, state, step) {
|
|
|
787
1150
|
const cleanRetry = canOfferCleanRetry(root, state, step, head);
|
|
788
1151
|
// A failure recorded before the generator marker can still have written to
|
|
789
1152
|
// the tree (a direct fs or exec side effect, or a crash mid-flush); a
|
|
790
|
-
// marker means only the
|
|
791
|
-
//
|
|
1153
|
+
// marker means only the handed-back half (a prompt or a validation pass)
|
|
1154
|
+
// or the install and commit are left, so plain retry is safe outright. So
|
|
1155
|
+
// is retrying a step with no generator half to rerun.
|
|
792
1156
|
const pending = generatorPending(step);
|
|
793
1157
|
const retrySafety = pending
|
|
794
1158
|
? assessPreMarkerRetry(root, step)
|
|
795
1159
|
: { kind: 'safe' };
|
|
1160
|
+
const capReached = rearmCapReached(step);
|
|
796
1161
|
const lines = [
|
|
797
1162
|
`Migration ${migrationId} failed${summary ? `: ${summary}` : ''}.`,
|
|
798
1163
|
` started from: ${step.gitRefBefore ?? '(unknown)'}`,
|
|
799
1164
|
` current HEAD: ${head ?? '(unknown)'}`,
|
|
800
1165
|
` working tree: ${tree === null ? '(unknown)' : tree ? `\n${tree}` : '(clean)'}`,
|
|
801
1166
|
``,
|
|
1167
|
+
...(capReached ? [rearmCapLine(step), ``] : []),
|
|
802
1168
|
`Decide how to proceed and re-run reconcile with one of:`,
|
|
803
1169
|
retryOptionLine(retrySafety, reconcileCommand(root, runId, 'retry')),
|
|
804
1170
|
];
|
|
@@ -812,11 +1178,26 @@ function emitRetryFailed(root, runId, state, step) {
|
|
|
812
1178
|
// A step whose generator may still run gets no `next`, whichever retry the
|
|
813
1179
|
// checks above would accept: git can vouch for the tracked tree only, and
|
|
814
1180
|
// an agent that follows `next` blindly must not rerun a generator over
|
|
815
|
-
// writes nothing here could see. Choosing a retry has to be explicit.
|
|
816
|
-
|
|
817
|
-
|
|
1181
|
+
// writes nothing here could see. Choosing a retry has to be explicit. The
|
|
1182
|
+
// rearm cap withholds it too: each rearm resets the response streak, so a
|
|
1183
|
+
// blindly-followed retry `next` would loop past every escalation.
|
|
1184
|
+
emit(root, runId, step, 'retry-failed', {
|
|
1185
|
+
...(pending || capReached
|
|
1186
|
+
? {}
|
|
1187
|
+
: { next: reconcileCommand(root, runId, 'retry') }),
|
|
818
1188
|
instructionLines: lines,
|
|
819
|
-
});
|
|
1189
|
+
}, noProgress);
|
|
1190
|
+
}
|
|
1191
|
+
// Whether the step has used up its rearms: attempts beyond the first are all
|
|
1192
|
+
// rearms, whichever retry action produced them.
|
|
1193
|
+
function rearmCapReached(step) {
|
|
1194
|
+
return step.attempt - 1 >= REARM_ESCALATION_CAP;
|
|
1195
|
+
}
|
|
1196
|
+
// No availability promise: which retry forms remain is the option list's to
|
|
1197
|
+
// say (a pre-marker death may offer none), and the cap itself withholds only
|
|
1198
|
+
// the preselected continuation.
|
|
1199
|
+
function rearmCapLine(step) {
|
|
1200
|
+
return `This migration has already been retried ${step.attempt - 1} times without completing. Repeating an unchanged retry is unlikely to end differently: fix the underlying problem first, choose one of the non-retry options below, or ask the user how to proceed.`;
|
|
820
1201
|
}
|
|
821
1202
|
// Whether the step's generator half may still have to run: it exists and no
|
|
822
1203
|
// attempt has recorded running it. Only then can a retry apply a generator
|
|
@@ -827,6 +1208,11 @@ function emitRetryFailed(root, runId, state, step) {
|
|
|
827
1208
|
function generatorPending(step) {
|
|
828
1209
|
return step.generatorCompleted !== true && step.hasGenerator !== false;
|
|
829
1210
|
}
|
|
1211
|
+
// Absent generatorMadeChanges (a marker an older nx wrote) counts as applied,
|
|
1212
|
+
// keeping that version's fold behavior.
|
|
1213
|
+
function generatorChangesApplied(step) {
|
|
1214
|
+
return (step.generatorCompleted === true && step.generatorMadeChanges !== false);
|
|
1215
|
+
}
|
|
830
1216
|
// Appended to the failed and died dispenses of a step whose generator may rerun.
|
|
831
1217
|
const UNVERIFIABLE_WRITES_LINE = `None of these can be verified against writes git does not see (ignored paths, files outside the repository); if this migration writes there, inspect that state before choosing.`;
|
|
832
1218
|
function retryOptionLine(safety, command) {
|
|
@@ -859,6 +1245,8 @@ function retryOptionLine(safety, command) {
|
|
|
859
1245
|
// records dirty, a run created before that field existed carries nothing to
|
|
860
1246
|
// check, and an unreadable HEAD is no ref at all, so none of the three can be
|
|
861
1247
|
// read as a restore point that exists.
|
|
1248
|
+
// The debt check is run-wide: a clean retry resets and cleans the whole tree,
|
|
1249
|
+
// which would discard every other failed step's uncommitted work as well.
|
|
862
1250
|
function canOfferCleanRetry(root, state, step, head) {
|
|
863
1251
|
return (state.createCommits &&
|
|
864
1252
|
!state.checkpointFailed &&
|
|
@@ -926,22 +1314,21 @@ function assessPreMarkerRetry(root, step) {
|
|
|
926
1314
|
}
|
|
927
1315
|
return { kind: 'safe' };
|
|
928
1316
|
}
|
|
929
|
-
function emitDied(root, runId, state, step) {
|
|
1317
|
+
function emitDied(root, runId, state, step, noProgress) {
|
|
930
1318
|
const migrationId = step.migrationId;
|
|
931
1319
|
const ref = step.gitRefBefore;
|
|
932
1320
|
const head = (0, git_utils_1.getLatestCommitSha)(root);
|
|
933
1321
|
const tree = dirtyTreeSummary(root);
|
|
934
1322
|
const cleanRetry = canOfferCleanRetry(root, state, step, head);
|
|
935
|
-
// The generator half is recorded (or the step never had one), so a retry
|
|
936
|
-
// that keeps the tree as it stands has the rest of the step left to run: a
|
|
937
|
-
// prompt, or the install and commit its worker never reached.
|
|
938
1323
|
const resume = !generatorPending(step);
|
|
1324
|
+
const capReached = rearmCapReached(step);
|
|
939
1325
|
const lines = [
|
|
940
1326
|
`The worker for ${migrationId} died; its process is gone.`,
|
|
941
1327
|
` started from: ${ref ?? '(unknown)'}`,
|
|
942
1328
|
` current HEAD: ${head ?? '(unknown)'}`,
|
|
943
1329
|
` working tree: ${tree === null ? '(unknown)' : tree ? `\n${tree}` : '(clean)'}`,
|
|
944
1330
|
``,
|
|
1331
|
+
...(capReached ? [rearmCapLine(step), ``] : []),
|
|
945
1332
|
];
|
|
946
1333
|
const options = [];
|
|
947
1334
|
if (resume) {
|
|
@@ -962,18 +1349,21 @@ function emitDied(root, runId, state, step) {
|
|
|
962
1349
|
if (!resume) {
|
|
963
1350
|
lines.push(UNVERIFIABLE_WRITES_LINE);
|
|
964
1351
|
}
|
|
965
|
-
// `retry` is preselected wherever it is legal: it is the only resolution
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
//
|
|
971
|
-
|
|
972
|
-
|
|
1352
|
+
// `retry` is preselected wherever it is legal: it is the only resolution that
|
|
1353
|
+
// neither discards work nor records a result the run never produced. While
|
|
1354
|
+
// the generator may still run there is no `next` at all: a reset cannot be
|
|
1355
|
+
// verified against writes git does not see, and adopting records a result
|
|
1356
|
+
// nothing checked, so an agent that follows `next` blindly must land on
|
|
1357
|
+
// neither. The rearm cap withholds it for the same reason as in
|
|
1358
|
+
// emitRetryFailed.
|
|
1359
|
+
emit(root, runId, step, 'died', {
|
|
1360
|
+
...(resume && !capReached
|
|
1361
|
+
? { next: reconcileCommand(root, runId, 'retry') }
|
|
1362
|
+
: {}),
|
|
973
1363
|
instructionLines: lines,
|
|
974
|
-
});
|
|
1364
|
+
}, noProgress);
|
|
975
1365
|
}
|
|
976
|
-
function emitStillRunning(root, runId, step) {
|
|
1366
|
+
function emitStillRunning(root, runId, step, noProgress) {
|
|
977
1367
|
const migrationId = step.migrationId;
|
|
978
1368
|
const ageMs = step.startedAt ? Date.now() - Date.parse(step.startedAt) : 0;
|
|
979
1369
|
const lines = [
|
|
@@ -982,47 +1372,157 @@ function emitStillRunning(root, runId, step) {
|
|
|
982
1372
|
if (ageMs >= HANG_THRESHOLD_MS) {
|
|
983
1373
|
lines.push(`It has been running for ${Math.floor(ageMs / 60000)} minutes and may be hung. Verify pid ${step.pid}; either keep waiting, or kill it so the next reconcile can classify it as died.`);
|
|
984
1374
|
}
|
|
985
|
-
emit(runId, step, 'still-running', {
|
|
1375
|
+
emit(root, runId, step, 'still-running', {
|
|
986
1376
|
next: reconcileCommand(root, runId),
|
|
987
1377
|
instructionLines: lines,
|
|
988
|
-
});
|
|
1378
|
+
}, noProgress);
|
|
989
1379
|
}
|
|
990
|
-
function emitAwaitPrompt(root, dir, runId, step) {
|
|
1380
|
+
function emitAwaitPrompt(root, dir, runId, step, noProgress) {
|
|
991
1381
|
const migrationId = step.migrationId;
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
//
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1382
|
+
const filePath = (0, handoff_1.runStepHandoffPath)(dir, step.id);
|
|
1383
|
+
// Recreated if the agent removed it, so the handed-over path always has its
|
|
1384
|
+
// parent (an agent that has to `mkdir -p` pays a permission prompt).
|
|
1385
|
+
const handoffsDir = (0, run_state_1.runHandoffsDir)(dir);
|
|
1386
|
+
const handoffsDirIs = (0, handoff_1.handoffsDirState)(handoffsDir);
|
|
1387
|
+
if (handoffsDirIs === 'missing') {
|
|
1388
|
+
(0, fs_1.mkdirSync)(handoffsDir, { recursive: true });
|
|
1389
|
+
}
|
|
1390
|
+
// Claim only while fresh state still awaits this handoff. Steps that never
|
|
1391
|
+
// park cannot report updates; serial dispensing fixes order, and stale
|
|
1392
|
+
// attempts redispatch.
|
|
1393
|
+
let advancedElsewhere = false;
|
|
1394
|
+
const claimed = (0, state_lock_1.updateRunState)(dir, (fresh) => {
|
|
1395
|
+
const freshStep = fresh.steps.find((s) => s.id === step.id);
|
|
1396
|
+
if (freshStep?.status !== 'awaiting-prompt-outcome' ||
|
|
1397
|
+
freshStep.attempt !== step.attempt) {
|
|
1398
|
+
advancedElsewhere = true;
|
|
1399
|
+
return null;
|
|
1400
|
+
}
|
|
1401
|
+
const next = (0, issues_1.claimIssuesForStep)(fresh, step.id, runId);
|
|
1402
|
+
return next === fresh ? null : next;
|
|
1403
|
+
});
|
|
1404
|
+
if (advancedElsewhere) {
|
|
1405
|
+
// Terminates like dispenseNextStep's redispatch: step state only
|
|
1406
|
+
// advances, so each re-entry observes strictly later state.
|
|
1407
|
+
advanceAndDispense(root, dir, runId);
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
const validating = step.awaitingKind === 'generator-validation';
|
|
1411
|
+
// The plan's prompt path is the ground truth for a prompt park: a stored copy
|
|
1412
|
+
// naming different instructions is rejected against it.
|
|
1413
|
+
const planPrompt = validating
|
|
1414
|
+
? null
|
|
1415
|
+
: planPromptPath(dir, claimed, migrationId);
|
|
1416
|
+
// Re-emit the payload the worker stored when it parked the step, so a
|
|
1417
|
+
// session that lost the original block gets the work restated. An awaiting
|
|
1418
|
+
// step offers no retry action, so a dispense that only pointed backward
|
|
1419
|
+
// could stall a valid run forever; only a plan that cannot name the prompt
|
|
1420
|
+
// falls back to that pointer.
|
|
1421
|
+
const payload = (0, agent_work_payload_1.readAgentWorkPayload)((0, agent_work_payload_1.agentWorkPayloadPath)(dir, step.id, step.attempt), {
|
|
1422
|
+
migrationId,
|
|
1423
|
+
kind: validating ? 'generator-validation' : 'migration-prompt',
|
|
1424
|
+
...(planPrompt === null ? {} : { promptPath: planPrompt }),
|
|
1425
|
+
}) ??
|
|
1426
|
+
(validating
|
|
1427
|
+
? { migrationId, kind: 'generator-validation' }
|
|
1428
|
+
: planPrompt === null
|
|
1429
|
+
? null
|
|
1430
|
+
: { migrationId, prompt: planPrompt });
|
|
1431
|
+
if (payload) {
|
|
1432
|
+
(0, agent_output_1.emitPromptBlock)(migrationId, payload);
|
|
1433
|
+
}
|
|
1434
|
+
const blockRef = payload
|
|
1435
|
+
? 'the <nx_migrate_prompt> block above'
|
|
1436
|
+
: "the worker's earlier <nx_migrate_prompt> block";
|
|
1437
|
+
const lines = validating
|
|
1438
|
+
? [
|
|
1439
|
+
`Migration ${migrationId} ran its generator; its changes are awaiting your validation.`,
|
|
1440
|
+
`Validate them (see ${blockRef} and the runbook's validation scope rules), then write the handoff file and run the "next" command.`,
|
|
1441
|
+
]
|
|
1442
|
+
: [
|
|
1443
|
+
`Migration ${migrationId} is a prompt-based migration awaiting your outcome.`,
|
|
1444
|
+
`Apply the prompt (see ${blockRef}), then write the handoff file and run the "next" command.`,
|
|
1445
|
+
// Resolved per dispense: an earlier step may have changed the formatter
|
|
1446
|
+
// since the runbook was written.
|
|
1447
|
+
`Format command: ${(0, format_command_1.resolveFormatCommand)(root, (0, util_1.pmExecPrefix)(root)) ??
|
|
1448
|
+
'none (no configured formatter is installed)'}`,
|
|
1449
|
+
];
|
|
1450
|
+
lines.push(`Handoff file: ${filePath}`,
|
|
1451
|
+
// The skipped outcome is offered only while nothing of the migration is in
|
|
1452
|
+
// the tree: once a generator's changes are applied, the fold treats a
|
|
1453
|
+
// skipped handoff as completed anyway.
|
|
1454
|
+
validating
|
|
1455
|
+
? `Handoff JSON: ${(0, fragments_1.renderHandoffShapeInline)('what you verified')}. If validation does not apply here, use "status": "success" and say so in the summary.`
|
|
1456
|
+
: generatorChangesApplied(step)
|
|
1457
|
+
? `Handoff JSON: ${(0, fragments_1.renderHandoffShapeInline)('what you did')}. If the prompt does not apply here, use "status": "success" and say so in the summary; the migration's generator changes are already applied.`
|
|
1458
|
+
: `Handoff JSON: ${(0, fragments_1.renderHandoffShapeInline)('what you did')}. To mark the prompt not applicable, use "status": "success" with "outcome": "skipped".`);
|
|
1459
|
+
lines.push(...(0, issues_1.renderIssueDigestLines)(claimed, step.id, runId));
|
|
1004
1460
|
// A handoff that exists but can't be read/parsed/validated is a rejection,
|
|
1005
1461
|
// not a still-awaited outcome. Naming why stops the run from re-emitting the
|
|
1006
1462
|
// same await forever while the agent leaves the bad file in place.
|
|
1007
|
-
|
|
1463
|
+
// A non-directory in the handoffs dir's place is never read through, so a
|
|
1464
|
+
// rewritten handoff cannot cure it: name the replacement instead.
|
|
1465
|
+
const rejection = handoffsDirIs === 'other'
|
|
1466
|
+
? [
|
|
1467
|
+
`${handoffsDir} is not a directory, so no handoff can be read from it. Replace it with a directory, then write the handoff file and run the "next" command.`,
|
|
1468
|
+
]
|
|
1469
|
+
: describeRejectedHandoff(dir, claimed, step);
|
|
1008
1470
|
if (rejection.length > 0) {
|
|
1009
1471
|
lines.push('', ...rejection);
|
|
1010
1472
|
}
|
|
1011
|
-
emit(runId, step, 'await-prompt', {
|
|
1473
|
+
emit(root, runId, step, 'await-prompt', {
|
|
1012
1474
|
next: reconcileCommand(root, runId),
|
|
1013
1475
|
instructionLines: lines,
|
|
1014
|
-
});
|
|
1476
|
+
}, noProgress);
|
|
1477
|
+
}
|
|
1478
|
+
// The prompt path the run's latest plan snapshot records for the migration.
|
|
1479
|
+
// Every failure lands on null, damaged-but-parseable snapshots included: this
|
|
1480
|
+
// runs while re-handing an awaiting step's work, and throwing here would keep
|
|
1481
|
+
// the run from re-dispensing that work at all. The snapshot name is validated
|
|
1482
|
+
// as a bare `plan-<round>.json` at state read, so the join cannot leave the run
|
|
1483
|
+
// directory.
|
|
1484
|
+
function planPromptPath(dir, state, migrationId) {
|
|
1485
|
+
const round = (0, state_machine_1.latestRound)(state);
|
|
1486
|
+
if (!round)
|
|
1487
|
+
return null;
|
|
1488
|
+
let migrations;
|
|
1489
|
+
try {
|
|
1490
|
+
migrations = (0, fileutils_1.readJsonFile)((0, path_1.join)(dir, round.planSnapshot)).migrations;
|
|
1491
|
+
}
|
|
1492
|
+
catch {
|
|
1493
|
+
return null;
|
|
1494
|
+
}
|
|
1495
|
+
if (!Array.isArray(migrations))
|
|
1496
|
+
return null;
|
|
1497
|
+
for (const entry of migrations) {
|
|
1498
|
+
if (typeof entry !== 'object' || entry === null)
|
|
1499
|
+
continue;
|
|
1500
|
+
const migration = entry;
|
|
1501
|
+
if (`${migration.package}:${migration.name}` !== migrationId)
|
|
1502
|
+
continue;
|
|
1503
|
+
return typeof migration.prompt === 'string' ? migration.prompt : null;
|
|
1504
|
+
}
|
|
1505
|
+
return null;
|
|
1015
1506
|
}
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1507
|
+
// Wording mirrors the classic runner's ambiguous-outcome cause lines. A
|
|
1508
|
+
// readable handoff whose issue report is invalid is a rejection too: the fold
|
|
1509
|
+
// refused to consume it, and only this response can tell the agent why.
|
|
1510
|
+
function describeRejectedHandoff(dir, state, step) {
|
|
1511
|
+
const result = readStepHandoff(dir, step.id);
|
|
1512
|
+
const followUp = 'Rewrite the handoff file, then run the "next" command.';
|
|
1513
|
+
if (result.ok === true) {
|
|
1514
|
+
const issues = (0, issues_1.parseHandoffIssues)(result.handoff.extras, state, step);
|
|
1515
|
+
if (issues.ok !== true) {
|
|
1516
|
+
return [
|
|
1517
|
+
`The handoff file was rejected: ${issues.reason}.`,
|
|
1518
|
+
followUp,
|
|
1519
|
+
];
|
|
1520
|
+
}
|
|
1021
1521
|
return [];
|
|
1522
|
+
}
|
|
1022
1523
|
const { reason, detail } = result;
|
|
1023
1524
|
if (reason === 'missing')
|
|
1024
1525
|
return [];
|
|
1025
|
-
const followUp = 'Rewrite the handoff file, then run the "next" command.';
|
|
1026
1526
|
switch (reason) {
|
|
1027
1527
|
case 'read-error':
|
|
1028
1528
|
return [
|
|
@@ -1050,13 +1550,17 @@ function describeRejectedHandoff(handoffPath) {
|
|
|
1050
1550
|
// exit would tell the driving agent that reconcile itself crashed, and it
|
|
1051
1551
|
// would stop reading for the correction it is being handed.
|
|
1052
1552
|
function emitError(root, runId, reason) {
|
|
1553
|
+
// A rejected action is an active response like any dispense, so it carries
|
|
1554
|
+
// the runbook footer: the master receiving it may have just lost its
|
|
1555
|
+
// context. One sanitized array feeds both outputs, as in emit().
|
|
1556
|
+
const lines = (0, agent_output_1.safeLines)([reason, ...runbookFooterLines(root, runId)]);
|
|
1053
1557
|
(0, agent_output_1.warnToAgent)({
|
|
1054
1558
|
title: 'The requested --step-action could not be applied.',
|
|
1055
|
-
bodyLines:
|
|
1559
|
+
bodyLines: lines,
|
|
1056
1560
|
});
|
|
1057
1561
|
(0, agent_output_1.emitStepBlock)(runId, '-', 'error', {
|
|
1058
1562
|
next: reconcileCommand(root, runId),
|
|
1059
|
-
instructions:
|
|
1563
|
+
instructions: lines.join('\n'),
|
|
1060
1564
|
});
|
|
1061
1565
|
(0, migrate_analytics_1.reportMigrateOrchestratorDispense)({ action: 'error', attempt: 0 });
|
|
1062
1566
|
}
|
|
@@ -1108,29 +1612,69 @@ function completeRun(root, dir, runId, state) {
|
|
|
1108
1612
|
if (installLine) {
|
|
1109
1613
|
(0, agent_output_1.warnToAgent)({ title: installLine });
|
|
1110
1614
|
}
|
|
1615
|
+
const issueLines = (0, issues_1.renderUnresolvedIssueLines)(current, runId);
|
|
1616
|
+
if (issueLines.length > 0) {
|
|
1617
|
+
(0, agent_output_1.warnToAgent)({ title: issueLines[0], bodyLines: issueLines.slice(1) });
|
|
1618
|
+
}
|
|
1111
1619
|
const instructionLines = [
|
|
1112
1620
|
`Migrate run ${runId} is complete.`,
|
|
1113
1621
|
` applied: ${completed}`,
|
|
1114
1622
|
` skipped: ${skipped}`,
|
|
1115
1623
|
...(commitDebt ? [debtLine] : []),
|
|
1116
1624
|
...(installLine ? [installLine] : []),
|
|
1625
|
+
...issueLines,
|
|
1117
1626
|
];
|
|
1118
1627
|
(0, agent_output_1.logToAgent)({ title: 'nx migrate: complete', bodyLines: instructionLines });
|
|
1119
1628
|
(0, agent_output_1.emitStepBlock)(runId, '-', 'complete', {
|
|
1120
1629
|
instructions: instructionLines.join('\n'),
|
|
1121
1630
|
});
|
|
1122
1631
|
}
|
|
1123
|
-
function emit(runId, step, action, payload) {
|
|
1632
|
+
function emit(root, runId, step, action, payload, noProgress) {
|
|
1633
|
+
// At the escalation threshold the whole response becomes the 'no-progress'
|
|
1634
|
+
// action, with the step's own instructions kept below so acting on them stays
|
|
1635
|
+
// possible. Never a step status: nothing durable changed, and a status would
|
|
1636
|
+
// have to be walked back once the agent acts.
|
|
1637
|
+
const escalated = noProgress !== null;
|
|
1638
|
+
const effectiveAction = escalated ? 'no-progress' : action;
|
|
1124
1639
|
const { instructionLines, ...rest } = payload;
|
|
1640
|
+
const allLines = escalated
|
|
1641
|
+
? [...noProgressLines(step, noProgress), ...(instructionLines ?? [])]
|
|
1642
|
+
: instructionLines;
|
|
1643
|
+
const withFooter = allLines
|
|
1644
|
+
? [...allLines, ...runbookFooterLines(root, runId)]
|
|
1645
|
+
: undefined;
|
|
1125
1646
|
// One sanitized array feeds both, so the block payload says exactly what the
|
|
1126
1647
|
// human echo said.
|
|
1127
|
-
const lines =
|
|
1128
|
-
(0, agent_output_1.logToAgent)({ title: `nx migrate: ${
|
|
1129
|
-
(0, agent_output_1.emitStepBlock)(runId, step.id,
|
|
1648
|
+
const lines = withFooter ? (0, agent_output_1.safeLines)(withFooter) : undefined;
|
|
1649
|
+
(0, agent_output_1.logToAgent)({ title: `nx migrate: ${effectiveAction}`, bodyLines: lines });
|
|
1650
|
+
(0, agent_output_1.emitStepBlock)(runId, step.id, effectiveAction, {
|
|
1130
1651
|
...rest,
|
|
1131
1652
|
...(lines ? { instructions: lines.join('\n') } : {}),
|
|
1132
1653
|
});
|
|
1133
|
-
(0, migrate_analytics_1.reportMigrateOrchestratorDispense)({
|
|
1654
|
+
(0, migrate_analytics_1.reportMigrateOrchestratorDispense)({
|
|
1655
|
+
action: effectiveAction,
|
|
1656
|
+
attempt: step.attempt,
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
function noProgressLines(step, streak) {
|
|
1660
|
+
return [
|
|
1661
|
+
`No progress: this is response ${streak.consecutiveCount} in a row for migration ${step.migrationId} with no change in the run's recorded state since ${streak.firstSeenAt}.`,
|
|
1662
|
+
`Re-running the reconcile command changes nothing on its own; act on the instructions below. If something is blocking you from acting on them, stop looping and report the blocker to the user.`,
|
|
1663
|
+
``,
|
|
1664
|
+
];
|
|
1665
|
+
}
|
|
1666
|
+
// Append the runbook path to init, active and rejected responses so a
|
|
1667
|
+
// compacted, truncated or restarted session can recover the contract. Missing
|
|
1668
|
+
// or non-regular runbooks stand alone.
|
|
1669
|
+
function runbookFooterLines(root, runId) {
|
|
1670
|
+
if (lstatRunbook((0, path_1.join)((0, run_state_1.runDir)(root, runId), runbook_1.RUNBOOK_FILE_NAME))?.isFile() !==
|
|
1671
|
+
true) {
|
|
1672
|
+
return [];
|
|
1673
|
+
}
|
|
1674
|
+
return [
|
|
1675
|
+
``,
|
|
1676
|
+
`Runbook: ${types_1.MIGRATE_RUNS_RELATIVE_DIR}/${runId}/${runbook_1.RUNBOOK_FILE_NAME}. After a compaction or restart, re-read it and run \`${reconcileCommand(root, runId)}\`; never infer the run's progress from memory.`,
|
|
1677
|
+
];
|
|
1134
1678
|
}
|
|
1135
1679
|
// Raw argv is forwarded verbatim across the wrapper hops, so every flag is a
|
|
1136
1680
|
// single `--flag=value` token. Interpolated values are validated shell-safe at
|
|
@@ -1176,11 +1720,24 @@ function applyEventOrThrow(state, event) {
|
|
|
1176
1720
|
}
|
|
1177
1721
|
return result.state;
|
|
1178
1722
|
}
|
|
1179
|
-
function
|
|
1180
|
-
return (0, handoff_1.
|
|
1723
|
+
function readStepHandoff(dir, stepId) {
|
|
1724
|
+
return (0, handoff_1.readHandoffWithReason)((0, handoff_1.runStepHandoffPath)(dir, stepId), (0, run_state_1.runHandoffsDir)(dir));
|
|
1181
1725
|
}
|
|
1182
|
-
|
|
1183
|
-
|
|
1726
|
+
// Probe-time guard: a handoffs dir the agent swapped for a symlink into a
|
|
1727
|
+
// directory holding a file of the step's fixed name would otherwise have the
|
|
1728
|
+
// orchestrator delete that file. A swap between the probe and the rm is not
|
|
1729
|
+
// caught. A probe failure skips the rm too; the next read reports it.
|
|
1730
|
+
function removeHandoff(dir, stepId) {
|
|
1731
|
+
let state;
|
|
1732
|
+
try {
|
|
1733
|
+
state = (0, handoff_1.handoffsDirState)((0, run_state_1.runHandoffsDir)(dir));
|
|
1734
|
+
}
|
|
1735
|
+
catch {
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
if (state === 'directory') {
|
|
1739
|
+
(0, fs_1.rmSync)((0, handoff_1.runStepHandoffPath)(dir, stepId), { force: true });
|
|
1740
|
+
}
|
|
1184
1741
|
}
|
|
1185
1742
|
// null means the probe itself failed; the death dispense renders that as
|
|
1186
1743
|
// '(unknown)', because '(clean)' would invite a retry-clean reset over
|