toolroll 0.9.28 → 0.9.29

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
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.29 — 2026-10-04
4
+
5
+ - **Tasks filed from the command line get sized too.** `task scope` now sizes
6
+ the task from its goal before the plan is sealed, so work the lead files
7
+ gets the same fast-or-strong model choice as work filed in the console.
8
+ - **A reused process ID no longer blocks finishing a release.** Once
9
+ Toolroll has recorded that a process exited, a new program taking its
10
+ number doesn't count as the old one. Leftover process records close when the
11
+ run ends, and a wait that clears by itself says so instead of asking you to
12
+ confirm.
13
+ - **Status shows each task once.** A task that is building appears only under
14
+ Building, an approved plan no longer reads as waiting for review, and risky
15
+ routes say "Risky" instead of "routine".
16
+
3
17
  ## 0.9.28 — 2026-10-04
4
18
 
5
19
  - **Each task gets the right model.** When a task is filed, a fast
@@ -114,19 +114,21 @@ export function taskWaitSnapshot(store, taskId, now, watchedRun) {
114
114
  const next = holdKind === "operator" ? "Remove hold" : holdKind === "revision" ? "Review plan" : holdKind === "stop" ? "Resume or close attempt" : holdKind === "contest" ? "Choose a result" : "Review task";
115
115
  return answer("Needs a person", next, true, 1, "needs-person");
116
116
  }
117
- if (task["plan"] === "drafted")
118
- return answer("Needs a person", "Approve plan", true, 1, "needs-person");
119
- if (Number(task["strikes"] ?? 0) >= 3)
120
- return answer("Needs a person", "Review repeated failures", true, 1, "needs-person");
121
117
  // An admitted attempt is already the work being watched. Its immutable
122
118
  // approval snapshot lives on the run, so a damaged or later-edited current
123
119
  // scope must not make `wait` abandon that attempt before it settles.
124
120
  if (attempt !== undefined)
125
121
  return answer("Running", phase === null ? "Wait for attempt" : `Wait — ${phase}`, false, null, null);
126
- const scope = store.handle.prepare("SELECT digest, approved_digest, profile_state FROM task_scope WHERE task_id = ?").get(taskId);
122
+ const scope = store.handle.prepare(`SELECT digest, approved_digest, profile_state,
123
+ (approved_at IS NOT NULL AND approved_by IS NOT NULL AND approved_digest = digest) AS approved
124
+ FROM task_scope WHERE task_id = ?`).get(taskId);
125
+ if (task["plan"] === "drafted" && Number(scope?.["approved"]) !== 1)
126
+ return answer("Needs a person", "Approve plan", true, 1, "needs-person");
127
+ if (Number(task["strikes"] ?? 0) >= 3)
128
+ return answer("Needs a person", "Review repeated failures", true, 1, "needs-person");
127
129
  if (scope === undefined)
128
130
  return answer("Needs a person", "Add scope", true, 1, "needs-person");
129
- if (scope["approved_digest"] !== scope["digest"] || scope["profile_state"] !== "resolved")
131
+ if (Number(scope["approved"]) !== 1 || scope["profile_state"] !== "resolved")
130
132
  return answer("Needs a person", "Approve task", true, 1, "needs-person");
