@seanyao/roll 4.721.1 → 4.721.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v4.721.2 — 2026-07-21
6
+
7
+ ### 稳定性
8
+ - loop 的构建子进程失联或被系统杀掉后,现在会在限定时间内察觉、收尾并写入终态(标记为 aborted),不再让整个受监督周期永久挂住;新增子进程存活/超时检测(FIX-1474)[loop]
9
+
5
10
  ## v4.721.1 — 2026-07-21
6
11
 
7
12
  ### 稳定性
package/dist/roll.mjs CHANGED
@@ -14639,6 +14639,19 @@ function timeoutTeardownCommands(ctx) {
14639
14639
  { kind: "release_lock" }
14640
14640
  ];
14641
14641
  }
14642
+ function abortedTeardownCommands(ctx) {
14643
+ return [
14644
+ { kind: "kill_agent", graceSec: WATCHDOG_KILL_GRACE_SEC },
14645
+ { kind: "measure_worktree" },
14646
+ { kind: "emit_event", event: cycleEndEvent(ctx, "aborted") },
14647
+ { kind: "append_run", status: "aborted", outcome: mapV2Status("aborted"), cycleId: ctx.cycleId },
14648
+ {
14649
+ kind: "append_alert",
14650
+ message: `cycle ${ctx.cycleId}: builder child process lost (killed out-of-band) \u2014 cycle aborted; worktree preserved`
14651
+ },
14652
+ { kind: "release_lock" }
14653
+ ];
14654
+ }
14642
14655
  function cycleTimeoutVerdict(input) {
14643
14656
  const wall = input.wallLimitSec ?? CYCLE_WALL_TIMEOUT_SEC;
14644
14657
  const idle = input.noProgressLimitSec ?? CYCLE_NO_PROGRESS_SEC;
@@ -14833,6 +14846,12 @@ function cycleStep(state, event) {
14833
14846
  { kind: "cleanup_worktree", branch: state.ctx.branch }
14834
14847
  ]);
