@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.mjs CHANGED
@@ -111,7 +111,7 @@ import {
111
111
  unsendLatest,
112
112
  usageLine,
113
113
  validateTaskEdit
114
- } from "./chunk-DMG4ISUG.mjs";
114
+ } from "./chunk-O6LQU3W6.mjs";
115
115
  import {
116
116
  PLAN_ENVELOPE_KEY,
117
117
  PlanParseError,
@@ -9072,6 +9072,8 @@ var RunnerProcess = class {
9072
9072
  markEnded;
9073
9073
  child = null;
9074
9074
  exitStatus = null;
9075
+ /** The process itself reported its end, before its pipes drained; see {@link hasExited}. */
9076
+ exitReported = false;
9075
9077
  stderrTail = "";
9076
9078
  spawnEnv = {};
9077
9079
  /** Stdout readers' end-of-stream flushes, for an end the pipe itself never reports. */
@@ -9122,6 +9124,7 @@ var RunnerProcess = class {
9122
9124
  });
9123
9125
  child.on("close", (code, signal) => this.end({ code, signal }));
9124
9126
  child.once("exit", (code, signal) => {
9127
+ this.exitReported = true;
9125
9128
  const grace = setTimeout(() => {
9126
9129
  for (const flush of [...this.stdoutEnds]) flush();
9127
9130
  this.end({ code, signal });
@@ -9176,6 +9179,15 @@ ${err.message}`);
9176
9179
  get exit() {
9177
9180
  return this.exitStatus;
9178
9181
  }
9182
+ /**
9183
+ * Whether the process has ended, for any reason. Unlike {@link exit} this is
9184
+ * true from its own 'exit', not only once 'close' has drained the pipes: a
9185
+ * runner that has exited can take no further turn even while a descendant
9186
+ * holds its stdout or stderr.
9187
+ */
9188
+ get hasExited() {
9189
+ return this.exitStatus !== null || this.exitReported;
9190
+ }
9179
9191
  /** The exit code a listener is told: -1 for a signal or a spawn failure. */
9180
9192
  get exitCode() {
9181
9193
  return this.exitStatus?.code ?? -1;
@@ -9374,6 +9386,9 @@ var StdioAgentAdapter = class {
9374
9386
  nativeSessionId() {
9375
9387
  return this.sessionId;
9376
9388
  }
9389
+ get exited() {
9390
+ return this.runner.hasExited;
9391
+ }
9377
9392
  onProcessExit(listener) {
9378
9393
  const fire = () => listener(this.runner.exitCode, this.exitMessage());
9379
9394
  if (this.runner.exit) queueMicrotask(fire);
@@ -13187,7 +13202,7 @@ var OpenCodeAdapter = class {
13187
13202
  return this.runner.ended;
13188
13203
  }
13189
13204
  get exited() {
13190
- return this.runner.exit !== null;
13205
+ return this.runner.hasExited;
13191
13206
  }
13192
13207
  async start(opts) {
13193
13208
  try {
@@ -19891,10 +19906,21 @@ var GitWorktreeIsolation = class {
19891
19906
  const entry = record.repos[repo.path];
19892
19907
  if (!entry?.changed) continue;
19893
19908
  const merged = await this.tryGit(repo.root, ["merge-base", "--is-ancestor", entry.landedTip ?? repo.integrationBranch, "HEAD"]);
19894
- if (!merged.ok) return false;
19909
+ if (!merged.ok && !await this.integrationGone(repo)) return false;
19895
19910
  }
19896
19911
  return true;
19897
19912
  }
19913
+ /**
19914
+ * The repo's integration branch is gone — deleted by hand, or by a squash
19915
+ * merge's clean-up — in a repo git can read. Merge all merges that branch by
19916
+ * name, so no merge can bring the work in again, and it is not offered as
19917
+ * work to merge. A repo git cannot read at all (moved, or git failing)
19918
+ * proves nothing either way and counts as not merged.
19919
+ */
19920
+ async integrationGone(repo) {
19921
+ if (!(await this.tryGit(repo.root, ["rev-parse", "--verify", "-q", "HEAD"])).ok) return false;
19922
+ return !(await this.tryGit(repo.root, ["rev-parse", "--verify", "-q", `refs/heads/${repo.integrationBranch}`])).ok;
19923
+ }
19898
19924
  async snapshotTree(workspaceRoot, exclude) {
19899
19925
  const scan = await this.scanGroup(workspaceRoot);
19900
19926
  if ("refused" in scan) return null;
@@ -23042,6 +23068,8 @@ var IsolationRunController = class {
23042
23068
  listener;
23043
23069
  liveTasks;
23044
23070
  run = null;
23071
+ /** An adoption stays scoped even while cleanup temporarily leaves no run record. */
23072
+ adoptions = 0;
23045
23073
  /** Copied paths already reported for the current run: every task gets the same copies. */
23046
23074
  reportedCopies = /* @__PURE__ */ new Set();
23047
23075
  reportedSetupFailures = /* @__PURE__ */ new Set();
@@ -23117,18 +23145,27 @@ var IsolationRunController = class {
23117
23145
  * Take over a plan's persisted isolation, or none for a plan that has not
23118
23146
  * isolated yet. Whatever a crashed process left behind for the run — a
23119
23147
  * worktree still marked active, a directory no record owns — is pruned,
23120
- * while kept, failed and conflicted worktrees stay for the user.
23148
+ * while kept, failed and conflicted worktrees stay for the user. Then git is
23149
+ * asked which landed work the user has merged since the record was saved:
23150
+ * a settled run whose landed work is all in HEAD has nothing left to hand
23151
+ * over, so it is cleared up as Merge all would have, unless it still holds
23152
+ * work that never landed (ADR-0013).
23121
23153
  *
23122
- * Deliberately not reported as a change: adopting is not a change to
23123
- * persist, and a host that adopts without persisting (VS Code's restore)
23124
- * would otherwise write a new session file on every reload.
23154
+ * Deliberately not reported to the listener: adopting is not a change of the
23155
+ * session's own, and a host that adopts without persisting (VS Code's
23156
+ * restore) would otherwise write a new session file on every reload. What
23157
+ * git changed is returned instead, for the caller to save where it can and
23158
+ * announce. A plan loaded meanwhile supersedes this adoption, which then
23159
+ * touches nothing and resolves `unchanged`.
23125
23160
  */
23126
23161
  async adopt(state) {
23162
+ const adoption = ++this.adoptions;
23127
23163
  this.run = state?.run ?? null;
23128
23164
  this.resolvers = { ...state?.resolvers ?? {} };
23129
- if (!this.run) return;
23165
+ const run = this.run;
23166
+ if (!run) return "unchanged";
23130
23167
  try {
23131
- const pruned = await this.isolation.pruneOrphans(this.run);
23168
+ const pruned = await this.isolation.pruneOrphans(run);
23132
23169
  for (const task of pruned.kept) {
23133
23170
  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.`);
23134
23171
  }
@@ -23136,6 +23173,24 @@ var IsolationRunController = class {
23136
23173
  } catch (err) {
23137
23174
  this.tell("warn", `Could not prune leftover worktrees: ${err instanceof Error ? err.message : String(err)}`);
23138
23175
  }
23176
+ if (this.run !== run) return "unchanged";
23177
+ const found = await this.findLanded(run);
23178
+ if (this.run !== run) return "unchanged";
23179
+ const outcome = found ? "changed" : "unchanged";
23180
+ if (this.isOpen || !this.allMerged(run) || this.holdouts(run).length > 0) return outcome;
23181
+ const resolvers = this.resolvers;
23182
+ this.run = null;
23183
+ this.resolvers = {};
23184
+ const discarded = await this.discardMerged(run);
23185
+ if (this.adoptions !== adoption) return "unchanged";
23186
+ if (!discarded) {
23187
+ if (this.run === null) {
23188
+ this.run = run;
23189
+ this.resolvers = resolvers;
23190
+ }
23191
+ return outcome;
23192
+ }
23193
+ return this.isOpen ? "changed" : "cleared";
23139
23194
  }
23140
23195
  /** Open a run if none is, leaving whether it isolates to its first change task. */
23141
23196
  open() {
@@ -23292,8 +23347,7 @@ var IsolationRunController = class {
23292
23347
  const run = this.run;
23293
23348
  if (mode !== "isolated" || !run) return;
23294
23349
  await this.refreshInHead();
23295
- const records = Object.values(run.tasks);
23296
- if (records.some((r) => r.status === "merged") && handoffOf(run).landed.length === 0) {
23350
+ if (this.allMerged(run)) {
23297
23351
  this.tell("info", "Everything this run landed is merged into your branch already.");
23298
23352
  await this.clearMerged(run);
23299
23353
  return;
@@ -23320,11 +23374,21 @@ var IsolationRunController = class {
23320
23374
  */
23321
23375
  async refreshInHead() {
23322
23376
  const run = this.run;
23323
- if (!run || !Object.values(run.tasks).some((r) => r.status === "merged" && !r.inHead)) return false;
23377
+ if (!run) return false;
23378
+ const found = await this.findLanded(run);
23379
+ if (found) this.listener.changed();
23380
+ return found;
23381
+ }
23382
+ /** Mark the run's landed work git finds in HEAD; true when some was found. */
23383
+ async findLanded(run) {
23384
+ if (!Object.values(run.tasks).some((r) => r.status === "merged" && !r.inHead)) return false;
23324
23385
  const found = await this.isolation.findInHead(run).catch(() => []);
23325
- if (found.length > 0) this.listener.changed();
23326
23386
  return found.length > 0;
23327
23387
  }
23388
+ /** The run landed something and every landing is in the user's HEAD. */
23389
+ allMerged(run) {
23390
+ return Object.values(run.tasks).some((r) => r.status === "merged") && handoffOf(run).landed.length === 0;
23391
+ }
23328
23392
  /**
23329
23393
  * The tracked state of the workspace as an attempt whose tree is checked
23330
23394
  * starts (see `attemptKind.checksTree`), for {@link filesChangedSince}
@@ -23446,7 +23510,7 @@ var IsolationRunController = class {
23446
23510
  */
23447
23511
  async clearMerged(run) {
23448
23512
  const live = this.liveRecords(run);
23449
- const unlanded = Object.values(run.tasks).filter((r) => r.status !== "merged" && !live.includes(r));
23513
+ const unlanded = this.holdouts(run).filter((r) => !live.includes(r));
23450
23514
  if (live.length > 0 || unlanded.length > 0) {
23451
23515
  const why = [
23452
23516
  ...live.length > 0 ? [`${tasksNamed(live)} ${live.length === 1 ? "is" : "are"} still running or waiting on you`] : [],
@@ -23456,13 +23520,22 @@ var IsolationRunController = class {
23456
23520
  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.`);
23457
23521
  return;
23458
23522
  }
23523
+ if (await this.discardMerged(run)) this.forget();
23524
+ }
23525
+ /** The records that keep a settled run from being cleared up: live, or holding work that never landed. */
23526
+ holdouts(run) {
23527
+ const live = this.liveTasks();
23528
+ return Object.values(run.tasks).filter((r) => live.has(r.taskId) || r.status !== "merged");
23529
+ }
23530
+ /** The run's worktrees and merged branches go; false, said, when git would not let them. */
23531
+ async discardMerged(run) {
23459
23532
  try {
23460
23533
  this.report(await this.isolation.discard(run, { integration: "delete-merged" }));
23534
+ return true;
23461
23535
  } catch (err) {
23462
23536
  this.tell("warn", `Merged, but could not clean up the run's worktrees and branches: ${err instanceof Error ? err.message : String(err)}`);
23463
- return;
23537
+ return false;
23464
23538
  }
23465
- this.forget();
23466
23539
  }
23467
23540
  /** Say what a removal kept back instead of deleting: where the work went, or which worktree stayed. */
23468
23541
  report(removal) {
@@ -27117,17 +27190,37 @@ var CliAgentAiService = class {
27117
27190
  * `startConversation` reseeds from Ordewell's transcript, which is the same
27118
27191
  * degradation `restoreChat` already performs on every surface.
27119
27192
  *
27193
+ * A process can end while idle without Ordewell stopping it — the OS, the
27194
+ * CLI itself, a sleeping machine, a signal sent to its group — and every
27195
+ * path of ours that kills one also drops its adapter. So an adapter still
27196
+ * held whose process has ended died on its own, and is restarted here
27197
+ * rather than answering the next message with its exit code.
27198
+ *
27120
27199
  * Also restarted when the policy's decision on the runner's own search has
27121
27200
  * changed since the process was spawned: the running thread would otherwise
27122
27201
  * keep searching unasked after Full turned Guarded. Codex 0.162 applies
27123
27202
  * `thread/resume`'s `config.web_search` either way (checked live).
27124
27203
  */
27125
27204
  async ensureAdapter(session) {
27126
- if (session.adapter && session.nativeSearch === this.nativeSearch(session)) return session.adapter;
27205
+ const live = session.adapter;
27206
+ if (live && !live.exited && session.nativeSearch === this.nativeSearch(session)) return live;
27127
27207
  session.stop();
27128
27208
  const start = session.start;
27129
27209
  if (!start) throw new Error("No active planner conversation.");
27130
- return this.startAdapter({ ...start.startOptions, resumeSessionId: session.nativeSessionId ?? void 0, resumeCost: session.sessionCost ?? void 0 }, start.tools, session);
27210
+ const resumeSessionId = session.nativeSessionId ?? void 0;
27211
+ try {
27212
+ const adapter = await this.startAdapter({ ...start.startOptions, resumeSessionId, resumeCost: session.sessionCost ?? void 0 }, start.tools, session);
27213
+ const resumed = adapter.nativeSessionId();
27214
+ if (resumeSessionId && resumed && resumed !== resumeSessionId) {
27215
+ throw new Error("The runner started a different session instead.");
27216
+ }
27217
+ return adapter;
27218
+ } catch (err) {
27219
+ if (!resumeSessionId || session.discarded.aborted) throw err;
27220
+ if (this.session === session) this.session = null;
27221
+ session.discard();
27222
+ 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.`);
27223
+ }
27131
27224
  }
27132
27225
  /**
27133
27226
  * What the planner gets of the repository's config, its runner being
@@ -31590,9 +31683,9 @@ var Session = class {
31590
31683
  runSnapshot() {
31591
31684
  if (!this.plan) return EMPTY_RUN;
31592
31685
  const planned = reduceRun(EMPTY_RUN, { type: "plan_snapshot", plan: surfacePlan({ ...this.plan, tasks: this.store.snapshot() }) });
31593
- const view = reduceRun(planned, this.events.statusMessage());
31594
- const held2 = this.sentHeld ? reduceRun(view, { type: "plan_edit_held", held: this.sentHeld }) : view;
31595
- return this.sentHandoff ? reduceRun(held2, { type: "isolation_handoff", ...this.sentHandoff }) : held2;
31686
+ const handed = this.sentHandoff ? reduceRun(planned, { type: "isolation_handoff", ...this.sentHandoff }) : planned;
31687
+ const view = reduceRun(handed, this.events.statusMessage());
31688
+ return this.sentHeld ? reduceRun(view, { type: "plan_edit_held", held: this.sentHeld }) : view;
31596
31689
  }
31597
31690
  /** The ancestors a task waits on at its merge gate (ADR-0020); empty when it waits for no Merge all. */
31598
31691
  mergeGate(taskId) {
@@ -31782,12 +31875,31 @@ var Session = class {
31782
31875
  }
31783
31876
  carryOverQueuedEdits(plan);
31784
31877
  if (adopting) {
31785
- void this.runs.adopt(plan.isolation ?? null);
31878
+ void this.adoptRun(plan, opts?.persist !== false || opts?.sessionId !== void 0);
31786
31879
  this.sentHandoff = this.owedHandoff();
31787
31880
  }
31788
31881
  if (opts?.persist !== false) this.persist();
31789
31882
  if (this.usage.hasUsage) this.broadcast(this.usage.message());
31790
31883
  }
31884
+ /**
31885
+ * Take the loaded plan's run over, then correct what the surfaces were told
31886
+ * by what git found: the saved record cannot know what the user merged
31887
+ * since, and its handoff would offer Merge all for work already in their
31888
+ * branch. The status update says what landed work is still unmerged, which
31889
+ * a handoff folds to (see `reduceRun`), and `run_closed` that the run was
31890
+ * cleared up. Saved only where the load itself may save (`persists`), so a
31891
+ * restore with no session file to write keeps the correction in memory for
31892
+ * its next save. A plan loaded meanwhile is not this one's to correct.
31893
+ * Starts nothing (ADR-0044).
31894
+ */
31895
+ async adoptRun(plan, persists) {
31896
+ const outcome = await this.runs.adopt(plan.isolation ?? null);
31897
+ if (this.closed || this.plan !== plan || outcome === "unchanged") return;
31898
+ plan.isolation = this.runs.planIsolation ?? void 0;
31899
+ if (persists) this.persist();
31900
+ this.events.status(plan);
31901
+ if (outcome === "cleared") this.broadcast({ type: "run_closed" });
31902
+ }
31791
31903
  destroy() {
31792
31904
  this.closed = true;
31793
31905
  this.dropPlanScope();
@@ -32683,11 +32795,13 @@ var STATE_FILE = "state.json";
32683
32795
  function getStatePath(baseDir) {
32684
32796
  return path33.join(getStateDir(baseDir), STATE_FILE);
32685
32797
  }
32686
- function saveState(plan, baseDir) {
32798
+ var SESSION_ID_KEY = "sessionId";
32799
+ function saveState(plan, baseDir, sessionId) {
32687
32800
  const statePath = getStatePath(baseDir ?? process.cwd());
32688
32801
  ensureDir(path33.dirname(statePath));
32689
32802
  plan.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
32690
- fs17.writeFileSync(statePath, JSON.stringify(plan, null, 2));
32803
+ const file = sessionId ? { ...plan, [SESSION_ID_KEY]: sessionId } : plan;
32804
+ fs17.writeFileSync(statePath, JSON.stringify(file, null, 2));
32691
32805
  }
32692
32806
  function loadState(baseDir, logger = defaultLogger) {
32693
32807
  const dir = baseDir ?? process.cwd();
@@ -32700,10 +32814,12 @@ function loadState(baseDir, logger = defaultLogger) {
32700
32814
  logger.warn("stateStore", `rejecting state from ${statePath}: old format (scalar "runner" instead of "runners" array). Please re-plan.`);
32701
32815
  return null;
32702
32816
  }
32817
+ const sessionId = parsed[SESSION_ID_KEY];
32818
+ delete parsed[SESSION_ID_KEY];
32703
32819
  const plan = parsed;
32704
32820
  migratePlanStateIsolation(plan);
32705
32821
  migrateLoadedTasks(plan.tasks);
32706
- return plan;
32822
+ return typeof sessionId === "string" && sessionId ? { plan, sessionId } : { plan };
32707
32823
  } catch (err) {
32708
32824
  logger.warn("stateStore", `failed to load state from ${statePath}; falling back to empty state`, err);
32709
32825
  return null;