@ordewell/core 0.10.8 → 0.10.9

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/index.js CHANGED
@@ -10811,6 +10811,8 @@ var RunnerProcess = class {
10811
10811
  markEnded;
10812
10812
  child = null;
10813
10813
  exitStatus = null;
10814
+ /** The process itself reported its end, before its pipes drained; see {@link hasExited}. */
10815
+ exitReported = false;
10814
10816
  stderrTail = "";
10815
10817
  spawnEnv = {};
10816
10818
  /** Stdout readers' end-of-stream flushes, for an end the pipe itself never reports. */
@@ -10861,6 +10863,7 @@ var RunnerProcess = class {
10861
10863
  });
10862
10864
  child.on("close", (code, signal) => this.end({ code, signal }));
10863
10865
  child.once("exit", (code, signal) => {
10866
+ this.exitReported = true;
10864
10867
  const grace = setTimeout(() => {
10865
10868
  for (const flush of [...this.stdoutEnds]) flush();
10866
10869
  this.end({ code, signal });
@@ -10915,6 +10918,15 @@ ${err.message}`);
10915
10918
  get exit() {
10916
10919
  return this.exitStatus;
10917
10920
  }
10921
+ /**
10922
+ * Whether the process has ended, for any reason. Unlike {@link exit} this is
10923
+ * true from its own 'exit', not only once 'close' has drained the pipes: a
10924
+ * runner that has exited can take no further turn even while a descendant
10925
+ * holds its stdout or stderr.
10926
+ */
10927
+ get hasExited() {
10928
+ return this.exitStatus !== null || this.exitReported;
10929
+ }
10918
10930
  /** The exit code a listener is told: -1 for a signal or a spawn failure. */
10919
10931
  get exitCode() {
10920
10932
  return this.exitStatus?.code ?? -1;
@@ -11113,6 +11125,9 @@ var StdioAgentAdapter = class {
11113
11125
  nativeSessionId() {
11114
11126
  return this.sessionId;
11115
11127
  }
11128
+ get exited() {
11129
+ return this.runner.hasExited;
11130
+ }
11116
11131
  onProcessExit(listener) {
11117
11132
  const fire = () => listener(this.runner.exitCode, this.exitMessage());
11118
11133
  if (this.runner.exit) queueMicrotask(fire);
@@ -14926,7 +14941,7 @@ var OpenCodeAdapter = class {
14926
14941
  return this.runner.ended;
14927
14942
  }
14928
14943
  get exited() {
14929
- return this.runner.exit !== null;
14944
+ return this.runner.hasExited;
14930
14945
  }
14931
14946
  async start(opts) {
14932
14947
  try {
@@ -23103,10 +23118,21 @@ var GitWorktreeIsolation = class {
23103
23118
  const entry = record.repos[repo.path];
23104
23119
  if (!entry?.changed) continue;
23105
23120
  const merged = await this.tryGit(repo.root, ["merge-base", "--is-ancestor", entry.landedTip ?? repo.integrationBranch, "HEAD"]);
23106
- if (!merged.ok) return false;
23121
+ if (!merged.ok && !await this.integrationGone(repo)) return false;
23107
23122
  }
23108
23123
  return true;
23109
23124
  }
23125
+ /**
23126
+ * The repo's integration branch is gone — deleted by hand, or by a squash
23127
+ * merge's clean-up — in a repo git can read. Merge all merges that branch by
23128
+ * name, so no merge can bring the work in again, and it is not offered as
23129
+ * work to merge. A repo git cannot read at all (moved, or git failing)
23130
+ * proves nothing either way and counts as not merged.
23131
+ */
23132
+ async integrationGone(repo) {
23133
+ if (!(await this.tryGit(repo.root, ["rev-parse", "--verify", "-q", "HEAD"])).ok) return false;
23134
+ return !(await this.tryGit(repo.root, ["rev-parse", "--verify", "-q", `refs/heads/${repo.integrationBranch}`])).ok;
23135
+ }
23110
23136
  async snapshotTree(workspaceRoot, exclude) {
23111
23137
  const scan = await this.scanGroup(workspaceRoot);
23112
23138
  if ("refused" in scan) return null;
@@ -26321,6 +26347,8 @@ var IsolationRunController = class {
26321
26347
  listener;
26322
26348
  liveTasks;
26323
26349
  run = null;
26350
+ /** An adoption stays scoped even while cleanup temporarily leaves no run record. */
26351
+ adoptions = 0;
26324
26352
  /** Copied paths already reported for the current run: every task gets the same copies. */
26325
26353
  reportedCopies = /* @__PURE__ */ new Set();
26326
26354
  reportedSetupFailures = /* @__PURE__ */ new Set();
@@ -26396,18 +26424,27 @@ var IsolationRunController = class {
26396
26424
  * Take over a plan's persisted isolation, or none for a plan that has not
26397
26425
  * isolated yet. Whatever a crashed process left behind for the run — a
26398
26426
  * worktree still marked active, a directory no record owns — is pruned,
26399
- * while kept, failed and conflicted worktrees stay for the user.
26427
+ * while kept, failed and conflicted worktrees stay for the user. Then git is
26428
+ * asked which landed work the user has merged since the record was saved:
26429
+ * a settled run whose landed work is all in HEAD has nothing left to hand
26430
+ * over, so it is cleared up as Merge all would have, unless it still holds
26431
+ * work that never landed (ADR-0013).
26400
26432
  *
26401
- * Deliberately not reported as a change: adopting is not a change to
26402
- * persist, and a host that adopts without persisting (VS Code's restore)
26403
- * would otherwise write a new session file on every reload.
26433
+ * Deliberately not reported to the listener: adopting is not a change of the
26434
+ * session's own, and a host that adopts without persisting (VS Code's
26435
+ * restore) would otherwise write a new session file on every reload. What
26436
+ * git changed is returned instead, for the caller to save where it can and
26437
+ * announce. A plan loaded meanwhile supersedes this adoption, which then
26438
+ * touches nothing and resolves `unchanged`.
26404
26439
  */
26405
26440
  async adopt(state) {
26441
+ const adoption = ++this.adoptions;
26406
26442
  this.run = state?.run ?? null;
26407
26443
  this.resolvers = { ...state?.resolvers ?? {} };
26408
- if (!this.run) return;
26444
+ const run = this.run;
26445
+ if (!run) return "unchanged";
26409
26446
  try {
26410
- const pruned = await this.isolation.pruneOrphans(this.run);
26447
+ const pruned = await this.isolation.pruneOrphans(run);
26411
26448
  for (const task of pruned.kept) {
26412
26449
  this.tell("warn", `Task "${task.title}" was still holding unlanded work when this plan was re-opened, so its worktree and branch were kept. Retry it to land the work, or review it by hand.`);
26413
26450
  }
@@ -26415,6 +26452,24 @@ var IsolationRunController = class {
26415
26452
  } catch (err) {
26416
26453
  this.tell("warn", `Could not prune leftover worktrees: ${err instanceof Error ? err.message : String(err)}`);
26417
26454
  }
26455
+ if (this.run !== run) return "unchanged";
26456
+ const found = await this.findLanded(run);
26457
+ if (this.run !== run) return "unchanged";
26458
+ const outcome = found ? "changed" : "unchanged";
26459
+ if (this.isOpen || !this.allMerged(run) || this.holdouts(run).length > 0) return outcome;
26460
+ const resolvers = this.resolvers;
26461
+ this.run = null;
26462
+ this.resolvers = {};
26463
+ const discarded = await this.discardMerged(run);
26464
+ if (this.adoptions !== adoption) return "unchanged";
26465
+ if (!discarded) {
26466
+ if (this.run === null) {
26467
+ this.run = run;
26468
+ this.resolvers = resolvers;
26469
+ }
26470
+ return outcome;
26471
+ }
26472
+ return this.isOpen ? "changed" : "cleared";
26418
26473
  }
26419
26474
  /** Open a run if none is, leaving whether it isolates to its first change task. */
26420
26475
  open() {
@@ -26571,8 +26626,7 @@ var IsolationRunController = class {
26571
26626
  const run = this.run;
26572
26627
  if (mode !== "isolated" || !run) return;
26573
26628
  await this.refreshInHead();
26574
- const records = Object.values(run.tasks);
26575
- if (records.some((r) => r.status === "merged") && handoffOf(run).landed.length === 0) {
26629
+ if (this.allMerged(run)) {
26576
26630
  this.tell("info", "Everything this run landed is merged into your branch already.");
26577
26631
  await this.clearMerged(run);
26578
26632
  return;
@@ -26599,11 +26653,21 @@ var IsolationRunController = class {
26599
26653
  */
26600
26654
  async refreshInHead() {
26601
26655
  const run = this.run;
26602
- if (!run || !Object.values(run.tasks).some((r) => r.status === "merged" && !r.inHead)) return false;
26656
+ if (!run) return false;
26657
+ const found = await this.findLanded(run);
26658
+ if (found) this.listener.changed();
26659
+ return found;
26660
+ }
26661
+ /** Mark the run's landed work git finds in HEAD; true when some was found. */
26662
+ async findLanded(run) {
26663
+ if (!Object.values(run.tasks).some((r) => r.status === "merged" && !r.inHead)) return false;
26603
26664
  const found = await this.isolation.findInHead(run).catch(() => []);
26604
- if (found.length > 0) this.listener.changed();
26605
26665
  return found.length > 0;
26606
26666
  }
26667
+ /** The run landed something and every landing is in the user's HEAD. */
26668
+ allMerged(run) {
26669
+ return Object.values(run.tasks).some((r) => r.status === "merged") && handoffOf(run).landed.length === 0;
26670
+ }
26607
26671
  /**
26608
26672
  * The tracked state of the workspace as an attempt whose tree is checked
26609
26673
  * starts (see `attemptKind.checksTree`), for {@link filesChangedSince}
@@ -26725,7 +26789,7 @@ var IsolationRunController = class {
26725
26789
  */
26726
26790
  async clearMerged(run) {
26727
26791
  const live2 = this.liveRecords(run);
26728
- const unlanded = Object.values(run.tasks).filter((r) => r.status !== "merged" && !live2.includes(r));
26792
+ const unlanded = this.holdouts(run).filter((r) => !live2.includes(r));
26729
26793
  if (live2.length > 0 || unlanded.length > 0) {
26730
26794
  const why = [
26731
26795
  ...live2.length > 0 ? [`${tasksNamed(live2)} ${live2.length === 1 ? "is" : "are"} still running or waiting on you`] : [],
@@ -26735,13 +26799,22 @@ var IsolationRunController = class {
26735
26799
  this.tell("info", `The run is not cleared up: ${why.join(", and ")}, so ${one ? "its worktree stays" : "their worktrees stay"}, and so do the run's branches.`);
26736
26800
  return;
26737
26801
  }
26802
+ if (await this.discardMerged(run)) this.forget();
26803
+ }
26804
+ /** The records that keep a settled run from being cleared up: live, or holding work that never landed. */
26805
+ holdouts(run) {
26806
+ const live2 = this.liveTasks();
26807
+ return Object.values(run.tasks).filter((r) => live2.has(r.taskId) || r.status !== "merged");
26808
+ }
26809
+ /** The run's worktrees and merged branches go; false, said, when git would not let them. */
26810
+ async discardMerged(run) {
26738
26811
  try {
26739
26812
  this.report(await this.isolation.discard(run, { integration: "delete-merged" }));
26813
+ return true;
26740
26814
  } catch (err) {
26741
26815
  this.tell("warn", `Merged, but could not clean up the run's worktrees and branches: ${err instanceof Error ? err.message : String(err)}`);
26742
- return;
26816
+ return false;
26743
26817
  }
26744
- this.forget();
26745
26818
  }
26746
26819
  /** Say what a removal kept back instead of deleting: where the work went, or which worktree stayed. */
26747
26820
  report(removal) {
@@ -30396,17 +30469,37 @@ var CliAgentAiService = class {
30396
30469
  * `startConversation` reseeds from Ordewell's transcript, which is the same
30397
30470
  * degradation `restoreChat` already performs on every surface.
30398
30471
  *
30472
+ * A process can end while idle without Ordewell stopping it — the OS, the
30473
+ * CLI itself, a sleeping machine, a signal sent to its group — and every
30474
+ * path of ours that kills one also drops its adapter. So an adapter still
30475
+ * held whose process has ended died on its own, and is restarted here
30476
+ * rather than answering the next message with its exit code.
30477
+ *
30399
30478
  * Also restarted when the policy's decision on the runner's own search has
30400
30479
  * changed since the process was spawned: the running thread would otherwise
30401
30480
  * keep searching unasked after Full turned Guarded. Codex 0.162 applies
30402
30481
  * `thread/resume`'s `config.web_search` either way (checked live).
30403
30482
  */
30404
30483
  async ensureAdapter(session) {
30405
- if (session.adapter && session.nativeSearch === this.nativeSearch(session)) return session.adapter;
30484
+ const live2 = session.adapter;
30485
+ if (live2 && !live2.exited && session.nativeSearch === this.nativeSearch(session)) return live2;
30406
30486
  session.stop();
30407
30487
  const start = session.start;
30408
30488
  if (!start) throw new Error("No active planner conversation.");
30409
- return this.startAdapter({ ...start.startOptions, resumeSessionId: session.nativeSessionId ?? void 0, resumeCost: session.sessionCost ?? void 0 }, start.tools, session);
30489
+ const resumeSessionId = session.nativeSessionId ?? void 0;
30490
+ try {
30491
+ const adapter = await this.startAdapter({ ...start.startOptions, resumeSessionId, resumeCost: session.sessionCost ?? void 0 }, start.tools, session);
30492
+ const resumed = adapter.nativeSessionId();
30493
+ if (resumeSessionId && resumed && resumed !== resumeSessionId) {
30494
+ throw new Error("The runner started a different session instead.");
30495
+ }
30496
+ return adapter;
30497
+ } catch (err) {
30498
+ if (!resumeSessionId || session.discarded.aborted) throw err;
30499
+ if (this.session === session) this.session = null;
30500
+ session.discard();
30501
+ throw new Error(`The ${this.runner} planner's session could not be resumed (${err instanceof Error ? err.message : String(err)}). Send your message again: the planner picks the conversation up from its transcript.`);
30502
+ }
30410
30503
  }
30411
30504
  /**
30412
30505
  * What the planner gets of the repository's config, its runner being
@@ -31532,6 +31625,12 @@ function withoutIsolation(tasks) {
31532
31625
  function handoffOf2({ type: _type, ...handoff }) {
31533
31626
  return handoff;
31534
31627
  }
31628
+ function stillToMerge(handoff, runMerge) {
31629
+ if (!handoff?.landed.length) return handoff;
31630
+ if (!runMerge) return null;
31631
+ const same = runMerge.landed.length === handoff.landed.length && runMerge.landed.every((t, i) => t.taskId === handoff.landed[i].taskId);
31632
+ return same ? handoff : { ...handoff, repos: runMerge.repos, landed: runMerge.landed };
31633
+ }
31535
31634
  function planTasks(plan) {
31536
31635
  if ("tasks" in plan) return plan.tasks;
31537
31636
  return rejoinPlanTree(plan.pendingTasks, plan.phase === "executing" ? plan.executionLog : []);
@@ -31552,6 +31651,7 @@ function reduceRun(view, input) {
31552
31651
  ...view,
31553
31652
  tasks: applyStatus(view.tasks, new Map(input.tasks.map((t) => [t.id, t]))),
31554
31653
  runMerge: input.runMerge ?? null,
31654
+ handoff: stillToMerge(view.handoff, input.runMerge),
31555
31655
  usage: input.usage
31556
31656
  };
31557
31657
  case "task_started":
@@ -35035,9 +35135,9 @@ var Session = class {
35035
35135
  runSnapshot() {
35036
35136
  if (!this.plan) return EMPTY_RUN;
35037
35137
  const planned = reduceRun(EMPTY_RUN, { type: "plan_snapshot", plan: surfacePlan({ ...this.plan, tasks: this.store.snapshot() }) });
35038
- const view = reduceRun(planned, this.events.statusMessage());
35039
- const held2 = this.sentHeld ? reduceRun(view, { type: "plan_edit_held", held: this.sentHeld }) : view;
35040
- return this.sentHandoff ? reduceRun(held2, { type: "isolation_handoff", ...this.sentHandoff }) : held2;
35138
+ const handed = this.sentHandoff ? reduceRun(planned, { type: "isolation_handoff", ...this.sentHandoff }) : planned;
35139
+ const view = reduceRun(handed, this.events.statusMessage());
35140
+ return this.sentHeld ? reduceRun(view, { type: "plan_edit_held", held: this.sentHeld }) : view;
35041
35141
  }
35042
35142
  /** The ancestors a task waits on at its merge gate (ADR-0020); empty when it waits for no Merge all. */
35043
35143
  mergeGate(taskId) {
@@ -35227,12 +35327,31 @@ var Session = class {
35227
35327
  }
35228
35328
  carryOverQueuedEdits(plan);
35229
35329
  if (adopting) {
35230
- void this.runs.adopt(plan.isolation ?? null);
35330
+ void this.adoptRun(plan, opts?.persist !== false || opts?.sessionId !== void 0);
35231
35331
  this.sentHandoff = this.owedHandoff();
35232
35332
  }
35233
35333
  if (opts?.persist !== false) this.persist();
35234
35334
  if (this.usage.hasUsage) this.broadcast(this.usage.message());
35235
35335
  }
35336
+ /**
35337
+ * Take the loaded plan's run over, then correct what the surfaces were told
35338
+ * by what git found: the saved record cannot know what the user merged
35339
+ * since, and its handoff would offer Merge all for work already in their
35340
+ * branch. The status update says what landed work is still unmerged, which
35341
+ * a handoff folds to (see `reduceRun`), and `run_closed` that the run was
35342
+ * cleared up. Saved only where the load itself may save (`persists`), so a
35343
+ * restore with no session file to write keeps the correction in memory for
35344
+ * its next save. A plan loaded meanwhile is not this one's to correct.
35345
+ * Starts nothing (ADR-0044).
35346
+ */
35347
+ async adoptRun(plan, persists) {
35348
+ const outcome = await this.runs.adopt(plan.isolation ?? null);
35349
+ if (this.closed || this.plan !== plan || outcome === "unchanged") return;
35350
+ plan.isolation = this.runs.planIsolation ?? void 0;
35351
+ if (persists) this.persist();
35352
+ this.events.status(plan);
35353
+ if (outcome === "cleared") this.broadcast({ type: "run_closed" });
35354
+ }
35236
35355
  destroy() {
35237
35356
  this.closed = true;
35238
35357
  this.dropPlanScope();
@@ -36810,11 +36929,13 @@ var STATE_FILE = "state.json";
36810
36929
  function getStatePath(baseDir) {
36811
36930
  return path34.join(getStateDir(baseDir), STATE_FILE);
36812
36931
  }
36813
- function saveState(plan, baseDir) {
36932
+ var SESSION_ID_KEY = "sessionId";
36933
+ function saveState(plan, baseDir, sessionId) {
36814
36934
  const statePath = getStatePath(baseDir ?? process.cwd());
36815
36935
  ensureDir(path34.dirname(statePath));
36816
36936
  plan.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
36817
- fs17.writeFileSync(statePath, JSON.stringify(plan, null, 2));
36937
+ const file = sessionId ? { ...plan, [SESSION_ID_KEY]: sessionId } : plan;
36938
+ fs17.writeFileSync(statePath, JSON.stringify(file, null, 2));
36818
36939
  }
36819
36940
  function loadState(baseDir, logger = defaultLogger) {
36820
36941
  const dir = baseDir ?? process.cwd();
@@ -36827,10 +36948,12 @@ function loadState(baseDir, logger = defaultLogger) {
36827
36948
  logger.warn("stateStore", `rejecting state from ${statePath}: old format (scalar "runner" instead of "runners" array). Please re-plan.`);
36828
36949
  return null;
36829
36950
  }
36951
+ const sessionId = parsed[SESSION_ID_KEY];
36952
+ delete parsed[SESSION_ID_KEY];
36830
36953
  const plan = parsed;
36831
36954
  migratePlanStateIsolation(plan);
36832
36955
  migrateLoadedTasks(plan.tasks);
36833
- return plan;
36956
+ return typeof sessionId === "string" && sessionId ? { plan, sessionId } : { plan };
36834
36957
  } catch (err) {
36835
36958
  logger.warn("stateStore", `failed to load state from ${statePath}; falling back to empty state`, err);
36836
36959
  return null;