gentle-pi 2.6.2 → 2.6.4

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/README.md CHANGED
@@ -141,7 +141,7 @@ Review the exact change, not a moving target. Native review keeps one candidate
141
141
  The [v2.6.0 release](https://github.com/Gentleman-Programming/gentle-pi/releases/tag/v2.6.0) brings a more persistent, inspectable Pi workspace:
142
142
 
143
143
  - **Shell:** registered worktrees survive reloads; `/gentle:changes` groups dirty roots with diffs, status, and line counts; fullscreen navigation, responsive sidebars, and cached frames stay live without unnecessary redraws.
144
- - **Agents and profiles:** the Agents view shows orchestrator/session hierarchy, retained completion, abort, and lost-exit history, parent-child handoff, and model, effort, and usage observability. Named `/gentle:profiles` atomically route the orchestrator independently from packaged and review roles.
144
+ - **Agents and profiles:** the Agents view shows orchestrator/session hierarchy, retained completion, abort, and lost-exit history, parent-child handoff, and model, effort, and usage observability. Named `/gentle:profiles` atomically route the orchestrator independently from packaged and review roles; applying one replaces the routing of every agent, and the panel shows the routing the runtime actually uses even when `models.json` is sparse.
145
145
  - **Control and recovery:** native SDD requires parent-confirmed preflight; native review supports intended-untracked selection, consent, and provider continuations. Subsystems install with explicit recovery guidance when npm lifecycle scripts were skipped; Pi Git installs are recognized globally; custom ask responses are opt-in. Windows keeps child consoles hidden and fixes ownership mode; Gentle Todo keeps the next pending task visible when collapsed.
146
146
 
147
147
  ---
@@ -13,7 +13,7 @@ The [v2.6.0 release](https://github.com/Gentleman-Programming/gentle-pi/releases
13
13
  - The Agents List and Details views preserve the orchestrator/session hierarchy and completion, abort, and lost-exit history. Parent-child queries and notifications have an explicit handoff path, while model, effort, and usage stay observable per task.
14
14
  - Named `/gentle:profiles` atomically route the orchestrator separately from packaged and review roles; see the [technical reference](readme-reference.md#agent-model-profiles) for the profile model.
15
15
 
16
- The source checkout currently prepares `gentle-pi` `2.6.2` with a package-local Gentle AI `v2.8.2` pin; this is not a claim that `2.6.2` is published.
16
+ The source checkout currently prepares `gentle-pi` `2.6.4` with a package-local Gentle AI `v2.9.0` pin; this is not a claim that `2.6.4` is published.
17
17
 
18
18
  ## Shell interactions and runtime behavior
19
19
 
@@ -30,7 +30,7 @@ This reference preserves the detailed installation, configuration, SDD/OpenSpec,
30
30
  | **Skill creation workflow** | Provides the `gentle-ai-skill-creator`/`gentle-ai-skill-improver` skills, `/skill-creation` prompt, and packaged style guide for LLM-first skills. |
31
31
  | **Delivery skills** | Includes issue-first PRs, chained PRs, work-unit commits, cognitive docs, comment writing, and Judgment Day review. |
32
32
  | **Bounded native review** | Freezes one candidate, dispatches only controller-selected lenses, and records native authority. Review outcomes are informational; delivery follows ordinary repository policy. |
33
- | **Verified native runtime** | The current source checkout provisions the exact package-local Gentle AI v2.8.2 runtime: signed, SHA-256-pinned release archives on Darwin/Linux and a Go SumDB-verified source build on Windows x64/arm64. It validates package-local integrity and rejects PATH, global, sibling, symlink, and mode fallbacks. |
33
+ | **Verified native runtime** | The current source checkout provisions the exact package-local Gentle AI v2.9.0 runtime: signed, SHA-256-pinned release archives on Darwin/Linux and a Go SumDB-verified source build on Windows x64/arm64. It validates package-local integrity and rejects PATH, global, sibling, symlink, and mode fallbacks. |
34
34
  | **Runtime safety** | Blocks destructive shell commands, asks for confirmation for sensitive operations, and blocks direct read/write/edit access to sensitive paths. |
35
35
 
36
36
  ## Native pointer regions
@@ -67,7 +67,7 @@ The stable release is [`v2.6.0`](https://github.com/Gentleman-Programming/gentle
67
67
 
68
68
  ### Source checkout
69
69
 
70
- This checkout prepares `gentle-pi` `2.6.2`; it is source state, not a published release. Its package-local native runtime pin is Gentle AI `v2.8.2`, distinct from the published `v2.6.0` pairing.
70
+ This checkout prepares `gentle-pi` `2.6.4`; it is source state, not a published release. Its package-local native runtime pin is Gentle AI `v2.9.0`, distinct from the published `v2.6.0` pairing.
71
71
 
72
72
  ### Pi compatibility
73
73
 
@@ -94,7 +94,7 @@ pi install npm:gentle-pi@2.6.0
94
94
 
95
95
  RDD remains opt-in. Enable it only through an explicit user decision with `/gentle:review-mode enable`; `status` lets you inspect the mode without changing it.
96
96
 
97
- The source checkout's RDD integration installs Gentle AI only into its private `.gentle-ai/` directory. Darwin and Linux use pinned release assets with asset and executable SHA-256 verification (signed archives for source pin `v2.8.2`; raw prerelease binaries only under a prerelease pin). Windows x64 and arm64 build the exact `v2.8.2` source tag with a local Go 1.25.10+ toolchain, a sealed Go environment, `GOTOOLCHAIN=local`, and `GOSUMDB=sum.golang.org`; it does not download Go automatically. Windows provenance is Go-toolchain plus SumDB evidence and postinstall tamper detection, **not** Authenticode or protection against a malicious joint binary-and-manifest replacement. Package-private locks coordinate cooperative concurrent or crashed installers; their tombstones fail closed. A malicious same-user process with write access to package-private `node_modules` is outside that protocol because it can already replace package code, binary, or manifest, and portable Node has no pathname-delete CAS. It never uses `PATH` or a global `gentle-ai` installation. For development or offline installs only, set `GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1`; native review operations then fail closed with an actionable `package-local-binary-missing` error. To recover explicitly, if `GENTLE_PI_SKIP_GENTLE_AI_INSTALL` is set, remove or unset it before changing to the installed `gentle-pi` package directory. Then run `node scripts/install-gentle-ai.mjs`. This invokes the package-owned installer without relying on a global binary or npm configuration change. A missing binary can result from skipped lifecycle scripts, but does not prove that lifecycle scripts were disabled.
97
+ The source checkout's RDD integration installs Gentle AI only into its private `.gentle-ai/` directory. Darwin and Linux use pinned release assets with asset and executable SHA-256 verification (signed archives for source pin `v2.9.0`; raw prerelease binaries only under a prerelease pin). Windows x64 and arm64 build the exact `v2.9.0` source tag with a local Go 1.25.10+ toolchain, a sealed Go environment, `GOTOOLCHAIN=local`, and `GOSUMDB=sum.golang.org`; it does not download Go automatically. Windows provenance is Go-toolchain plus SumDB evidence and postinstall tamper detection, **not** Authenticode or protection against a malicious joint binary-and-manifest replacement. Package-private locks coordinate cooperative concurrent or crashed installers; their tombstones fail closed. A malicious same-user process with write access to package-private `node_modules` is outside that protocol because it can already replace package code, binary, or manifest, and portable Node has no pathname-delete CAS. It never uses `PATH` or a global `gentle-ai` installation. For development or offline installs only, set `GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1`; native review operations then fail closed with an actionable `package-local-binary-missing` error. To recover explicitly, if `GENTLE_PI_SKIP_GENTLE_AI_INSTALL` is set, remove or unset it before changing to the installed `gentle-pi` package directory. Then run `node scripts/install-gentle-ai.mjs`. This invokes the package-owned installer without relying on a global binary or npm configuration change. A missing binary can result from skipped lifecycle scripts, but does not prove that lifecycle scripts were disabled.
98
98
 
99
99
  Recommended companion packages:
100
100
 
@@ -250,13 +250,13 @@ flowchart TD
250
250
 
251
251
  VALIDATE is informational. Commit, push, PR, and release commands follow ordinary repository policy; RDD never authorizes, rewrites, consumes review state for, or blocks them. Dangerous-command safety and destructive-review consent remain independent.
252
252
 
253
- For the source checkout, native contract pairing is exact: this adapter resolves only the integrity-verified package-local Gentle AI v2.8.2 executable, independently hashes it, then negotiates `gentle-ai.review-integration/v2` outside the repository. Capabilities are cached by that executable digest. Every START, target status, FINALIZE, validate, and BIND-SDD request passes the same contract identifier. Negotiated envelopes decode exactly against the vendored schemas; `recover` routes only the provider-selected `action_disposition`, and optional additions require a future compatible schema/minor that the provider explicitly advertises and the consumer negotiates.
253
+ For the source checkout, native contract pairing is exact: this adapter resolves only the integrity-verified package-local Gentle AI v2.9.0 executable, independently hashes it, then negotiates `gentle-ai.review-integration/v2` outside the repository. Capabilities are cached by that executable digest. Every START, target status, FINALIZE, validate, and BIND-SDD request passes the same contract identifier. Negotiated envelopes decode exactly against the vendored schemas; `recover` routes only the provider-selected `action_disposition`, and optional additions require a future compatible schema/minor that the provider explicitly advertises and the consumer negotiates.
254
254
 
255
255
  Contract `/v2` replaces the Base64 `candidate_diff` reviewer transport of `/v1` with immutable `base_tree`/`candidate_tree` plus an ordered `changed_path_manifest` and never an inline patch. `gentle-pi` negotiates `/v2` only, with no dual-lane fallback; the cutover landed as one atomic commit against gentle-ai v2.2.2 (tracked by the `migrate-review-integration-v2` change), and the `/v1` schemas stay packaged because the `/v2` schemas `$ref` into their fragments. This provider contract version is unrelated to Pi's own internal "compact-v2" review-authority naming used below — the shared digit is coincidental, not a version pairing.
256
256
 
257
257
  Target status owns `current_target`, `unrelated`, `ambiguous`, and `corrupted` applicability and returns one native action. Pi does not reconstruct ordinary authority from provider-private files or choose a lineage from repository-wide history. Restart recovery rebuilds only the derived candidate view from the native Git/content projection, including intended-untracked paths, symlinks, and immutable gitlink identities. Native failure envelopes retain their exact mutation outcome, replayability, required inputs, request digest, and next action. After an unknown or lost mutating result, Pi calls target status before any replay decision and returns only the provider-declared action.
258
258
 
259
- Once the source checkout's pinned gentle-ai runtime (currently v2.8.2) has written review authority, rollback MUST preserve every native store and receipt and MUST NOT run a downgraded binary against that repository. Disable the Pi route or roll forward to a compatible authority-aware release instead; deleting authority data or reinstalling an older binary is not a rollback path.
259
+ Once the source checkout's pinned gentle-ai runtime (currently v2.9.0) has written review authority, rollback MUST preserve every native store and receipt and MUST NOT run a downgraded binary against that repository. Disable the Pi route or roll forward to a compatible authority-aware release instead; deleting authority data or reinstalling an older binary is not a rollback path.
260
260
 
261
261
  ### FINALIZE wrapper input
262
262
 
@@ -600,7 +600,7 @@ Profiles are named, switchable snapshots of the global agent-model routing from
600
600
 
601
601
  | Key | Action |
602
602
  | ------- | ---------------------------------------------------------------------- |
603
- | `enter` | Apply the selected profile live (writes `models.json`, reconciles agents, sets the orchestrator when the profile defines one). |
603
+ | `enter` | Apply the selected profile live (writes `models.json`, replaces the routing of every agent, sets the orchestrator when the profile defines one). |
604
604
  | `c` | Create a new, empty profile. |
605
605
  | `s` | Update the selected profile from the current routing (including the orchestrator currently set in `settings.json`). |
606
606
  | `d` | Duplicate the selected profile. |
@@ -612,11 +612,11 @@ Profiles are named, switchable snapshots of the global agent-model routing from
612
612
  | `pgup`/`pgdn`, `ctrl+j`/`ctrl+k` | Scroll the detail pane by a page. |
613
613
  | `esc` | Close. |
614
614
 
615
- Applying a profile writes `~/.pi/gentle-ai/models.json`, then reconciles agent frontmatter and `subagents.json` the same way `/gentle:models` does. The reconciliation happens on the next subagent launch, and that launch still routes with the previous routing — expect one launch of lag after switching. The active profile is persisted so `/gentle:profiles` reopens with the applied profile marked.
615
+ Applying a profile writes `~/.pi/gentle-ai/models.json`, then reconciles agent frontmatter and `subagents.json` the same way `/gentle:models` does. A profile is a complete snapshot: every discoverable agent it omits returns to inherit, so routing materialized by a previous profile, by `/gentle:models`, or by a migration never survives a switch silently. The reconciliation happens on the next subagent launch, and that launch still routes with the previous routing — expect one launch of lag after switching. The active profile is persisted so `/gentle:profiles` reopens with the applied profile marked.
616
616
 
617
617
  A profile also carries the orchestrator under the reserved routing key `orchestrator`. Applying a profile that defines it writes `defaultProvider`, `defaultModel`, and `defaultThinkingLevel` to Pi's global `settings.json` (preserving every other key; an unreadable `settings.json` aborts that part and is reported instead of being overwritten). Applying a profile without an `orchestrator` entry never moves the orchestrator, and `s` snapshots the currently effective orchestrator together with the routing. `orchestrator` is reserved: it is not a subagent name, is never written to `subagents.json`, and is not counted as a role.
618
618
 
619
- When `profiles.json` is missing, the command seeds one profile named `current` captured from the existing `models.json`, marked active only when `models.json` has routing entries. Profiles or routing entries dropped by normalization are named in a warning instead of being lost silently.
619
+ The panel's current routing, the `current` seed, and `s` all read the routing in effect: `models.json` where it has an entry, and otherwise the `subagents.json` model profile or frontmatter routing the runtime actually resolves for that agent. A sparse `models.json` therefore never hides routing that is still live. When `profiles.json` is missing, the command seeds one profile named `current` captured from that effective routing, marked active only when it has routing entries. Profiles or routing entries dropped by normalization are named in a warning instead of being lost silently.
620
620
 
621
621
  Saved globally at:
622
622
 
@@ -1,6 +1,6 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { extractParentConfirmedSddPreflightContext, getPackageAssetOwner, isParentConfirmedSddPreflightContext, SHIPPED_SDD_AGENT_NAMES } from "../lib/sdd-preflight.ts";
3
- import { NativeReviewCliV216, NativeReviewCliError, createNodeExecFileAdapter, decodeNativeSddStatusV2, type NativeReviewCli, type NativeSddAcquireRequest, type NativeSddSettleRequest } from "../lib/native-review-cli.ts";
3
+ import { NativeReviewCliV216, NativeReviewCliError, createNodeExecFileAdapter, decodeNativeSddStatusV2, type NativeReviewCli, type NativeSddAcquireRequest, type NativeSddAttemptResult, type NativeSddSettleRequest } from "../lib/native-review-cli.ts";
4
4
  import { spawn } from "node:child_process";
5
5
  import { recordReviewMutation } from "../lib/review-reminder-receipt.ts";
6
6
  import { SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
@@ -19,7 +19,7 @@ import { isFinished, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord }
19
19
  import { AgentRunner, piCommand, abortReasonText, plannedCommands, type RemediationPlan, type RemediationScope, REMEDIATION_PLAN_ENV, parseRemediationPlan, remediationEvidence, type AskAnswer, type RunnerDeps, type SddChangeSelection, type TaskRequest, type RemediationTerminalFacts } from "../lib/agents-runner.ts";
20
20
  import { ChildMessenger, type IpcEndpoint } from "../lib/agents-messaging.ts";
21
21
  import { hasReviewSessionPermission, resolveCanonicalGitRepositoryIdentitySync, type ReviewSessionManager } from "../lib/review-session-standing-permission.ts";
22
- import { historyDir, remediationUnresolved, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
22
+ import { acquireTaskLock, historyDir, remediationUnresolved, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
23
23
  import { sessionToMarkdown } from "../lib/agents-transcript.ts";
24
24
  import { AgentsView } from "../lib/agents-view.ts";
25
25
  import { PresencePublisher } from "../lib/orchestrator-presence.ts";
@@ -129,6 +129,97 @@ export function remediationToolAllowed(scope: RemediationScope | undefined, cwd:
129
129
  } catch { return false; }
130
130
  }
131
131
 
132
+ export interface RemediationReconciliationResult {
133
+ acquireState?: NativeSddAttemptResult["state"];
134
+ settlementState?: NativeSddAttemptResult["state"];
135
+ }
136
+
137
+ async function replayUncertainNativeMutation<T>(label: "acquire" | "settlement", invoke: () => Promise<T>): Promise<T> {
138
+ try { return await invoke(); }
139
+ catch (error) {
140
+ if (error instanceof TypeError || error instanceof NativeReviewCliError && error.mutationOutcome === "none") throw error;
141
+ try { return await invoke(); }
142
+ catch { throw new Error(`Native remediation ${label} remains unresolved; retry only this exact retained task`); }
143
+ }
144
+ }
145
+
146
+ function finishReconciledTask(task: TaskRecord, message: string): void {
147
+ task.status = TASK_STATUS.FAILED;
148
+ task.error = message;
149
+ task.lastStep = "reconciled";
150
+ task.endedAt ??= Date.now();
151
+ task.lastActivityAt = Date.now();
152
+ }
153
+
154
+ export async function reconcileManagedRemediation(task: TaskRecord, native: NativeReviewCli, persist: (task: TaskRecord) => Promise<void>): Promise<RemediationReconciliationResult> {
155
+ const state = task.sddRemediation;
156
+ if (task.agent !== "sdd-remediate" || !state?.acquire || state.acquire.workspaceRoot !== task.cwd) throw new Error("Task is not an exact managed remediation record");
157
+ if (state.settlement) throw new Error("Managed remediation task already has a terminal settlement");
158
+
159
+ const settleExact = async (settle: NativeSddSettleRequest, acquireState?: NativeSddAttemptResult["state"]): Promise<RemediationReconciliationResult> => {
160
+ if (!native.sddAttemptSettle) throw new Error("Native remediation settlement reconciliation is unavailable");
161
+ let settlement: NativeSddAttemptResult;
162
+ try { settlement = await replayUncertainNativeMutation("settlement", () => native.sddAttemptSettle!(structuredClone(settle))); }
163
+ catch (error) {
164
+ state.settlementUncertain = true;
165
+ await persist(task);
166
+ throw error;
167
+ }
168
+ state.settlement = structuredClone(settlement);
169
+ delete state.settlementUncertain;
170
+ finishReconciledTask(task, `Managed remediation settlement reconciled as ${settlement.state}${settlement.reason ? `(${settlement.reason})` : ""}; no actor started`);
171
+ await persist(task);
172
+ return { ...(acquireState === undefined ? {} : { acquireState }), settlementState: settlement.state };
173
+ };
174
+
175
+ if (state.settle) {
176
+ const settle = state.settle, acquire = state.acquire;
177
+ const sameUntracked = JSON.stringify({ scope: settle.untrackedScope, inventory: settle.expectedUntrackedInventory, intended: settle.intendedUntracked }) === JSON.stringify({ scope: acquire.untrackedScope, inventory: acquire.expectedUntrackedInventory, intended: acquire.intendedUntracked });
178
+ if (state.acquireResult?.state !== "proceed" || !state.token || state.acquireResult.token !== state.token || settle.token !== state.token || settle.workspaceRoot !== acquire.workspaceRoot || settle.changeName !== acquire.changeName || settle.remediatesEvidenceRevision !== acquire.remediatesEvidenceRevision || !sameUntracked) throw new Error("Retained remediation settlement does not match its exact acquired authority");
179
+ return settleExact(structuredClone(settle), state.acquireResult.state);
180
+ }
181
+ if (!state.acquireUncertain) throw new Error("Managed remediation task has no uncertain acquire or settlement to reconcile");
182
+ if (state.actorClaimed) throw new Error("Managed remediation actor effects are uncertain; exact settlement or maintainer intervention is required");
183
+ if (state.acquireResult || state.token || state.settlementUncertain) throw new Error("Managed remediation acquire history is inconsistent; maintainer intervention is required");
184
+ if (!native.sddAttemptAcquire) throw new Error("Native remediation acquire reconciliation is unavailable");
185
+
186
+ let acquired: NativeSddAttemptResult;
187
+ try { acquired = await replayUncertainNativeMutation("acquire", () => native.sddAttemptAcquire!(structuredClone(state.acquire))); }
188
+ catch (error) {
189
+ state.acquireUncertain = true;
190
+ await persist(task);
191
+ throw error;
192
+ }
193
+ if (acquired.state === "proceed" && !acquired.token) throw new Error("Native remediation acquire reconciliation returned no token");
194
+ state.acquireResult = structuredClone(acquired);
195
+ delete state.acquireUncertain;
196
+ if (acquired.state !== "proceed") {
197
+ delete state.token;
198
+ finishReconciledTask(task, `Managed remediation acquire reconciled as ${acquired.state}${acquired.reason ? `(${acquired.reason})` : ""}; no actor started`);
199
+ await persist(task);
200
+ return { acquireState: acquired.state };
201
+ }
202
+
203
+ state.token = acquired.token;
204
+ const acquire = state.acquire;
205
+ const settle: NativeSddSettleRequest = {
206
+ workspaceRoot: acquire.workspaceRoot,
207
+ changeName: acquire.changeName,
208
+ token: acquired.token!,
209
+ requestId: `reconcile-${createHash("sha256").update(JSON.stringify(acquire)).digest("hex").slice(0, 32)}`,
210
+ outcome: "interrupted",
211
+ diagnosis: "Acquire outcome was recovered after the actor launch boundary was refused; no managed actor was launched",
212
+ harnessDisposition: "invalidated",
213
+ cleanupEvidence: "No managed actor was spawned; no child cleanup was required",
214
+ processEvidence: "spawned=false; actor_claimed=false; reconciliation=acquire",
215
+ remediatesEvidenceRevision: acquire.remediatesEvidenceRevision,
216
+ ...(acquire.untrackedScope === undefined ? {} : { untrackedScope: acquire.untrackedScope, expectedUntrackedInventory: acquire.expectedUntrackedInventory, intendedUntracked: acquire.intendedUntracked }),
217
+ };
218
+ state.settle = structuredClone(settle);
219
+ await persist(task); // Recovered token and exact settlement replay input become durable atomically before native mutation.
220
+ return settleExact(settle, acquired.state);
221
+ }
222
+
132
223
  export async function admitManagedRemediation(request: TaskRequest, input: unknown, native: NativeReviewCli, persist: (task: TaskRecord) => Promise<void>, context?: Pick<ExtensionContext, "hasUI" | "ui">, preparedTask?: TaskRecord): Promise<Partial<TaskRequest>> {
133
224
  const selected = request.sddChange;
134
225
  const asset = fileURLToPath(new URL("../assets/agents/sdd-remediate.md", import.meta.url));
@@ -871,7 +962,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
871
962
  const selected = store.get(task.id);
872
963
  if (!isOwnedActive(selected)) return;
873
964
  if (selected.status === TASK_STATUS.QUEUED) {
874
- if (runner.cancel(selected.id)) ctx.ui.notify(`Stopped ${selected.agent}.`);
965
+ if (runner.cancel(selected.id, "stopped from the agents panel")) ctx.ui.notify(`Stopped ${selected.agent}.`);
875
966
  else ctx.ui.notify(`Task ${selected.agent} already finished.`, "warning");
876
967
  return;
877
968
  }
@@ -885,7 +976,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
885
976
  ctx.ui.notify(`Task ${selected.agent} already finished.`, "warning");
886
977
  return;
887
978
  }
888
- if (runner.cancel(current.id)) ctx.ui.notify(`Stopped ${current.agent}.`);
979
+ if (runner.cancel(current.id, "stopped from the agents panel")) ctx.ui.notify(`Stopped ${current.agent}.`);
889
980
  else ctx.ui.notify(`Task ${current.agent} already finished.`, "warning");
890
981
  } finally {
891
982
  stoppingTaskIds.delete(selected.id);
@@ -904,7 +995,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
904
995
  const confirmation = (async () => {
905
996
  try {
906
997
  if (!await ctx.ui.confirm(`Stop ${count} active ${noun}?`, `Only these ${count} ${noun} will stop. Current work may be incomplete.`)) return;
907
- const cancelled = active.filter((task) => runner.cancel(task.id)).length;
998
+ const cancelled = active.filter((task) => runner.cancel(task.id, "stopped from the agents panel (stop all)")).length;
908
999
  ctx.ui.notify(`Stopped ${cancelled} ${cancelled === 1 ? "subagent" : "subagents"}.`);
909
1000
  } finally {
910
1001
  stopAllConfirmation = undefined;
@@ -1136,7 +1227,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1136
1227
  // no recorded reason. Cancel through the runner so the lifecycle runs and the
1137
1228
  // record is persisted, and tell the user why.
1138
1229
  const onAbort = (): void => {
1139
- if (runner.cancel(task.id)) {
1230
+ if (runner.cancel(task.id, `cancelled: the tool call was aborted${abortReasonText(signal?.reason)}`)) {
1140
1231
  ctx.ui.notify(
1141
1232
  `Subagent ${task.agent} cancelled: the tool call was aborted${abortReasonText(signal?.reason)}. The run is recorded as cancelled.`,
1142
1233
  "warning",
@@ -1220,6 +1311,25 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1220
1311
  return task ? text(describeTask(task), taskDetails(task)) : text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
1221
1312
  });
1222
1313
 
1314
+ tool("reconcile", "Reconcile one retained managed remediation mutation without launching an actor.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params, ctx) => {
1315
+ const id = String(params.task_id);
1316
+ const lock = acquireTaskLock(tasksDir, id);
1317
+ try {
1318
+ const stored = await loadStoredTask(tasksDir, id);
1319
+ if (!stored) return text(`Error: no task ${id}`, { error: "unknown task" });
1320
+ const task = stored.task, retainedThread = stored.thread;
1321
+ const target = registryFor(ctx).validate(task.cwd);
1322
+ if (target !== resolve(task.cwd) || task.sddRemediation?.acquire.workspaceRoot !== target) throw new Error("Retained remediation task must resolve to its exact worktree in the same Git clone as this session");
1323
+ const native = deps.nativeSdd ?? new NativeReviewCliV216(createNodeExecFileAdapter());
1324
+ const persist = async (current: TaskRecord) => {
1325
+ await saveTask(tasksDir, current, retainedThread);
1326
+ store.update(current.id, { status: current.status, error: current.error, lastStep: current.lastStep, endedAt: current.endedAt, lastActivityAt: current.lastActivityAt, sddRemediation: current.sddRemediation });
1327
+ };
1328
+ const result = await reconcileManagedRemediation(task, native, persist);
1329
+ return text(`Managed remediation task ${task.id} reconciled; no actor started. Use fresh native status and admission for later work.`, { gentleAgents: { taskId: task.id, agent: task.agent, status: task.status, mode: task.mode }, reconciliation: { ...result, actorStarted: false } });
1330
+ } finally { lock.release(); }
1331
+ });
1332
+
1223
1333
  tool("result", "Return the final answer of a finished subagent task, or its current state if it is still running.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => {
1224
1334
  const task = await resolveTask(String(params.task_id));
1225
1335
  if (!task) return text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
@@ -1241,7 +1351,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1241
1351
 
1242
1352
  tool("cancel", "Cancel a queued or running subagent task.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => {
1243
1353
  const id = String(params.task_id);
1244
- return runner.cancel(id) ? text(`Cancelled task ${id}.`) : text(`Error: task ${id} is not running.`, { error: "not running" });
1354
+ return runner.cancel(id, "cancelled by the cancel tool") ? text(`Cancelled task ${id}.`) : text(`Error: task ${id} is not running.`, { error: "not running" });
1245
1355
  });
1246
1356
 
1247
1357
  tool("send_message", "Steer a running subagent with a message delivered before its next model call.", { required: ["task_id", "message"], properties: { task_id: { type: "string" }, message: { type: "string" } } }, async (params) => {
@@ -1331,6 +1441,6 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1331
1441
  sidebarTui = undefined;
1332
1442
  worktrees?.close();
1333
1443
  worktrees = undefined;
1334
- runner.cancelAll();
1444
+ runner.cancelAll("cancelled: parent session shut down");
1335
1445
  });
1336
1446
  }
@@ -2212,6 +2212,145 @@ function updateFrontmatterRouting(
2212
2212
  return `---\n${lines.join("\n")}${body}`;
2213
2213
  }
2214
2214
 
2215
+ /**
2216
+ * The routing an agent file currently carries, read the same way
2217
+ * `updateFrontmatterRouting` writes it: top-level `model:` and `thinking:`
2218
+ * frontmatter lines. Anything else is "no routing", not an error.
2219
+ */
2220
+ function readFrontmatterRouting(content: string): AgentRoutingEntry | undefined {
2221
+ if (!content.startsWith("---\n")) return undefined;
2222
+ const endIndex = content.indexOf("\n---", 4);
2223
+ if (endIndex === -1) return undefined;
2224
+ const raw: Record<string, string> = {};
2225
+ for (const line of content.slice(4, endIndex).split("\n")) {
2226
+ if (line.startsWith("model:")) raw.model = line.slice("model:".length).trim();
2227
+ else if (line.startsWith("thinking:")) raw.thinking = line.slice("thinking:".length).trim();
2228
+ }
2229
+ if (raw.model === undefined && raw.thinking === undefined) return undefined;
2230
+ const entry = normalizeRoutingEntry(raw);
2231
+ return entry && !isClearRoutingEntry(entry) ? entry : undefined;
2232
+ }
2233
+
2234
+ function routingEntryFromModelProfile(value: unknown): AgentRoutingEntry | undefined {
2235
+ if (!isRecord(value)) return undefined;
2236
+ const entry = normalizeRoutingEntry({ model: value.model, thinking: value.effort });
2237
+ return entry && !isClearRoutingEntry(entry) ? entry : undefined;
2238
+ }
2239
+
2240
+ function readSubagentModelProfiles(path: string): Record<string, unknown> {
2241
+ if (!existsSync(path)) return {};
2242
+ try {
2243
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
2244
+ return isRecord(parsed) && isRecord(parsed.model_profiles) ? parsed.model_profiles : {};
2245
+ } catch {
2246
+ return {};
2247
+ }
2248
+ }
2249
+
2250
+ async function readSubagentModelProfilesAsync(path: string): Promise<Record<string, unknown>> {
2251
+ if (!(await pathExists(path))) return {};
2252
+ try {
2253
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
2254
+ return isRecord(parsed) && isRecord(parsed.model_profiles) ? parsed.model_profiles : {};
2255
+ } catch {
2256
+ return {};
2257
+ }
2258
+ }
2259
+
2260
+ /**
2261
+ * The routing an agent is materialized with — what subagent launches actually
2262
+ * resolve — regardless of what `models.json` records: the runtime reads
2263
+ * `subagents.json` model profiles first and the agent frontmatter otherwise.
2264
+ */
2265
+ function readMaterializedRoutingEntry(
2266
+ cwd: string,
2267
+ agent: AgentEntry,
2268
+ profilesByPath: Map<string, Record<string, unknown>>,
2269
+ ): AgentRoutingEntry | undefined {
2270
+ const profilesPath = agentModelProfileConfigPath(cwd, agent.source);
2271
+ let profiles = profilesByPath.get(profilesPath);
2272
+ if (!profiles) {
2273
+ profiles = readSubagentModelProfiles(profilesPath);
2274
+ profilesByPath.set(profilesPath, profiles);
2275
+ }
2276
+ const fromProfile = routingEntryFromModelProfile(profiles[agent.name]);
2277
+ if (fromProfile) return fromProfile;
2278
+ if (!agent.filePath || !existsSync(agent.filePath)) return undefined;
2279
+ try {
2280
+ return readFrontmatterRouting(readFileSync(agent.filePath, "utf8"));
2281
+ } catch {
2282
+ return undefined;
2283
+ }
2284
+ }
2285
+
2286
+ async function readMaterializedRoutingEntryAsync(
2287
+ cwd: string,
2288
+ agent: AgentEntry,
2289
+ profilesByPath: Map<string, Record<string, unknown>>,
2290
+ ): Promise<AgentRoutingEntry | undefined> {
2291
+ const profilesPath = agentModelProfileConfigPath(cwd, agent.source);
2292
+ let profiles = profilesByPath.get(profilesPath);
2293
+ if (!profiles) {
2294
+ profiles = await readSubagentModelProfilesAsync(profilesPath);
2295
+ profilesByPath.set(profilesPath, profiles);
2296
+ }
2297
+ const fromProfile = routingEntryFromModelProfile(profiles[agent.name]);
2298
+ if (fromProfile) return fromProfile;
2299
+ if (!agent.filePath || !(await pathExists(agent.filePath))) return undefined;
2300
+ try {
2301
+ return readFrontmatterRouting(await readFile(agent.filePath, "utf8"));
2302
+ } catch {
2303
+ return undefined;
2304
+ }
2305
+ }
2306
+
2307
+ /**
2308
+ * The routing in effect: `models.json` where it speaks, and the materialized
2309
+ * stores the runtime resolves from for every discoverable agent it is silent
2310
+ * about. A sparse `models.json` therefore never hides routing that is still
2311
+ * live (#1012). Reading never writes.
2312
+ */
2313
+ function readEffectiveModelConfig(cwd: string): AgentModelConfig {
2314
+ const effective = cloneModelConfig(readModelConfig(cwd));
2315
+ const profilesByPath = new Map<string, Record<string, unknown>>();
2316
+ for (const agent of listDiscoverableAgents(cwd)) {
2317
+ if (isProviderReviewRole(agent.name) || agent.name in effective) continue;
2318
+ const entry = readMaterializedRoutingEntry(cwd, agent, profilesByPath);
2319
+ if (entry) effective[agent.name] = entry;
2320
+ }
2321
+ return effective;
2322
+ }
2323
+
2324
+ async function readEffectiveModelConfigAsync(cwd: string): Promise<AgentModelConfig> {
2325
+ const effective = cloneModelConfig(await readModelConfigAsync(cwd));
2326
+ const profilesByPath = new Map<string, Record<string, unknown>>();
2327
+ for (const agent of await listDiscoverableAgentsAsync(cwd)) {
2328
+ if (isProviderReviewRole(agent.name) || agent.name in effective) continue;
2329
+ const entry = await readMaterializedRoutingEntryAsync(cwd, agent, profilesByPath);
2330
+ if (entry) effective[agent.name] = entry;
2331
+ }
2332
+ return effective;
2333
+ }
2334
+
2335
+ /**
2336
+ * A profile is a complete routing snapshot: applying it must leave every
2337
+ * discoverable agent it omits on inherit, not on whatever was materialized
2338
+ * before. Padding the omitted agents with clear entries makes
2339
+ * `applyModelConfig` remove their model profiles and frontmatter routing, the
2340
+ * same way `/gentle:models` clears an agent set to inherit.
2341
+ */
2342
+ async function withOmittedAgentsClearedAsync(
2343
+ cwd: string,
2344
+ config: AgentModelConfig,
2345
+ ): Promise<AgentModelConfig> {
2346
+ const completed = cloneModelConfig(config);
2347
+ for (const agent of await listDiscoverableAgentsAsync(cwd)) {
2348
+ if (isProviderReviewRole(agent.name) || agent.name in completed) continue;
2349
+ completed[agent.name] = {};
2350
+ }
2351
+ return completed;
2352
+ }
2353
+
2215
2354
  function parseAgentName(filePath: string): string | undefined {
2216
2355
  let content: string;
2217
2356
  try {
@@ -2457,6 +2596,9 @@ function updateSubagentModelProfileAtPath(
2457
2596
  ? { ...config.model_profiles }
2458
2597
  : {};
2459
2598
  const profile = modelProfileForRoutingEntry(entry);
2599
+ // A write that would leave the profile as it is (including removing a
2600
+ // profile that was never there) is not an update and touches no file.
2601
+ if (JSON.stringify(modelProfiles[name]) === JSON.stringify(profile)) return false;
2460
2602
  if (profile) {
2461
2603
  if (options.preserveExisting && isRecord(modelProfiles[name])) return false;
2462
2604
  modelProfiles[name] = profile;
@@ -2487,6 +2629,9 @@ async function updateSubagentModelProfileAtPathAsync(
2487
2629
  ? { ...config.model_profiles }
2488
2630
  : {};
2489
2631
  const profile = modelProfileForRoutingEntry(entry);
2632
+ // A write that would leave the profile as it is (including removing a
2633
+ // profile that was never there) is not an update and touches no file.
2634
+ if (JSON.stringify(modelProfiles[name]) === JSON.stringify(profile)) return false;
2490
2635
  if (profile) {
2491
2636
  if (options.preserveExisting && isRecord(modelProfiles[name])) return false;
2492
2637
  modelProfiles[name] = profile;
@@ -3660,7 +3805,7 @@ class ProfilesPanel implements OverlayComponent {
3660
3805
  this.renderLine("Profile routing", width, "accent"),
3661
3806
  ...this.indentLines(this.routingLines(profileRows, widths), width),
3662
3807
  "",
3663
- this.renderLine("Current routing (models.json)", width, "accent"),
3808
+ this.renderLine("Current routing (effective)", width, "accent"),
3664
3809
  ...this.indentLines(this.routingLines(currentRows, widths), width),
3665
3810
  ];
3666
3811
  }
@@ -3819,6 +3964,13 @@ async function runProfilesPanelAction(
3819
3964
  if (routingWritten && previousActiveConfig !== undefined) {
3820
3965
  try {
3821
3966
  await writeModelConfigAsync(ctx.cwd, previousActiveConfig);
3967
+ // Materialize the previous profile again with the same
3968
+ // replacement semantics, so the failed profile's routes do not
3969
+ // linger in subagents.json or the agent frontmatter.
3970
+ await applyModelConfigAsync(
3971
+ ctx.cwd,
3972
+ await withOmittedAgentsClearedAsync(ctx.cwd, previousActiveConfig),
3973
+ );
3822
3974
  } catch {
3823
3975
  restored = "";
3824
3976
  }
@@ -3856,8 +4008,20 @@ async function runProfilesPanelAction(
3856
4008
  );
3857
4009
  return revertClaim(false);
3858
4010
  }
3859
- const applyResult = await applySavedModelConfig(ctx);
3860
- if (applyResult.invalidPath) {
4011
+ // models.json holds the profile as written; the padding with clear
4012
+ // entries only drives materialization, so agents the profile omits
4013
+ // return to inherit instead of keeping a previously materialized route.
4014
+ let applyResult: { updated: number; skipped: number };
4015
+ try {
4016
+ applyResult = await applyModelConfigAsync(
4017
+ ctx.cwd,
4018
+ await withOmittedAgentsClearedAsync(ctx.cwd, normalized),
4019
+ );
4020
+ } catch (error) {
4021
+ ctx.ui.notify(
4022
+ `el Gentleman could not materialize profile "${result.name}": ${profilesErrorMessage(error)}`,
4023
+ "warning",
4024
+ );
3861
4025
  return revertClaim(true);
3862
4026
  }
3863
4027
  let orchestratorNote = "";
@@ -3899,7 +4063,7 @@ async function runProfilesPanelAction(
3899
4063
  }
3900
4064
  }
3901
4065
  case "update": {
3902
- const current = await readModelConfigAsync(ctx.cwd);
4066
+ const current = await readEffectiveModelConfigAsync(ctx.cwd);
3903
4067
  // A profile is a complete snapshot, so capturing the current routing also
3904
4068
  // captures the orchestrator the routing is running under. A settings file
3905
4069
  // that cannot be read leaves the snapshot without an orchestrator entry
@@ -4035,7 +4199,7 @@ async function handleProfilesCommand(ctx: ExtensionContext): Promise<void> {
4035
4199
  }
4036
4200
  let file: AgentProfilesFile;
4037
4201
  if (read.status === "missing") {
4038
- file = bootstrapProfilesFile(await readModelConfigAsync(ctx.cwd));
4202
+ file = bootstrapProfilesFile(await readEffectiveModelConfigAsync(ctx.cwd));
4039
4203
  try {
4040
4204
  writeProfilesFileSync(path, file);
4041
4205
  } catch (error) {
@@ -4045,17 +4209,17 @@ async function handleProfilesCommand(ctx: ExtensionContext): Promise<void> {
4045
4209
  );
4046
4210
  return;
4047
4211
  }
4048
- ctx.ui.notify(`el Gentleman seeded the "current" profile in ${path} from the saved model routing.`, "info");
4212
+ ctx.ui.notify(`el Gentleman seeded the "current" profile in ${path} from the routing currently in effect.`, "info");
4049
4213
  } else {
4050
4214
  file = read.file;
4051
4215
  reportProfilesDrops(ctx, path, read.drops);
4052
4216
  }
4053
4217
  let selectedName: string | undefined;
4054
- let result = await showProfilesPanel(ctx, file, await readModelConfigAsync(ctx.cwd), selectedName);
4218
+ let result = await showProfilesPanel(ctx, file, await readEffectiveModelConfigAsync(ctx.cwd), selectedName);
4055
4219
  while (result.type !== "close") {
4056
4220
  selectedName = "name" in result ? result.name : undefined;
4057
4221
  file = await runProfilesPanelAction(ctx, path, file, result);
4058
- result = await showProfilesPanel(ctx, file, await readModelConfigAsync(ctx.cwd), selectedName);
4222
+ result = await showProfilesPanel(ctx, file, await readEffectiveModelConfigAsync(ctx.cwd), selectedName);
4059
4223
  }
4060
4224
  }
4061
4225
 
@@ -7927,6 +8091,8 @@ async function executeReviewControllerOperation(
7927
8091
  /** @internal */
7928
8092
  export const __testing = {
7929
8093
  resolveReviewModeGate,
8094
+ readEffectiveModelConfig,
8095
+ readEffectiveModelConfigAsync,
7930
8096
  listAgentsFromDir,
7931
8097
  listAgentsFromDirAsync,
7932
8098
  listDiscoverableAgents,
@@ -1,5 +1,9 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { closeSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
1
4
  import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
5
+ import os from "node:os";
6
+ import { dirname, join } from "node:path";
3
7
  import { emptyThread, type TaskRecord, type TaskThread } from "./agents-protocol.ts";
4
8
 
5
9
  // Gentle Agents history: one JSON file per finished task, written by the
@@ -38,6 +42,83 @@ function fileFor(dir: string, id: string): string {
38
42
  return join(dir, `${id}${FILE_SUFFIX}`);
39
43
  }
40
44
 
45
+ const TASK_LOCK_SCHEMA = "gentle-pi.task-reconciliation-lock/v1" as const;
46
+ const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
47
+ type TaskLockOwner = { schema: typeof TASK_LOCK_SCHEMA; taskId: string; token: string; pid: number; host: string | null };
48
+ export interface TaskLock { readonly path: string; readonly taskId: string; readonly token: string; release(): void; }
49
+ function taskLockHost(): string | null {
50
+ try {
51
+ const hostname = os.hostname().trim();
52
+ if (process.platform === "linux") { const boot = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(), namespace = readlinkSync("/proc/self/ns/pid"); return UUID.test(boot) && /^pid:\[\d+\]$/.test(namespace) ? `linux:${hostname}:${boot}:${namespace}` : null; }
53
+ if (process.platform === "darwin") { const boot = execFileSync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], { encoding: "utf8", timeout: 1000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"] }).trim(); return UUID.test(boot) ? `darwin:${hostname}:${boot.toLowerCase()}` : null; }
54
+ return hostname ? `${process.platform}:${hostname}` : null;
55
+ } catch { return null; }
56
+ }
57
+ function errorCode(error: unknown): string | undefined {
58
+ return typeof error === "object" && error !== null && "code" in error && typeof (error as { code?: unknown }).code === "string" ? (error as { code: string }).code : undefined;
59
+ }
60
+ function lockBusy(path: string, reason: string): Error { return new Error(`Task reconciliation lock is busy or ambiguous at ${path}: ${reason}`); }
61
+ function validTaskLockOwner(value: unknown, taskId: string): value is TaskLockOwner {
62
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
63
+ const owner = value as Partial<TaskLockOwner>;
64
+ return Object.keys(value).sort().join(",") === "host,pid,schema,taskId,token" && owner.schema === TASK_LOCK_SCHEMA && owner.taskId === taskId && typeof owner.token === "string" && UUID.test(owner.token) && Number.isSafeInteger(owner.pid) && owner.pid > 0 && (owner.host === null || typeof owner.host === "string" && owner.host.length > 0 && !owner.host.includes("\0"));
65
+ }
66
+ function ownerAt(path: string, taskId: string, token: string): TaskLockOwner {
67
+ if (!path.endsWith(`${taskId}.reconcile.${token}`)) throw lockBusy(path, "candidate filename is malformed");
68
+ const stat = lstatSync(path);
69
+ if (!stat.isFile() || stat.isSymbolicLink()) throw lockBusy(path, "candidate is not an expected regular file");
70
+ try {
71
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
72
+ if (!validTaskLockOwner(parsed, taskId) || parsed.token !== token) throw new Error("filename and owner metadata disagree");
73
+ return parsed;
74
+ } catch (error) { throw lockBusy(path, `candidate owner metadata is malformed${error instanceof Error ? `: ${error.message}` : ""}`); }
75
+ }
76
+ function ownerDead(owner: TaskLockOwner): boolean {
77
+ const host = taskLockHost();
78
+ if (!owner.host || !host || owner.host !== host || owner.pid === process.pid) return false;
79
+ try { process.kill(owner.pid, 0); return false; } catch (error) { return errorCode(error) === "ESRCH"; }
80
+ }
81
+ function syncTaskLock(path: string, directory = false): void {
82
+ if (directory && process.platform === "win32") return;
83
+ const descriptor = openSync(path, directory ? "r" : "r+");
84
+ try { fsyncSync(descriptor); } finally { closeSync(descriptor); }
85
+ }
86
+ function publishExclusive(path: string, owner: TaskLockOwner): void {
87
+ const temporary = `${path}.tmp`;
88
+ try { writeFileSync(temporary, JSON.stringify(owner), { encoding: "utf8", flag: "wx", mode: 0o600 }); syncTaskLock(temporary); linkSync(temporary, path); syncTaskLock(dirname(path), true); }
89
+ finally { try { unlinkSync(temporary); } catch {} }
90
+ }
91
+ function scanTaskCandidates(dir: string, id: string, ownPath: string): void {
92
+ const prefix = `${id}.reconcile.`;
93
+ for (const name of readdirSync(dir)) {
94
+ if (!name.startsWith(prefix) || name.endsWith(".tmp")) continue;
95
+ const path = join(dir, name), token = name.slice(prefix.length);
96
+ if (!UUID.test(token)) throw lockBusy(path, "candidate filename is malformed");
97
+ let owner: TaskLockOwner;
98
+ try { owner = ownerAt(path, id, token); } catch (error) { if (errorCode(error) === "ENOENT") throw lockBusy(path, "candidate disappeared during election"); throw error; }
99
+ if (path === ownPath) continue;
100
+ if (ownerDead(owner)) { try { unlinkSync(path); } catch (error) { if (errorCode(error) !== "ENOENT") throw error; } continue; }
101
+ throw lockBusy(path, "owner is live, foreign, or its death is inconclusive");
102
+ }
103
+ }
104
+ function releaseTaskLock(path: string, owner: TaskLockOwner): void {
105
+ let current: TaskLockOwner;
106
+ try { current = ownerAt(path, owner.taskId, owner.token); } catch (error) { if (errorCode(error) === "ENOENT") return; throw error; }
107
+ if (current.pid !== owner.pid || current.host !== owner.host) throw new Error("Task reconciliation lock owner token does not match");
108
+ unlinkSync(path); syncTaskLock(dirname(path), true);
109
+ }
110
+ export function acquireTaskLock(dir: string, id: string): TaskLock {
111
+ if (!SAFE_ID.test(id)) throw new Error(`invalid task id: ${id}`);
112
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
113
+ const owner: TaskLockOwner = { schema: TASK_LOCK_SCHEMA, taskId: id, token: randomUUID(), pid: process.pid, host: taskLockHost() };
114
+ const path = join(dir, `${id}.reconcile.${owner.token}`);
115
+ publishExclusive(path, owner);
116
+ try { scanTaskCandidates(dir, id, path); }
117
+ catch (error) { try { releaseTaskLock(path, owner); } catch {} throw error; }
118
+ let released = false;
119
+ return { path, taskId: id, token: owner.token, release() { if (!released) { releaseTaskLock(path, owner); released = true; } } };
120
+ }
121
+
41
122
  function isRecord(value: unknown): value is TaskRecord {
42
123
  const task = value as Partial<TaskRecord> | undefined;
43
124
  return typeof task?.id === "string" && typeof task.agent === "string" && typeof task.status === "string" && typeof task.createdAt === "number";