14835
14848
  case "agent_exited": {
14849
+ if (event.lost === true) {
14850
+ return {
14851
+ state: { ...state, phase: "execute", terminal: "aborted", done: true, ctx: { ...state.ctx, agentExitCode: event.exit, agentTimedOut: event.timedOut } },
14852
+ commands: abortedTeardownCommands(terminalCtx(state))
14853
+ };
14854
+ }
14836
14855
  const plan = retryPlan({ attempt: state.attempt, exit: event.exit, timedOut: event.timedOut });
14837
14856
  if (plan.action === "abort_timeout") {
14838
14857
  return {
@@ -246187,6 +246206,10 @@ function spawnAndWait(bin, args, opts, pty = false, profile) {
246187
246206
  stderr += d.toString("utf8");
246188
246207
  });
246189
246208
  liveAgents.add(child);
246209
+ try {
246210
+ opts.onSpawn?.(child);
246211
+ } catch {
246212
+ }
246190
246213
  let settled = false;
246191
246214
  let exitDrainFallback;
246192
246215
  const settle = (result2) => {
@@ -284694,6 +284717,68 @@ function startStallDetector(opts) {
284694
284717
  }
284695
284718
  };
284696
284719
  }
284720
+ var LIVENESS_POLL_MS = 5e3;
284721
+ var LIVENESS_CONFIRM_TICKS = 2;
284722
+ function defaultIsAlive(pid) {
284723
+ try {
284724
+ process.kill(pid, 0);
284725
+ return true;
284726
+ } catch (e) {
284727
+ return e.code === "EPERM";
284728
+ }
284729
+ }
284730
+ function startBuilderLivenessProbe(opts) {
284731
+ const { cycleId, agent, appendEvent: appendEvent3 } = opts;
284732
+ const isAlive = opts.isAlive ?? defaultIsAlive;
284733
+ const kill = opts.kill ?? (() => killLiveAgents("SIGKILL"));
284734
+ const pollMs = opts.pollMs ?? (Number((process.env["ROLL_LIVENESS_POLL_MS"] ?? "").trim()) || LIVENESS_POLL_MS);
284735
+ const confirmTicks = opts.confirmTicks ?? LIVENESS_CONFIRM_TICKS;
284736
+ let lost = false;
284737
+ let deadStreak = 0;
284738
+ const tick = () => {
284739
+ if (lost || !opts.spawnPending())
284740
+ return;
284741
+ const pid = opts.pid();
284742
+ if (pid === void 0) {
284743
+ deadStreak = 0;
284744
+ return;
284745
+ }
284746
+ let alive = true;
284747
+ try {
284748
+ alive = isAlive(pid);
284749
+ } catch {
284750
+ }
284751
+ if (alive) {
284752
+ deadStreak = 0;
284753
+ return;
284754
+ }
284755
+ deadStreak += 1;
284756
+ if (deadStreak < confirmTicks)
284757
+ return;
284758
+ lost = true;
284759
+ clearInterval(timer);
284760
+ try {
284761
+ appendEvent3({ type: "cycle:agent_lost", cycleId, agent, pid, ts: Date.now() });
284762
+ } catch {
284763
+ }
284764
+ try {
284765
+ kill();
284766
+ } catch {
284767
+ }
284768
+ try {
284769
+ opts.onLost?.({ pid });
284770
+ } catch {
284771
+ }
284772
+ };
284773
+ const timer = setInterval(tick, pollMs);
284774
+ timer.unref?.();
284775
+ return {
284776
+ stop: () => {
284777
+ clearInterval(timer);
284778
+ return { lost };
284779
+ }
284780
+ };
284781
+ }
284697
284782
  function createCaptureMarkerSink(runDir, capture) {
284698
284783
  let buf = "";
284699
284784
  const pending = [];
@@ -284817,6 +284902,24 @@ ${skillBodyForSpawn}` : skillBodyForSpawn;
284817
284902
  let timeoutFired = null;
284818
284903
  let activeMainLeak = { detected: false, files: [] };
284819
284904
  let mainLeakWatchdog;
284905
+ let spawnedPid;
284906
+ let spawnSettled = false;
284907
+ let agentLost = false;
284908
+ let resolveLostRace;
284909
+ const lostRace = new Promise((resolve17) => {
284910
+ resolveLostRace = () => resolve17({ stdout: "", stderr: "", exitCode: 137, timedOut: false });
284911
+ });
284912
+ const livenessProbe = startBuilderLivenessProbe({
284913
+ cycleId: ctx.cycleId ?? "",
284914
+ agent: cmd.agent,
284915
+ pid: () => spawnedPid,
284916
+ spawnPending: () => !spawnSettled,
284917
+ appendEvent: (ev) => ports.events.appendEvent(ports.paths.eventsPath, ev),
284918
+ onLost: () => {
284919
+ agentLost = true;
284920
+ resolveLostRace?.();
284921
+ }
284922
+ });
284820
284923
  try {
284821
284924
  appendWriteProtectionEvent(ports, applyMainCheckoutWriteProtection({
284822
284925
  repoCwd: ports.repoCwd,
@@ -284825,37 +284928,47 @@ ${skillBodyForSpawn}` : skillBodyForSpawn;
284825
284928
  nowMs: () => eventTs2(ports)
284826
284929
  }));
284827
284930
  mainLeakWatchdog = startMainCheckoutLeakWatchdog(ports, ctx);