131
133
  if (state === "running" && attempt === undefined) {
132
134
  const liveClaim = store.handle.prepare(`SELECT 1 AS hit FROM claim INDEXED BY claim_by_task
@@ -157,7 +159,7 @@ function queueReason(row) {
157
159
  return "needs a result choice";
158
160
  if (hold === "backoff")
159
161
  return "retrying later";
160
- if (row["plan"] === "drafted")
162
+ if (row["plan"] === "drafted" && Number(row["approved"]) !== 1)
161
163
  return "needs plan review";
162
164
  if (Number(row["strikes"] ?? 0) >= 3)
163
165
  return "stalled after failures";
@@ -165,7 +167,7 @@ function queueReason(row) {
165
167
  return "waiting for another task";
166
168
  if (row["scope_digest"] == null)
167
169
  return "needs a scope";
168
- if (row["approved_digest"] !== row["scope_digest"] || row["profile_state"] !== "resolved")
170
+ if (Number(row["approved"]) !== 1 || row["profile_state"] !== "resolved")
169
171
  return "needs approval";
170
172
  return "ready for a worker";
171
173
  }
@@ -181,7 +183,7 @@ export function repairStaleStatuses(store, now) {
181
183
  }
182
184
  /** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead. */
183
185
  export function installationStatus(store, now, viewer = currentActor()?.account ?? null) {
184
- const runningRows = store.handle.prepare(`SELECT run.id, run.role, run.phase, ref.external_id AS task
186
+ const runningRows = store.handle.prepare(`SELECT run.id, run.role, run.phase, ref.id AS task_ref, ref.external_id AS task
185
187
  FROM run INDEXED BY work_unfinished
186
188
  JOIN task_ref AS ref ON ref.id = run.task_ref
187
189
  JOIN claim ON claim.lease_id = run.lease_id
@@ -194,8 +196,10 @@ export function installationStatus(store, now, viewer = currentActor()?.account
194
196
  phases.set(phase, (phases.get(phase) ?? 0) + 1);
195
197
  return { task: String(row["task"]), run: Number(row["id"]), phase };
196
198
  });
197
- const queuedRows = store.handle.prepare(`SELECT task.id, ref.plan, ref.strikes, scope.digest AS scope_digest,
198
- scope.approved_digest, scope.profile_state,
199
+ const runningRefs = new Set(runningRows.map(row => Number(row["task_ref"])));
200
+ const queuedRows = store.handle.prepare(`SELECT task.id, ref.id AS task_ref, ref.plan, ref.strikes, scope.digest AS scope_digest,
201
+ scope.profile_state,
202
+ (scope.approved_at IS NOT NULL AND scope.approved_by IS NOT NULL AND scope.approved_digest = scope.digest) AS approved,
199
203
  EXISTS (SELECT 1 FROM task_edge WHERE task_edge.blocked = task.id
200
204
  AND EXISTS (SELECT 1 FROM task AS blocker WHERE blocker.id = task_edge.blocker AND blocker.state <> 'done')) AS blocked,
201
205
  (SELECT owner_kind FROM hold INDEXED BY hold_by_task WHERE hold.task_ref = ref.id AND (hold.until IS NULL OR hold.until > ?)
@@ -203,7 +207,8 @@ export function installationStatus(store, now, viewer = currentActor()?.account
203
207
  FROM task INDEXED BY task_by_state
204
208
  JOIN task_ref AS ref INDEXED BY sqlite_autoindex_task_ref_1 ON ref.backend = ? AND ref.external_id = task.id
205
209
  LEFT JOIN task_scope AS scope ON scope.task_id = task.id
206
- WHERE task.state = 'queued'`).all(now.toISOString(), BUILT_IN);
210
+ WHERE task.state = 'queued'`).all(now.toISOString(), BUILT_IN)
211
+ .filter(row => !runningRefs.has(Number(row["task_ref"])));
207
212
  const reasons = new Map();
208
213
  const queuedTasks = [];
209
214
  const pauses = openAuthPauses(store);
@@ -8,7 +8,7 @@
8
8
  /** What a person is asked for. */
9
9
  export type NeedKey = "approval" | "card" | "answer" | "questions" | "sign-in" | "confirm-stopped" | "check-stopped" | "hold" | "choose-project" | "define-task" | "fix-request" | "choose-agent" | "fix-dependency" | "add-requirement" | "connect-builder" | "start-builder" | "vanished" | "review-result" | "rebuild" | "earlier-version" | "other";
10
10
  /** What the work waits for when no person can act. */
11
- export type WaitKey = "build-stopping" | "other-computer" | "card-reply" | "card-time" | "card-ci";
11
+ export type WaitKey = "build-stopping" | "build-finishing" | "other-computer" | "card-reply" | "card-time" | "card-ci";
12
12
  /** The action's code: the WorkAction that owns the act, or one of the acts a surface renders itself. */
13
13
  export type NeedActionCode = "approve-scope" | "approve-card" | "answer-decision" | "inspect-decisions" | "sign-in" | "confirm-stopped" | "inspect-hold" | "place-task" | "write-scope" | "select-agent" | "repair-dependency" | "repair-capability" | "start-worker" | "reconcile-run" | "open-result" | "retry-task" | "inspect-task";
14
14
  export type NeedAction = {
@@ -73,7 +73,7 @@ export declare function waitSentence(key: WaitKey | undefined, reason: string |
73
73
  * `unknown`: Toolroll can't check, so the person confirms they checked. */
74
74
  export declare function processNeedOf(fact: {
75
75
  run: number;
76
- kind: "open" | "alive" | "elsewhere" | "unprovable" | "unknown";
76
+ kind: "open" | "alive" | "elsewhere" | "unprovable" | "unknown" | "settling";
77
77
  } | null): {
78
78
  need: "confirm-stopped" | "check-stopped";
79
79
  build: number;
package/dist/needs-you.js CHANGED
@@ -79,6 +79,7 @@ export const NEED_ASK = {
79
79
  "add-requirement": "unblock", "connect-builder": "unblock", "start-builder": "unblock", vanished: "unblock", "earlier-version": "unblock", other: "unblock",
80
80
  };
81
81
  export const WAITS = {
82
+ "build-finishing": () => "Finishing up; this clears on its own.",
82
83
  "build-stopping": context => `Waiting for ${build(context)} to stop. Nothing is needed from you.`,
83
84
  "other-computer": context => `Waiting for the computer that ran ${build(context)} to confirm it stopped.`,
84
85
  "card-reply": () => "Waiting for a reply.",
@@ -110,6 +111,8 @@ export function waitSentence(key, reason, context = {}) {
110
111
  export function processNeedOf(fact) {
111
112
  if (fact === null)
112
113
  return null;
114
+ if (fact.kind === "settling")
115
+ return { wait: "build-finishing", build: fact.run };
113
116
  if (fact.kind === "unprovable")
114
117
  return { need: "confirm-stopped", build: fact.run };
115
118
  if (fact.kind === "unknown")
package/dist/operate.js CHANGED
@@ -26,7 +26,7 @@ import { checkIntegrations, integrationsBrokenLine, integrationsNow, renderInteg
26
26
  import { buildHandoff, handoffLines, loginAccount, runOnboard } from "./agent-onboard.js";
27
27
  import { backupFiles, backupFolderOf, backupOwner, backupNow, restoreDatabase, startBackups } from "./backup.js";
28
28
  import { pushLimitSink } from "./provider-limits.js";
29
- import { installFilingSizer, ownerSizer, settleSizings } from "./task-sizing.js";
29
+ import { heuristicSizing, installFilingSizer, ownerSizer, refineFiledSizing, settleSizings } from "./task-sizing.js";
30
30
  import { tierLines, tierReport } from "./tier-report.js";
31
31
  import { limitsView } from "./limits-ui.js";
32
32
  import { startCodexLimits } from "./codex-limits.js";
@@ -11110,7 +11110,7 @@ async function syncCommand(flags, context) {
11110
11110
  * separate acts by, usually, separate parties: an agent may draft a scope, and
11111
11111
  * only a person may agree to it.
11112
11112
  */
11113
- function scopeTask(positional, flags, context) {
11113
+ async function scopeTask(positional, flags, context) {
11114
11114
  const { store, write, json, now } = context;
11115
11115
  const id = positional[0];
11116
11116
  const goal = text(flags, "goal");
@@ -11265,6 +11265,27 @@ function scopeTask(positional, flags, context) {
11265
11265
  const actor = asGiven !== undefined && tokenGiven !== undefined && authenticateApprover(store, asGiven, tokenGiven).ok
11266
11266
  ? asGiven
11267
11267
  : null;
11268
+ // Size the new goal before filing its route. A replay must not classify
11269
+ // again or change the task behind the original answer. This probe does
11270
+ // not record a result; the composite filing below still owns the receipt.
11271
+ let refinement = null;
11272
+ store.replay(mutationFrom(flags, now), "task-scope-filed", () => {
11273
+ const ref = store.lookupRef(id);
11274
+ const previous = store.getScope(id);
11275
+ if (ref === null || ref.deliverable === "report" || candidateGiven !== undefined ||
11276
+ ref.sizing?.source === "person" || approvalOf(previous).approved ||
11277
+ previous?.proposedVia === "coordinator" || store.filedViaOf(id)?.startsWith("mcp:") ||
11278
+ ((plannedRace !== null || plannedComparison !== null) && store.openAuthorizationFor(ref.id) !== null))
11279
+ return null;
11280
+ const input = { title: store.getTask(id).title, goal, outOfScope: text(flags, "not") ?? null, touches };
11281
+ const sized = store.applySizing(id, heuristicSizing(input), { followPlanning: true }, now);
11282
+ if (sized.ok)
11283
+ refinement = refineFiledSizing(store, id, input, true, context.clock);
11284
+ return null;
11285
+ }, () => false);
11286
+ // Wait for bounded classification before filing so any approval in the
11287
+ // transaction below binds the final size and route.
11288
+ await refinement;
11268
11289
  // ONE replayed composite (surfaces round 1, finding 3): the filing, any
11269
11290
  // race/comparison terms, AND the mode seal record as a single operation —
11270
11291
  // a replayed key returns the FIRST answer whole instead of re-sealing
@@ -806,7 +806,7 @@ export function projectRoute(route, readiness) {
806
806
  return {
807
807
  digest: routeDigestOf(route),
808
808
  risk: route.risk,
809
- riskTitle: riskTitle(route.risk),
809
+ riskTitle: route.risk === "routine" && route.size?.risky ? "Risky" : riskTitle(route.risk),
810
810
  posture: drawsStrong(legs) ? "strong" : "economy",
811
811
  postureWords: postureWords(route),
812
812
  demands: route.demands.filter(reason => !/review/i.test(reason)),
@@ -1,6 +1,7 @@
1
1
  type HistoricalWitness = {
2
2
  observedAt: unknown;
3
3
  finishedAt: unknown;
4
+ exitedAt?: unknown;
4
5
  };
5
6
  /** Read-only checks of retained process witnesses. Saved PIDs authorize no
6
7
  * signal. ESRCH or proven macOS PID reuse establishes absence; everything
@@ -43,6 +43,11 @@ function newerMacProcess(pid, witness) {
43
43
  * unknown stays occupied. This concerns the historical identity, not whether
44
44
  * the numeric PID/PGID is currently populated by unrelated processes. */
45
45
  export function processMayBeAlive(pid, group, witness) {
46
+ // A recorded single-process exit is final: this number can now belong to
47
+ // someone else, even before the run ends. A leader's exit proves nothing
48
+ // about its group's remaining members, so groups keep the OS probe.
49
+ if (!group && witness?.exitedAt != null)
50
+ return false;
46
51
  if (!Number.isSafeInteger(pid) || pid <= 0)
47
52
  return true;
48
53
  if (group && process.platform !== "win32") {
package/dist/serve.js CHANGED
@@ -12687,7 +12687,7 @@ function approvalSheetHtml(input) {
12687
12687
  : projection === null
12688
12688
  ? route.legacy === null ? "" : `<p>${escape(agentsSummaryWords(route))}</p>`
12689
12689
  : `<p>${escape(projection.summary)}</p>` +
12690
- `<p class="meta">${escape(riskTitle(route.riskLevel))}: ${escape(riskConsequence(route.riskLevel))}. Uses ${escape(projection.postureWords)}.</p>` +
12690
+ `<p class="meta">${escape(agentsRiskWords(projection))}. Uses ${escape(projection.postureWords)}.</p>` +
12691
12691
  (projection.demands.length === 0 ? "" : `<ul class="meta">${projection.demands.map(one => `<li>${escape(one)}</li>`).join("")}</ul>`) +
12692
12692
  `<dl class="approval-roles">${projection.legs.map(leg => `<div><dt>${escape(ROLE_NOUN[leg.phase])}</dt><dd><span class="mono">${escape(leg.provider)} · ${escape(leg.model)}</span> <span class="meta">${escape(chosenWords(leg))}</span>` +
12693
12693
  `<ul>${leg.reasons.map(reason => `<li>${escape(reason)}</li>`).join("")}${leg.problem === null ? "" : `<li><strong>${escape(leg.problem)}</strong></li>`}</ul></dd></div>`).join("")}</dl>` +
@@ -12945,10 +12945,16 @@ function agentsSummaryWords(view) {
12945
12945
  return `${view.legacy.provider} · ${view.legacy.model} builds and repairs (repair model ${view.legacy.repairModel}); the planner and reviewer come from configuration at run time`;
12946
12946
  return view.problem ?? "the agents cannot be read";
12947
12947
  }
12948
+ function agentsRiskWords(projection) {
12949
+ const consequence = projection.risk === "routine" && projection.size?.risky
12950
+ ? riskConsequence("elevated")
12951
+ : riskConsequence(projection.risk);
12952
+ return `${projection.riskTitle}: ${consequence}`;
12953
+ }
12948
12954
  /** Neutral metadata badges: risk, posture, and standing. */
12949
12955
  function agentsBadgesHtml(view) {
12950
12956
  const p = view.projection;
12951
- return (`<span class="badge">${escape(sentenceCase(riskTitle(view.riskLevel)))}</span>` +
12957
+ return (`<span class="badge">${escape(sentenceCase(p?.riskTitle ?? riskTitle(view.riskLevel)))}</span>` +
12952
12958
  (p === null ? "" : `<span class="badge">${escape(sentenceCase(p.postureWords))}</span>`) +
12953
12959
  `<span class="badge">${escape(sentenceCase(agentsStandingWords(view)))}</span>`);
12954
12960
  }
@@ -12997,8 +13003,8 @@ function agentsCeremonyHtml(view) {
12997
13003
  return (`<div class="agents-ceremony"><p class="approval-label">agents</p>` +
12998
13004
  sizeLineHtml(view.projection) +
12999
13005
  `<p class="agents-summary">${escape(view.projection.summary)}</p>` +
13000
- `<div class="agents-badges"><span class="badge">${escape(sentenceCase(riskTitle(view.riskLevel)))}</span><span class="badge">${escape(sentenceCase(view.projection.postureWords))}</span></div>` +
13001
- `<p class="meta agents-risk-line">${escape(riskTitle(view.riskLevel))}: ${escape(riskConsequence(view.riskLevel))}.</p>` +
13006
+ `<div class="agents-badges"><span class="badge">${escape(sentenceCase(view.projection.riskTitle))}</span><span class="badge">${escape(sentenceCase(view.projection.postureWords))}</span></div>` +
13007
+ `<p class="meta agents-risk-line">${escape(agentsRiskWords(view.projection))}.</p>` +
13002
13008
  agentsWhyHtml(view.projection) +
13003
13009
  `<p class="meta">These exact agents are part of what you approve; changing any of them asks for a fresh approval.</p></div>`);
13004
13010
  }
@@ -13024,7 +13030,7 @@ function routineAgentsHtml(routine, approved, ceremony = false) {
13024
13030
  const projection = projectRoute(route, () => null);
13025
13031
  return (`<div class="agents-ceremony"><p class="approval-label">agents</p>` +
13026
13032
  `<p class="agents-summary">${escape(agentsSummary(route))}</p>` +
13027
- `<div class="agents-badges"><span class="badge">${escape(sentenceCase(riskTitle(route.risk)))}</span><span class="badge">${escape(sentenceCase(postureWords(route)))}</span><span class="badge">${approved ? "frozen by the approval" : "frozen when you approve"}</span></div>` +
13033
+ `<div class="agents-badges"><span class="badge">${escape(sentenceCase(projection.riskTitle))}</span><span class="badge">${escape(sentenceCase(postureWords(route)))}</span><span class="badge">${approved ? "frozen by the approval" : "frozen when you approve"}</span></div>` +
13028
13034
  (ceremony ? agentsWhyHtml(projection) : "") +
13029
13035
  `<p class="meta">${approved ? "Every firing runs on exactly these agents; a configuration change cannot re-route it." : "Approving freezes exactly these agents for every firing; a configuration change afterwards cannot re-route one."}</p></div>`);
13030
13036
  }
package/dist/store.d.ts CHANGED
@@ -26,7 +26,7 @@ import { type ApprovalGate, type ApprovalRules, type ApproverKind, type Filer }
26
26
  * next; `channel` is where it reports (`chat`: the conversation it was promised in); a promise lapses at `expires_at`. */
27
27
  export declare const LEAD_COMMITMENT_SCHEMA = "\nCREATE TABLE IF NOT EXISTS lead_commitment (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n owner TEXT NOT NULL,\n repo TEXT,\n thread INTEGER NOT NULL,\n turn INTEGER,\n channel TEXT NOT NULL DEFAULT 'chat',\n what TEXT NOT NULL,\n condition_json TEXT NOT NULL,\n check_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'done', 'cancelled', 'expired')),\n created_at TEXT NOT NULL,\n checked_at TEXT,\n closed_at TEXT,\n closed_by TEXT,\n outcome TEXT\n);\nCREATE INDEX IF NOT EXISTS lead_commitment_due ON lead_commitment (state, check_at);\nCREATE INDEX IF NOT EXISTS lead_commitment_owner ON lead_commitment (owner, state, id);\n";
28
28
  /** What stops a finished run reading stopped (Store.stopQuiescenceFact). */
29
- export type StopFactKind = "open" | "alive" | "elsewhere" | "unprovable" | "unknown";
29
+ export type StopFactKind = "open" | "alive" | "elsewhere" | "unprovable" | "unknown" | "settling";
30
30
  export type TaskAct = "filed" | "approved" | "cancelled" | "completed" | "asked";
31
31
  /** Store.stopQuiescenceFact, read safely: a deploy proves completion with this code over the INSTALLED runtime's store,
32
32
  * which may predate the method. There the older stopQuiescenceProblem decides, and its words give the kind. */
@@ -7274,7 +7274,8 @@ export declare class Store {
7274
7274
  * another computer can tell, `unprovable` when nothing is known to run
7275
7275
  * but its exit was never recorded, `unknown` when this computer can't
7276
7276
  * check at all (both are what `run settle` resolves; only the first may
7277
- * be called not running). */
7277
+ * be called not running). `settling` already meets the automatic
7278
+ * reconciliation proof, so no confirmation is needed. */
7278
7279
  stopQuiescenceFact(runId: number): {
7279
7280
  run: number;
7280
7281
  kind: StopFactKind;
@@ -7306,7 +7307,11 @@ export declare class Store {
7306
7307
  * process groups is alive, it settles as never started, with a ledger
7307
7308
  * entry. A sole pid-less witness has no group to prove gone and stays for
7308
7309
  * `run settle`. Native witnesses keep their OS-only road. */
7309
- settleUnspawnedWitnesses(now: Date): number;
7310
+ settleUnspawnedWitnesses(now: Date, onlyRun?: number): number;
7311
+ /** The existing automatic settlement proof, shared with its read-only
7312
+ * status. Unknown-only, foreign, native and still-running custody keep
7313
+ * their fences; only an observed single-process exit avoids a new probe. */
7314
+ private settleableUnspawnedWitnesses;
7310
7315
  /** `run settle` (an approver's last resort): every witness of a finished
7311
7316
  * run that cannot be proven either way is recorded settled under the
7312
7317
  * approver's reason, in the ledger. Refuses while anything of the run is
package/dist/store.js CHANGED
@@ -16479,6 +16479,9 @@ export class Store {
16479
16479
  // retained witnesses and every owned run; recovery revisits a pending
16480
16480
  // stop after an orphan or held supervisor finishes shutdown.
16481
16481
  this.recordRunProcessExits(id, result.now);
16482
+ // Use the existing reconciliation proof now, while exits are fresh. In
16483
+ // particular, a returned --candidate check need not await the next scan.
16484
+ this.settleUnspawnedWitnesses(result.now, id);
16482
16485
  this.settleRunStop(id, result.stopSettlement ?? "finished", result.now);
16483
16486
  // The first Ready result is an installation fact the moment it lands,
16484
16487
  // not only when something later looks for it. A run that changed
@@ -21473,7 +21476,8 @@ export class Store {
21473
21476
  * another computer can tell, `unprovable` when nothing is known to run
21474
21477
  * but its exit was never recorded, `unknown` when this computer can't
21475
21478
  * check at all (both are what `run settle` resolves; only the first may
21476
- * be called not running). */
21479
+ * be called not running). `settling` already meets the automatic
21480
+ * reconciliation proof, so no confirmation is needed. */
21477
21481
  stopQuiescenceFact(runId) {
21478
21482
  const fact = (run, kind, problem) => ({ run, kind, problem });
21479
21483
  const ids = this.ownedRunsOf(runId);
@@ -21489,10 +21493,13 @@ export class Store {
21489
21493
  if (held !== null && held.endedAt === null)
21490
21494
  return fact(id, "alive", `run #${id}'s held supervisor has not finished shutdown`);
21491
21495
  const witnesses = this.db.prepare("SELECT * FROM run_process WHERE run = ? ORDER BY id").all(id);
21496
+ let incompleteSpawn = false;
21492
21497
  if (run.providerStartedAt !== null && witnesses.length === 0) {
21493
21498
  return fact(id, "unprovable", `run #${id} may have spawned before its process witness was recorded; exit is unproven`);
21494
21499
  }
21495
21500
  for (const witness of witnesses) {
21501
+ // A recorded group exit proves the whole group ended, not just its
21502
+ // leader. Re-probing its number could mistake reuse for the old group.
21496
21503
  if (witness["exited_at"] !== null)
21497
21504
  continue;
21498
21505
  const host = String(witness["host"]);
@@ -21522,10 +21529,12 @@ export class Store {
21522
21529
  return fact(id, "unknown", `run #${id}'s ${backend} object ${container} cannot be proven empty from here`);
21523
21530
  continue;
21524
21531
  }
21525
- if (witness["pid"] === null)
21526
- return fact(id, "unprovable", `run #${id} has an incomplete spawn witness; exit is unproven`);
21532
+ if (witness["pid"] === null) {
21533
+ incompleteSpawn = true;
21534
+ continue;
21535
+ }
21527
21536
  const pid = Number(witness["pid"]);
21528
- if (processMayBeAlive(pid, witness["process_group"] === 1, { observedAt: witness["observed_at"], finishedAt: run.finishedAt }))
21537
+ if (processMayBeAlive(pid, witness["process_group"] === 1, { observedAt: witness["observed_at"], finishedAt: run.finishedAt, exitedAt: witness["exited_at"] }))
21529
21538
  return fact(id, "alive", `run #${id}'s process ${pid} may still be running`);
21530
21539
  }
21531
21540
  if (run.worktree !== null) {
@@ -21540,6 +21549,8 @@ export class Store {
21540
21549
  return fact(id, "unknown", `run #${id}'s workspace occupancy could not be established`);
21541
21550
  }
21542
21551
  }
21552
+ if (incompleteSpawn)
21553
+ return fact(id, this.settleableUnspawnedWitnesses(run, witnesses).length > 0 ? "settling" : "unprovable", `run #${id} has an incomplete spawn witness; exit is unproven`);
21543
21554
  }
21544
21555
  return null;
21545
21556
  }
@@ -21618,10 +21629,11 @@ export class Store {
21618
21629
  * process groups is alive, it settles as never started, with a ledger
21619
21630
  * entry. A sole pid-less witness has no group to prove gone and stays for
21620
21631
  * `run settle`. Native witnesses keep their OS-only road. */
21621
- settleUnspawnedWitnesses(now) {
21632
+ settleUnspawnedWitnesses(now, onlyRun) {
21622
21633
  const candidates = this.db.prepare(`SELECT DISTINCT p.run FROM run_process p JOIN run r ON r.id = p.run
21623
21634
  WHERE p.exited_at IS NULL AND p.pid IS NULL AND p.containment IS NULL AND p.container IS NULL
21624
- AND p.host = ? AND r.outcome IS NOT NULL AND r.finished_at IS NOT NULL ORDER BY p.run`).all(hostname());
21635
+ AND p.host = ? AND r.outcome IS NOT NULL AND r.finished_at IS NOT NULL
21636
+ AND (? IS NULL OR p.run = ?) ORDER BY p.run`).all(hostname(), onlyRun ?? null, onlyRun ?? null);
21625
21637
  let settled = 0;
21626
21638
  for (const candidate of candidates) {
21627
21639
  const runId = Number(candidate["run"]);
@@ -21630,22 +21642,8 @@ export class Store {
21630
21642
  const run = this.getRun(runId);
21631
21643
  if (run === null || run.outcome === null)
21632
21644
  return 0;
21633
- if (ownedProcessCount(runOwnerTag(this, runId)) > 0)
21634
- return 0;
21635
- const held = this.heldSessionOf(runId);
21636
- if (held !== null && held.endedAt === null)
21637
- return 0;
21638
21645
  const rows = this.db.prepare("SELECT * FROM run_process WHERE run = ? ORDER BY id").all(runId);
21639
- const pidless = rows.filter(row => row["exited_at"] === null && row["pid"] === null && row["containment"] === null && row["container"] === null && row["host"] === hostname());
21640
- const others = rows.filter(row => !pidless.includes(row));
21641
- if (pidless.length === 0 || others.length === 0)
21642
- return 0;
21643
- if (others.some(row => row["exited_at"] === null))
21644
- return 0;
21645
- // Exit was recorded once; the group's members are asked again now.
21646
- const groups = others.filter(row => row["pid"] !== null && row["host"] === hostname());
21647
- if (groups.length === 0 || groups.some(row => processMayBeAlive(Number(row["pid"]), row["process_group"] === 1, { observedAt: row["observed_at"], finishedAt: run.finishedAt })))
21648
- return 0;
21646
+ const pidless = this.settleableUnspawnedWitnesses(run, rows);
21649
21647
  const ref = this.refById(run.taskRef);
21650
21648
  let count = 0;
21651
21649
  for (const row of pidless) {
@@ -21664,6 +21662,24 @@ export class Store {
21664
21662
  }
21665
21663
  return settled;
21666
21664
  }
21665
+ /** The existing automatic settlement proof, shared with its read-only
21666
+ * status. Unknown-only, foreign, native and still-running custody keep
21667
+ * their fences; only an observed single-process exit avoids a new probe. */
21668
+ settleableUnspawnedWitnesses(run, rows) {
21669
+ if (run.outcome === null || run.finishedAt === null || ownedProcessCount(runOwnerTag(this, run.id)) > 0)
21670
+ return [];
21671
+ const held = this.heldSessionOf(run.id);
21672
+ if (held !== null && held.endedAt === null)
21673
+ return [];
21674
+ const pidless = rows.filter(row => row["exited_at"] === null && row["pid"] === null && row["containment"] === null && row["container"] === null && row["host"] === hostname());
21675
+ const others = rows.filter(row => !pidless.includes(row));
21676
+ if (pidless.length === 0 || others.length === 0 || others.some(row => row["exited_at"] === null))
21677
+ return [];
21678
+ const known = others.filter(row => row["pid"] !== null && row["host"] === hostname());
21679
+ if (known.length === 0 || known.some(row => processMayBeAlive(Number(row["pid"]), row["process_group"] === 1, { observedAt: row["observed_at"], finishedAt: run.finishedAt, exitedAt: row["exited_at"] })))
21680
+ return [];
21681
+ return pidless;
21682
+ }
21667
21683
  /** `run settle` (an approver's last resort): every witness of a finished
21668
21684
  * run that cannot be proven either way is recorded settled under the
21669
21685
  * approver's reason, in the ledger. Refuses while anything of the run is
@@ -21691,7 +21707,7 @@ export class Store {
21691
21707
  return alive(`run #${args.runId}'s process ${String(row["pid"])} belongs to ${String(row["host"])}; settle it there`);
21692
21708
  continue;
21693
21709
  }
21694
- if (processMayBeAlive(Number(row["pid"]), row["process_group"] === 1, { observedAt: row["observed_at"], finishedAt: run.finishedAt }))
21710
+ if (processMayBeAlive(Number(row["pid"]), row["process_group"] === 1, { observedAt: row["observed_at"], finishedAt: run.finishedAt, exitedAt: row["exited_at"] }))
21695
21711
  return alive(`run #${args.runId}'s process ${String(row["pid"])} is still running`);
21696
21712
  }
21697
21713
  for (const row of open) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.28",
3
+ "version": "0.9.29",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"