284828
- res = await ports.agentSpawn(cmd.agent, {
284829
- purpose: "builder",
284830
- // E4: the builder runs in the submodule cycle worktree for a submodule
284831
- // story (execCwd); its git env + writable roots target the submodule's
284832
- // own repo/object store so its commits actually land there.
284833
- cwd: execCwd,
284834
- skillBody: finalSkillBody,
284835
- ...ctx.evidenceRunDir !== void 0 ? { runDir: ctx.evidenceRunDir } : {},
284836
- writableRoots: submoduleAgentWritableRoots(ports.repoCwd, execRepoCwd, ports.paths.alertsPath),
284837
- ...ctx.model !== void 0 && ctx.model !== "" ? { model: ctx.model } : {},
284838
- env: {
284839
- ...process.env,
284840
- ROLL_LOOP_ALERT: ports.paths.alertsPath,
284841
- ...worktreeGitEnv(execCwd, ports.repoCwd),
284842
- ...agentSpawnEnvironment(cmd.agent)
284843
- },
284844
- // FIX-204B: pin the executor-picked story into the agent prompt — the
284845
- // claim (pick_story 🔨) and the work must be the same story.
284846
- ...ctx.storyId !== void 0 && ctx.storyId !== "" ? { storyId: ctx.storyId } : {},
284847
- onChunk: (d) => {
284848
- timeoutWatchdog.markProgress();
284849
- stallDetector.markProgress();
284850
- captureSink?.onChunk(d);
284851
- try {
284852
- appendFileSync16(livePath, d);
284853
- } catch {
284931
+ res = await Promise.race([
284932
+ ports.agentSpawn(cmd.agent, {
284933
+ purpose: "builder",
284934
+ // E4: the builder runs in the submodule cycle worktree for a submodule
284935
+ // story (execCwd); its git env + writable roots target the submodule's
284936
+ // own repo/object store so its commits actually land there.
284937
+ cwd: execCwd,
284938
+ skillBody: finalSkillBody,
284939
+ ...ctx.evidenceRunDir !== void 0 ? { runDir: ctx.evidenceRunDir } : {},
284940
+ writableRoots: submoduleAgentWritableRoots(ports.repoCwd, execRepoCwd, ports.paths.alertsPath),
284941
+ ...ctx.model !== void 0 && ctx.model !== "" ? { model: ctx.model } : {},
284942
+ env: {
284943
+ ...process.env,
284944
+ ROLL_LOOP_ALERT: ports.paths.alertsPath,
284945
+ ...worktreeGitEnv(execCwd, ports.repoCwd),
284946
+ ...agentSpawnEnvironment(cmd.agent)
284947
+ },
284948
+ // FIX-204B: pin the executor-picked story into the agent prompt the
284949
+ // claim (pick_story 🔨) and the work must be the same story.
284950
+ ...ctx.storyId !== void 0 && ctx.storyId !== "" ? { storyId: ctx.storyId } : {},
284951
+ onChunk: (d) => {
284952
+ timeoutWatchdog.markProgress();
284953
+ stallDetector.markProgress();
284954
+ captureSink?.onChunk(d);
284955
+ try {
284956
+ appendFileSync16(livePath, d);
284957
+ } catch {
284958
+ }
284959
+ signalRecorder.accept(d);
284960
+ },
284961
+ // FIX-1474: hand the live child to the liveness probe so an
284962
+ // out-of-band death is detected within a bounded window.
284963
+ onSpawn: (child) => {
284964
+ spawnedPid = child.pid;
284854
284965
  }
284855
- signalRecorder.accept(d);
284856
- }
284857
- });
284966
+ }),
284967
+ lostRace
284968
+ ]);
284858
284969
  } finally {
284970
+ spawnSettled = true;
284971
+ livenessProbe.stop();
284859
284972
  if (mainLeakWatchdog !== void 0) {
284860
284973
  activeMainLeak = await mainLeakWatchdog.stop();
284861
284974
  }
@@ -284963,7 +285076,7 @@ ${res.stderr}`.split("\n").find((l) => l.trim() !== "") ?? "").slice(0, 200);
284963
285076
  // FIX-343 (step ①): the builder session id is part of the auditable
284964
285077
  // header — the attest gate's scorer≠builder-session invariant is then
284965
285078
  // traceable to a recorded build-session id, not asserted.
284966
- `# exit=${res.exitCode} timedOut=${res.timedOut} build-session=${builderSessionId}
285079
+ `# exit=${res.exitCode} timedOut=${res.timedOut} lost=${agentLost} build-session=${builderSessionId}
284967
285080
  --- stdout ---
284968
285081
  ${res.stdout}
284969
285082
  --- stderr ---
@@ -285011,7 +285124,10 @@ ${res.stderr}
285011
285124
  cycleStartSec: ctx.startSec
285012
285125
  }) ?? void 0 : void 0;
285013
285126
  return {
285014
- event: { type: "agent_exited", exit: res.exitCode, timedOut: res.timedOut },
285127
+ // FIX-1474: a lost child converges the cycle to the explicit `aborted`
285128
+ // terminal in the orchestrator (no retry, no blocked) — the death was
285129
+ // environmental, recorded fail-loud via cycle:agent_lost.
285130
+ event: { type: "agent_exited", exit: res.exitCode, timedOut: res.timedOut, ...agentLost ? { lost: true } : {} },
285015
285131
  // FIX-343 (step ①): persist the builder session id on the cycle context so
285016
285132
  // it survives to the attest gate (the scorer≠builder-session invariant is then
285017
285133
  // traceable to a recorded build-session id, not asserted).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "4.721.1",
3
+ "version": "4.721.2",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "packageManager": "pnpm@11.1.3",
6
6
  "scripts": {