pi-better-harness 0.43.0 → 0.45.0

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/node_modules/pi-better-background-tasks/package.json +2 -1
  3. package/node_modules/pi-better-background-tasks/src/observed-waits.ts +302 -21
  4. package/node_modules/pi-better-background-tasks/src/shared-callback-batcher.ts +8 -3
  5. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/assignment-observation.mjs +43 -0
  6. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/index.ts +5 -0
  7. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/assignment-outcomes.ts +172 -0
  8. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/discovery.ts +128 -0
  9. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/errors.ts +2 -0
  10. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/operations.ts +88 -6
  11. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/outbox.ts +11 -2
  12. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/recovery.ts +55 -0
  13. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/rows.ts +2 -1
  14. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/schema.ts +114 -2
  15. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/store.ts +83 -1
  16. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/types.ts +46 -1
  17. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/waits.ts +47 -5
  18. package/node_modules/pi-better-goal/package.json +1 -1
  19. package/node_modules/pi-better-goal/src/shared-assignment-observation.mjs +43 -0
  20. package/node_modules/pi-better-goal/src/shared-assignment-projection.d.mts +15 -0
  21. package/node_modules/pi-better-goal/src/shared-assignment-projection.mjs +110 -0
  22. package/node_modules/pi-better-goal/src/subagents.ts +7 -3
  23. package/node_modules/pi-better-subagents/README.md +13 -0
  24. package/node_modules/pi-better-subagents/assignment-outcomes.ts +157 -0
  25. package/node_modules/pi-better-subagents/assignment-projection.d.mts +14 -0
  26. package/node_modules/pi-better-subagents/assignment-projection.mjs +109 -0
  27. package/node_modules/pi-better-subagents/cleanup.ts +12 -0
  28. package/node_modules/pi-better-subagents/docs/usage.md +14 -0
  29. package/node_modules/pi-better-subagents/finalization.ts +2 -0
  30. package/node_modules/pi-better-subagents/health-surface.mjs +2 -0
  31. package/node_modules/pi-better-subagents/historical-attention.ts +35 -0
  32. package/node_modules/pi-better-subagents/index.ts +206 -48
  33. package/node_modules/pi-better-subagents/list.mjs +7 -1
  34. package/node_modules/pi-better-subagents/navigator.mjs +23 -10
  35. package/node_modules/pi-better-subagents/outage-observations.ts +73 -0
  36. package/node_modules/pi-better-subagents/output-payload.ts +25 -6
  37. package/node_modules/pi-better-subagents/package.json +3 -1
  38. package/node_modules/pi-better-subagents/registry.ts +16 -3
  39. package/node_modules/pi-better-subagents/session-store.ts +1 -1
  40. package/node_modules/pi-better-subagents/shared-callback-batcher.ts +8 -3
  41. package/node_modules/pi-better-subagents/shared-operation-coordinator/assignment-observation.mjs +43 -0
  42. package/node_modules/pi-better-subagents/shared-operation-coordinator/index.ts +5 -0
  43. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/assignment-outcomes.ts +172 -0
  44. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/discovery.ts +128 -0
  45. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/errors.ts +2 -0
  46. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/operations.ts +88 -6
  47. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/outbox.ts +11 -2
  48. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/recovery.ts +55 -0
  49. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/rows.ts +2 -1
  50. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/schema.ts +114 -2
  51. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/store.ts +83 -1
  52. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/types.ts +46 -1
  53. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/waits.ts +47 -5
  54. package/node_modules/pi-better-subagents/task-policy.ts +2 -0
  55. package/node_modules/pi-better-subagents/tools.ts +24 -7
  56. package/node_modules/pi-better-subagents/worker-waits.ts +258 -94
  57. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.45.0] - 2026-11-12
4
+
5
+ ### Changed
6
+
7
+ - Bundle Subagents 0.32.0 for session-scoped historical wait warnings, original-session inspection, durable cancellation, and dismissal of unchanged notices. (#719)
8
+
9
+ ## [0.44.0] - 2026-11-12
10
+
11
+ ### Changed
12
+
13
+ - Bundle Subagents 0.31.0, Background Tasks 0.15.0, and Goal 0.13.0 for autonomous assignment continuation, shared progress, durable outcomes, bounded recovery, and coordinator outage reconciliation. (#707)
14
+
3
15
  ## [0.43.0] - 2026-11-11
4
16
 
5
17
  ### Changed
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.14.4",
3
+ "version": "0.15.0",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -44,6 +44,7 @@
44
44
  },
45
45
  "files": [
46
46
  "src/**/*.ts",
47
+ "src/shared-operation-coordinator/assignment-observation.mjs",
47
48
  "src/supervisor/**/*.mjs",
48
49
  "src/supervisor/**/*.js",
49
50
  "!src/**/*.test.ts",
@@ -72,7 +72,7 @@ import {
72
72
  import type { BackgroundTaskCallbackOrigin, CommandSpec, Condition, TaskSandboxProvenance } from "./types.js";
73
73
  import type { SpawnTaskParams, WatchTaskParams } from "./runtime.js";
74
74
 
75
- export const OBSERVED_WAITS_SERVICE_VERSION = 1 as const;
75
+ export const OBSERVED_WAITS_SERVICE_VERSION = 2 as const;
76
76
  export const OBSERVED_WAITS_SERVICE_CHANNEL = "pi-better-background-tasks:observed-waits-service";
77
77
  export const OBSERVED_WAITS_SERVICE_REQUEST_CHANNEL = "pi-better-background-tasks:observed-waits-service-request";
78
78
 
@@ -185,6 +185,7 @@ export interface WaitView {
185
185
  readonly park: boolean;
186
186
  /** Cancellation epoch the caller must echo when claiming or cancelling. */
187
187
  readonly cancelEpoch: number;
188
+ readonly publishedClaimId?: string | null;
188
189
  readonly ownerKind: OwnerIdentity["kind"];
189
190
  readonly continuationNote: string;
190
191
  readonly taskIds: readonly string[];
@@ -470,6 +471,8 @@ export interface BackgroundOperationService {
470
471
  readonly serviceId: string;
471
472
  readonly available: true;
472
473
  /** True when a verified continuation adapter is registered. */
474
+ readonly assignmentContinuation: 1;
475
+ readonly outageReconciliation: 1;
473
476
  readonly continuationReady: boolean;
474
477
  ownsTask(run: WorkerRunScope, taskId: string): boolean;
475
478
  launchForWorker(run: WorkerRunScope, input: WorkerLaunchInput): Promise<DurableLaunch>;
@@ -490,6 +493,7 @@ export interface BackgroundOperationService {
490
493
  * epoch and evidence pins and invalidates the claim so nothing resumes again.
491
494
  */
492
495
  holdFailedResumeForWorker(run: WorkerRunScope, input: HoldResumedAttemptInput): HoldResumedAttemptView;
496
+ repairFailedResumeForWorker(run: WorkerRunScope, input: HoldResumedAttemptInput & { checkpointId: string; expectedCancelEpoch: number }): { phase: string; repaired: boolean };
493
497
  listWorkerWaits(run: WorkerRunScope, options?: { pendingOnly?: boolean }): readonly WaitView[];
494
498
  cancelWorkerWait(run: WorkerRunScope, input: { waitId: string; generation: number; reason: string }): WaitView;
495
499
  registerContinuationAdapter(adapter: ContinuationAdapter): void;
@@ -805,7 +809,7 @@ function evidenceFor(inspection: SupervisorInspection): WaitEvidenceView[] {
805
809
  operationId: "",
806
810
  terminalRevision: 0,
807
811
  kind: "log",
808
- ref: `${label}.log`,
812
+ ref: path,
809
813
  summary: `${label} retained (${size} bytes)`,
810
814
  detailLost: false,
811
815
  });
@@ -836,6 +840,8 @@ export class ObservedWaitHost {
836
840
  private store?: Store;
837
841
  private readonly supervisor: LocalSupervisor;
838
842
  private readonly directory: string;
843
+ private readonly storePath: string;
844
+ private readonly discoveryScope: string;
839
845
  private readonly pollMs: number;
840
846
  private readonly options: ObservedWaitHostOptions;
841
847
  private timer?: NodeJS.Timeout;
@@ -884,13 +890,27 @@ export class ObservedWaitHost {
884
890
  private readonly hostStartDigest = startTokenDigest(currentProcessStartToken());
885
891
  private readonly hostId = `${process.pid}:${this.hostStartDigest}:${randomUUID()}`;
886
892
  private hostGeneration = ++HOST_INSTANCE_STATE.next;
893
+ private discoveryToken?: string;
894
+ private discoveryRole?: "holder" | "follower";
895
+ private reconcileInFlight?: Promise<void>;
896
+ private authorityPassCache?: Map<string, "live" | "dead" | "unknown">;
897
+ /** Running operations this follower is currently assigned to supervise. */
898
+ private readonly followerRunningTasks = new Set<string>();
899
+ private lastChangeRevision = -1;
900
+ private lastOwnedTerminalAt = 0;
887
901
 
888
902
  constructor(private readonly pi: ExtensionAPI, options: ObservedWaitHostOptions = {}) {
889
903
  this.options = options;
890
904
  this.directory = options.supervisorDirectory ?? defaultSupervisorDirectory();
905
+ this.storePath = options.storePath ?? defaultStorePath();
906
+ this.discoveryScope = createHash("sha256")
907
+ .update(`${resolve(this.storePath)}\0${resolve(this.directory)}`)
908
+ .digest("hex");
891
909
  this.supervisor = options.supervisor ?? createLocalSupervisor({ directory: this.directory });
892
910
  this.pollMs = options.pollMs ?? DEFAULT_POLL_MS;
893
911
  const base = {
912
+ assignmentContinuation: 1 as const,
913
+ outageReconciliation: 1 as const,
894
914
  version: OBSERVED_WAITS_SERVICE_VERSION,
895
915
  serviceId: this.serviceId,
896
916
  available: true as const,
@@ -908,6 +928,10 @@ export class ObservedWaitHost {
908
928
  publishResumeForWorker: (run: WorkerRunScope, input: PublishResumeViewInput) => this.publishResumeForWorker(run, input),
909
929
  releaseResumeForWorker: (run: WorkerRunScope, input: ReleaseResumeViewInput) => this.releaseResumeForWorker(run, input),
910
930
  holdFailedResumeForWorker: (run: WorkerRunScope, input: HoldResumedAttemptInput) => this.holdFailedResumeForWorker(run, input),
931
+ repairFailedResumeForWorker: (run, input) => {
932
+ this.ownedWait(this.workerOwner(run), input.waitId, input.generation);
933
+ return this.requireStore().repairFailedResume(input);
934
+ },
911
935
  listWorkerWaits: (run: WorkerRunScope, listOptions?: { pendingOnly?: boolean }) => this.listWorkerWaits(run, listOptions),
912
936
  cancelWorkerWait: (run: WorkerRunScope, input: { waitId: string; generation: number; reason: string }) => this.cancelWorkerWait(run, input),
913
937
  registerContinuationAdapter: (adapter: ContinuationAdapter) => this.registerContinuationAdapter(adapter),
@@ -943,7 +967,6 @@ export class ObservedWaitHost {
943
967
  // The same host instance can be activated for another session without a
944
968
  // reload. Re-evaluate pending deliveries against the new active origin.
945
969
  this.recoverAbandonedDeliveries();
946
- this.discoverObservedTasks();
947
970
  void this.reconcileNow();
948
971
  this.recoverTerminalNotifications();
949
972
  this.terminalRecoveryNeeded = false;
@@ -955,7 +978,7 @@ export class ObservedWaitHost {
955
978
  this.unavailableReason = undefined;
956
979
  try {
957
980
  this.store = openStore({
958
- path: this.options.storePath ?? defaultStorePath(),
981
+ path: this.storePath,
959
982
  ...(this.options.now ? { now: this.options.now } : {}),
960
983
  });
961
984
  } catch (error) {
@@ -971,7 +994,6 @@ export class ObservedWaitHost {
971
994
  this.announce();
972
995
  this.writeHostDebug();
973
996
  this.recoverAbandonedDeliveries();
974
- this.discoverObservedTasks();
975
997
  void this.reconcileNow();
976
998
  this.recoverTerminalNotifications();
977
999
  this.terminalRecoveryNeeded = false;
@@ -987,6 +1009,8 @@ export class ObservedWaitHost {
987
1009
  HOST_INSTANCE_STATE.closed.add(this.hostId);
988
1010
  if (this.timer) clearInterval(this.timer);
989
1011
  this.timer = undefined;
1012
+ this.releaseDiscoveryAuthority();
1013
+ this.releaseSupervisionAuthority();
990
1014
  try { this.store?.close(); } catch { /* already closed */ }
991
1015
  this.store = undefined;
992
1016
  this.launches.clear();
@@ -1100,9 +1124,40 @@ export class ObservedWaitHost {
1100
1124
 
1101
1125
  /** A deterministic, awaitable reconciliation pass for tests and session activation. */
1102
1126
  async reconcileNow(): Promise<void> {
1127
+ if (this.reconcileInFlight) return this.reconcileInFlight;
1128
+ const pass = this.reconcilePass();
1129
+ this.reconcileInFlight = pass;
1130
+ try {
1131
+ await pass;
1132
+ } finally {
1133
+ if (this.reconcileInFlight === pass) this.reconcileInFlight = undefined;
1134
+ }
1135
+ }
1136
+
1137
+ private async reconcilePass(): Promise<void> {
1103
1138
  if (!this.store || this.closed) return;
1104
- this.discoverObservedTasks();
1105
- const running = this.store.listOperations({ phase: "running" });
1139
+ this.authorityPassCache = new Map();
1140
+ try {
1141
+ const role = this.refreshDiscoveryAuthority();
1142
+ this.noteDiscoveryRole(role);
1143
+ if (role === "holder") {
1144
+ this.discoverObservedTasks();
1145
+ this.reconcileGlobalRunning();
1146
+ } else {
1147
+ this.reconcileFollowerRunning();
1148
+ this.reconcileOwnerChanges();
1149
+ }
1150
+ this.retryPendingTerminalCallbacks();
1151
+ await this.dispatchOutbox();
1152
+ } finally {
1153
+ this.authorityPassCache = undefined;
1154
+ }
1155
+ }
1156
+
1157
+ private reconcileGlobalRunning(): void {
1158
+ const store = this.store;
1159
+ if (!store) return;
1160
+ const running = store.listOperations({ phase: "running" });
1106
1161
  const runningIds = new Set(running.map((operation) => operation.taskId));
1107
1162
  let changedOwner: OwnerIdentity | undefined;
1108
1163
  for (const taskId of [...this.watchedRunningTasks]) {
@@ -1128,8 +1183,126 @@ export class ObservedWaitHost {
1128
1183
  this.watchedRunningTasks.add(operation.taskId);
1129
1184
  this.reconcileOperation(operation);
1130
1185
  }
1131
- this.retryPendingTerminalCallbacks();
1132
- await this.dispatchOutbox();
1186
+ }
1187
+
1188
+ private reconcileFollowerRunning(): void {
1189
+ const store = this.store;
1190
+ if (!store) return;
1191
+ const running = store.listRunningSupervisedBy(this.hostId);
1192
+ const runningIds = new Set(running.map((operation) => operation.taskId));
1193
+ for (const taskId of [...this.followerRunningTasks]) {
1194
+ if (!runningIds.has(taskId)) this.followerRunningTasks.delete(taskId);
1195
+ }
1196
+ for (const operation of running) {
1197
+ this.followerRunningTasks.add(operation.taskId);
1198
+ this.reconcileOperation(operation);
1199
+ }
1200
+ }
1201
+
1202
+ /**
1203
+ * A follower does not scan foreign operations. It refreshes the exact active
1204
+ * owner's terminal records only when the shared change revision advances, so
1205
+ * callback recovery stays correct without multiplying the running-set scan.
1206
+ */
1207
+ private reconcileOwnerChanges(): void {
1208
+ const store = this.store;
1209
+ const ctx = this.activeContext;
1210
+ if (!store || !ctx) return;
1211
+ const sessionId = this.sessionIdIfAvailable(ctx);
1212
+ if (!sessionId) return;
1213
+ let revision: number;
1214
+ try {
1215
+ revision = store.getChangeRevision();
1216
+ } catch {
1217
+ return;
1218
+ }
1219
+ if (revision === this.lastChangeRevision) return;
1220
+ this.lastChangeRevision = revision;
1221
+ let operations: OperationRecord[];
1222
+ try {
1223
+ operations = store.listOperations({
1224
+ ownerScope: { kind: "parent-session", sessionId, workspace: ctx.cwd },
1225
+ phase: "terminal",
1226
+ });
1227
+ } catch {
1228
+ return;
1229
+ }
1230
+ let newest = this.lastOwnedTerminalAt;
1231
+ let visibleOwner: OwnerIdentity | undefined;
1232
+ for (const operation of operations) {
1233
+ newest = Math.max(newest, operation.terminalAt ?? operation.updatedAt);
1234
+ if (this.activeOwnerRelation(operation.owner) === "match") visibleOwner ??= operation.owner;
1235
+ this.attemptTerminalCallback(
1236
+ operation,
1237
+ this.statusOf(operation.operationId),
1238
+ operation.terminalOutcome ?? "failed",
1239
+ [],
1240
+ { pendingWithoutContext: true },
1241
+ );
1242
+ }
1243
+ if (newest > this.lastOwnedTerminalAt) {
1244
+ this.lastOwnedTerminalAt = newest;
1245
+ if (visibleOwner) this.emitDurableTaskChange(visibleOwner);
1246
+ }
1247
+ }
1248
+
1249
+ private noteDiscoveryRole(role: "holder" | "follower"): void {
1250
+ if (this.discoveryRole === role) return;
1251
+ this.discoveryRole = role;
1252
+ this.followerRunningTasks.clear();
1253
+ if (role === "holder") {
1254
+ this.watchedRunningTasks.clear();
1255
+ this.discoveredTaskSidecars.clear();
1256
+ }
1257
+ this.lastChangeRevision = -1;
1258
+ }
1259
+
1260
+ /**
1261
+ * Acquire or confirm the one shared discovery claim. Process identity and
1262
+ * explicit close evidence decide replacement; elapsed time is not a lease.
1263
+ */
1264
+ private refreshDiscoveryAuthority(): "holder" | "follower" {
1265
+ const store = this.store;
1266
+ if (!store) return "follower";
1267
+ let claim;
1268
+ try {
1269
+ claim = store.readDiscoveryClaim(this.discoveryScope);
1270
+ } catch {
1271
+ return "follower";
1272
+ }
1273
+ if (claim?.hostId === this.hostId) {
1274
+ this.discoveryToken = claim.token;
1275
+ return "holder";
1276
+ }
1277
+ if (claim) {
1278
+ const state = this.discoveryHolderState(claim.hostId);
1279
+ if (state === "live" || state === "unknown") {
1280
+ this.discoveryToken = undefined;
1281
+ return "follower";
1282
+ }
1283
+ }
1284
+ const token = `discovery_${randomUUID()}`;
1285
+ try {
1286
+ const claimed = store.tryClaimDiscovery({
1287
+ scope: this.discoveryScope,
1288
+ expected: claim ? { hostId: claim.hostId, token: claim.token } : null,
1289
+ hostId: this.hostId,
1290
+ token,
1291
+ });
1292
+ if (claimed) {
1293
+ this.discoveryToken = claimed.token;
1294
+ return "holder";
1295
+ }
1296
+ } catch {
1297
+ // A busy store or a lost race defers discovery to a later pass.
1298
+ }
1299
+ this.discoveryToken = undefined;
1300
+ return "follower";
1301
+ }
1302
+
1303
+ private discoveryHolderState(hostId: string): "live" | "dead" | "unknown" {
1304
+ if (hostId === this.hostId) return "live";
1305
+ return this.supervisionHolderState(hostId);
1133
1306
  }
1134
1307
 
1135
1308
  isDurableTask(taskId: string): boolean {
@@ -2076,6 +2249,7 @@ export class ObservedWaitHost {
2076
2249
  }
2077
2250
 
2078
2251
  private rpcRegisterRun(params: JsonRecord): JsonRecord {
2252
+ if (params.assignmentOutcomesVersion !== undefined && params.assignmentOutcomesVersion !== 1) throw new Error("Unsupported assignment outcome capability; this host supports version 1.");
2079
2253
  const runId = requireRpcString(params, "runId");
2080
2254
  const conversationId = requireRpcString(params, "conversationId");
2081
2255
  const workspace = requireRpcString(params, "workspace");
@@ -2112,7 +2286,7 @@ export class ObservedWaitHost {
2112
2286
  ...(typeof params.writerPid === "number" && params.writerPid > 0 ? { writerPid: params.writerPid } : {}),
2113
2287
  ...(typeof params.writerLeaseId === "string" ? { writerLeaseId: params.writerLeaseId } : {}),
2114
2288
  }));
2115
- return { runId, conversationId, workspace, attemptId, workerOperations: true };
2289
+ return { runId, conversationId, workspace, attemptId, workerOperations: true, assignmentOutcomesVersion: 1 };
2116
2290
  }
2117
2291
 
2118
2292
  private rpcReleaseRun(params: JsonRecord): JsonRecord {
@@ -2652,6 +2826,27 @@ export class ObservedWaitHost {
2652
2826
 
2653
2827
  private reconcileOperation(operation: OperationRecord): void {
2654
2828
  if (operation.terminalRevision > 0) return;
2829
+ const authority = this.supervisionAuthority(operation);
2830
+ if (authority === "foreign") return;
2831
+ let claimed = false;
2832
+ if (authority === "replace") {
2833
+ try {
2834
+ this.store?.recordSupervision({
2835
+ operationId: operation.operationId,
2836
+ owner: operation.owner,
2837
+ expectedHostId: operation.supervision?.hostId ?? null,
2838
+ supervision: this.supervision(),
2839
+ health: "active",
2840
+ });
2841
+ claimed = true;
2842
+ } catch (error) {
2843
+ // Another host can hold the write lock, or it can take over between the
2844
+ // snapshot and this write. Leave the observed record alone and let the
2845
+ // next reconciliation tick re-read it.
2846
+ if (rpcCode(error) === "store-busy" || rpcCode(error) === "supervision-conflict") return;
2847
+ throw error;
2848
+ }
2849
+ }
2655
2850
  const inspection = this.inspect(operation.taskId);
2656
2851
  if (!inspection) {
2657
2852
  const now = this.options.now?.() ?? Date.now();
@@ -2679,12 +2874,15 @@ export class ObservedWaitHost {
2679
2874
  return;
2680
2875
  }
2681
2876
  try {
2682
- this.store?.recordSupervision({
2683
- operationId: operation.operationId,
2684
- owner: operation.owner,
2685
- health: "unmonitored",
2686
- reason: "no durable supervisor record exists for this operation",
2687
- });
2877
+ if (claimed || operation.health !== "unmonitored" || operation.healthReason !== "no durable supervisor record exists for this operation") {
2878
+ this.store?.recordSupervision({
2879
+ operationId: operation.operationId,
2880
+ owner: operation.owner,
2881
+ expectedHostId: this.hostId,
2882
+ health: "unmonitored",
2883
+ reason: "no durable supervisor record exists for this operation",
2884
+ });
2885
+ }
2688
2886
  } catch { /* the owner may have been superseded; health is observational */ }
2689
2887
  return;
2690
2888
  }
@@ -2692,24 +2890,102 @@ export class ObservedWaitHost {
2692
2890
  this.recordTerminal(operation, inspection);
2693
2891
  return;
2694
2892
  }
2695
- if (operation.health !== "active" || operation.supervision?.hostId !== this.hostId) {
2893
+ if (!claimed && (operation.health !== "active" || operation.supervision?.hostId !== this.hostId)) {
2696
2894
  // Health is observational; write it only when the supervising host or the
2697
2895
  // reported health actually changed. A 250 ms tick must not churn SQLite.
2698
2896
  try {
2699
2897
  this.store?.recordSupervision({
2700
2898
  operationId: operation.operationId,
2701
2899
  owner: operation.owner,
2900
+ expectedHostId: this.hostId,
2702
2901
  supervision: this.supervision(),
2703
2902
  health: "active",
2704
2903
  });
2705
2904
  } catch (error) {
2706
- // Another host can hold the write lock. Leave health unchanged and let
2707
- // the next reconciliation tick retry this observational update.
2708
- if (rpcCode(error) !== "store-busy") throw error;
2905
+ // Another host can hold the write lock or have taken over. Leave health
2906
+ // unchanged and let the next tick re-read the operation.
2907
+ if (rpcCode(error) !== "store-busy" && rpcCode(error) !== "supervision-conflict") throw error;
2908
+ }
2909
+ }
2910
+ }
2911
+
2912
+ /**
2913
+ * Whether this host may inspect the operation. The recorded `supervision`
2914
+ * identity is the cross-process authority marker: a live holder keeps it, and
2915
+ * only a closed or dead holder is replaced. Process identity is checked before
2916
+ * elapsed time, and an unprovable holder is never stolen from.
2917
+ */
2918
+ private supervisionAuthority(operation: OperationRecord): "self" | "foreign" | "replace" {
2919
+ const hostId = operation.supervision?.hostId;
2920
+ if (!hostId) return "replace";
2921
+ if (hostId === this.hostId) return "self";
2922
+ if (HOST_INSTANCE_STATE.closed.has(hostId)) return "replace";
2923
+ const cached = this.authorityPassCache?.get(hostId);
2924
+ const state = cached ?? this.supervisionHolderState(hostId);
2925
+ if (cached === undefined) this.authorityPassCache?.set(hostId, state);
2926
+ return state === "live" || state === "unknown" ? "foreign" : "replace";
2927
+ }
2928
+
2929
+ /** Process-identity check for a recorded holder, cached per pass by callers. */
2930
+ private supervisionHolderState(hostId: string): "live" | "dead" | "unknown" {
2931
+ const { pid, startTokenDigest: claimedDigest } = parseHostIdentity(hostId);
2932
+ if (!pid) return "unknown";
2933
+ if (pid === process.pid) {
2934
+ const generation = HOST_INSTANCE_STATE.generations.get(hostId);
2935
+ // Another instance in this process still owns its record. Creation order
2936
+ // alone is not death: a reload predecessor is marked closed above.
2937
+ return generation === undefined ? "dead" : "live";
2938
+ }
2939
+ if (!processExists(pid)) return "dead";
2940
+ if (!claimedDigest) return "unknown";
2941
+ const current = readProcessStartToken(pid);
2942
+ if (!current) return "unknown";
2943
+ return startTokenDigest(current) === claimedDigest ? "live" : "dead";
2944
+ }
2945
+
2946
+ /**
2947
+ * A graceful close releases only this host's running authority. A crash leaves
2948
+ * the same record for the next host to replace after process identity proves it
2949
+ * dead; a newer holder is never cleared by a late close.
2950
+ */
2951
+ private releaseSupervisionAuthority(): void {
2952
+ const store = this.store;
2953
+ if (!store) return;
2954
+ let running: OperationRecord[];
2955
+ try {
2956
+ running = store.listOperations({ phase: "running" });
2957
+ } catch {
2958
+ return;
2959
+ }
2960
+ for (const operation of running) {
2961
+ if (operation.supervision?.hostId !== this.hostId) continue;
2962
+ try {
2963
+ store.releaseSupervision({
2964
+ operationId: operation.operationId,
2965
+ owner: operation.owner,
2966
+ hostId: this.hostId,
2967
+ reason: "the supervising host closed; another host may take over",
2968
+ });
2969
+ } catch {
2970
+ // Closing must not fail on a busy or already replaced record.
2709
2971
  }
2710
2972
  }
2711
2973
  }
2712
2974
 
2975
+ /** Release only this host's exact discovery claim; a successor keeps its own. */
2976
+ private releaseDiscoveryAuthority(): void {
2977
+ const store = this.store;
2978
+ const token = this.discoveryToken;
2979
+ this.discoveryToken = undefined;
2980
+ this.discoveryRole = undefined;
2981
+ if (!store || !token) return;
2982
+ try {
2983
+ store.releaseDiscoveryClaim({ scope: this.discoveryScope, hostId: this.hostId, token });
2984
+ } catch {
2985
+ // Closing must not fail on a busy or already replaced claim.
2986
+ }
2987
+ }
2988
+
2713
2989
  private reconcileInspection(operation: OperationRecord, inspection: SupervisorInspection | undefined): void {
2714
2990
  if (inspection?.terminal && operation.terminalRevision === 0) this.recordTerminal(operation, inspection);
2715
2991
  }
@@ -3108,6 +3384,7 @@ export class ObservedWaitHost {
3108
3384
  let claimed: OutboxEventRecord[];
3109
3385
  try {
3110
3386
  claimed = store.claimOutbox({
3387
+ type: "dependency-ready",
3111
3388
  recipient: owner,
3112
3389
  claimToken,
3113
3390
  claimedBy: this.hostId,
@@ -3295,6 +3572,7 @@ export class ObservedWaitHost {
3295
3572
  settled: wait.settled,
3296
3573
  park: wait.park !== null,
3297
3574
  cancelEpoch: wait.cancelEpoch,
3575
+ publishedClaimId: store.listResumeClaims({ waitId: wait.waitId, generation: wait.generation, state: "published" })[0]?.claimId ?? null,
3298
3576
  ownerKind: wait.owner.kind,
3299
3577
  continuationNote: wait.continuationNote,
3300
3578
  taskIds: dependencies
@@ -3527,7 +3805,10 @@ export class ObservedWaitHost {
3527
3805
  // A tool call or activation for a different session/workspace must seed the
3528
3806
  // terminal callback journal once. Same-session calls keep the live context
3529
3807
  // current without rescanning historical terminal records.
3530
- if (!previous || previousKey !== nextKey) this.terminalRecoveryNeeded = true;
3808
+ if (!previous || previousKey !== nextKey) {
3809
+ this.terminalRecoveryNeeded = true;
3810
+ this.lastChangeRevision = -1;
3811
+ }
3531
3812
  this.writeHostDebug();
3532
3813
  }
3533
3814
 
@@ -119,12 +119,14 @@ export interface CallbackBatchHost {
119
119
  /** Older Pi shares this object; newer Pi supplies per-extension wrappers. */
120
120
  events?: object;
121
121
  sendMessage(
122
- message: { customType: string; content: string; display: boolean },
122
+ message: { customType: string; content: string; display: boolean; details?: unknown },
123
123
  options: Record<string, unknown>,
124
124
  ): unknown;
125
125
  }
126
126
 
127
127
  export interface CallbackBatchEvent {
128
+ receiptId?: string;
129
+ inspectId?: string;
128
130
  source: CallbackSource;
129
131
  id: string;
130
132
  label: string;
@@ -332,8 +334,8 @@ function boundedStatus(value: unknown): string {
332
334
  return count < notes.length ? `${kept}${omission(notes.length - count)}` : kept;
333
335
  }
334
336
 
335
- function inspectFor(event: Pick<CallbackBatchEvent, "id" | "detailTool">): string {
336
- const id = boundedField(event.id, MAX_ID_BYTES);
337
+ function inspectFor(event: Pick<CallbackBatchEvent, "id" | "detailTool" | "inspectId">): string {
338
+ const id = boundedField(event.inspectId ?? event.id, MAX_ID_BYTES);
337
339
  return event.detailTool === "bg_task_status"
338
340
  ? `bg_task_status id=${id}`
339
341
  : `subagent_result id=${JSON.stringify(id)}`;
@@ -665,6 +667,7 @@ export function createCallbackBatcher(
665
667
  const representedItems = deliverable.filter(([, item]) => representedSet.has(item.event));
666
668
  const overflowItems = deliverable.filter(([, item]) => !representedSet.has(item.event));
667
669
 
670
+ const receiptDetails = { completionEventIds: representedItems.map(([, item]) => item.event.receiptId).filter((id): id is string => Boolean(id)) };
668
671
  try {
669
672
  // Pi may defer the requested run while settled handlers finish, leaving
670
673
  // isIdle() true. Permit no second handoff until a run starts.
@@ -674,12 +677,14 @@ export function createCallbackBatcher(
674
677
  customType: "background-completion-batch",
675
678
  content: packed.text,
676
679
  display: true,
680
+ ...(receiptDetails.completionEventIds.length ? { details: receiptDetails } : {}),
677
681
  },
678
682
  // Newer Pi requires triggerTurn for steering too; false only appends
679
683
  // a custom message at the turn boundary without continuing the agent.
680
684
  { deliverAs: mode, triggerTurn: true },
681
685
  );
682
686
  } catch {
687
+ receiptDetails.completionEventIds.length = 0;
683
688
  awaitingRun = false;
684
689
  for (const [key] of deliverable) inFlight.delete(key);
685
690
  const retryItems = [...deliverable, ...pending.entries()]
@@ -0,0 +1,43 @@
1
+ // Generated from packages/operation-coordinator/assignment-observation.mjs. Do not edit directly.
2
+ import { createRequire } from "node:module";
3
+
4
+ /** Read-only, bounded projection input. Never creates/migrates a database. */
5
+ export function readAssignmentWaits(path, owner) {
6
+ let db;
7
+ try {
8
+ const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
9
+ db = new DatabaseSync(path, { readOnly: true });
10
+ // v4 adds repair accounting; the observed wait/outcome fields are unchanged.
11
+ if (!["1", "2", "3", "4"].includes(db.prepare("SELECT value FROM schema_meta WHERE key = 'schema_version'").get()?.value)) {
12
+ return { verified: false, waits: [], reason: "Unsupported coordinator schema; assignment state is unverified." };
13
+ }
14
+ const rows = db.prepare(`SELECT wait_id, generation, phase, phase_reason,
15
+ acknowledgement_json, cancel_epoch FROM waits
16
+ WHERE owner_kind = 'worker-conversation' AND owner_session_id = ? AND owner_workspace = ?
17
+ ${owner.assignmentId ? "AND json_extract(origin_json, '$.assignmentRef') = ?" : `AND (
18
+ EXISTS (SELECT 1 FROM json_each(?) AS attempt
19
+ WHERE attempt.value = json_extract(waits.origin_json, '$.attemptId')
20
+ OR attempt.value = json_extract(waits.park_json, '$.attemptId'))
21
+ OR EXISTS (SELECT 1 FROM resume_claims AS claim, json_each(?) AS attempt
22
+ WHERE claim.wait_id = waits.wait_id AND claim.generation = waits.generation
23
+ AND claim.attempt_id = attempt.value)
24
+ OR EXISTS (SELECT 1 FROM json_each(?) AS ref
25
+ WHERE json_extract(ref.value, '$.waitId') = waits.wait_id
26
+ AND json_extract(ref.value, '$.generation') = waits.generation)
27
+ )`}
28
+ ORDER BY created_at DESC, generation DESC LIMIT 100`).all(owner.sessionId, owner.workspace,
29
+ ...(owner.assignmentId ? [owner.assignmentId] : [JSON.stringify(owner.attemptIds ?? []), JSON.stringify(owner.attemptIds ?? []), JSON.stringify(owner.waitRefs ?? [])]));
30
+ const record = owner.assignmentId ? db.prepare("SELECT record_json FROM assignment_outcomes WHERE assignment_id = ?").get(owner.assignmentId) : null;
31
+ const outcome = record ? JSON.parse(record.record_json) : null;
32
+ if (outcome && (outcome.owner.sessionId !== owner.sessionId || outcome.owner.workspace !== owner.workspace)) throw new Error("Foreign assignment outcome");
33
+ return { verified: true, outcome, waits: rows.map((row) => ({
34
+ waitId: row.wait_id, generation: row.generation, phase: row.phase,
35
+ reason: row.phase_reason, cancelEpoch: row.cancel_epoch,
36
+ acknowledgement: row.acknowledgement_json ? JSON.parse(row.acknowledgement_json) : null,
37
+ })) };
38
+ } catch {
39
+ return { verified: false, waits: [], reason: "Coordinator state is unverified; compatible supervision is unavailable." };
40
+ } finally {
41
+ db?.close();
42
+ }
43
+ }
@@ -45,6 +45,7 @@
45
45
  * to be durable.
46
46
  */
47
47
  export { SCHEMA_VERSION, TERMINAL_WAIT_PHASES } from "./src/types.ts";
48
+ export type { AssignmentOutcomeInput, AssignmentOutcomeRecord } from "./src/assignment-outcomes.ts";
48
49
  export type {
49
50
  AcknowledgeDisposition,
50
51
  AcknowledgeResultInput,
@@ -53,6 +54,7 @@ export type {
53
54
  CancelWaitResult,
54
55
  ClaimOutboxInput,
55
56
  ClaimResumeInput,
57
+ DiscoveryClaimRecord,
56
58
  EvidenceKind,
57
59
  EvidencePinRecord,
58
60
  EvidenceReadRecord,
@@ -85,6 +87,8 @@ export type {
85
87
  RegisterOperationResult,
86
88
  RegisterWaitInput,
87
89
  RegisterWaitResult,
90
+ ReleaseDiscoveryClaimInput,
91
+ ReleaseSupervisionInput,
88
92
  ReleaseOutboxInput,
89
93
  ReleaseResumeClaimInput,
90
94
  ReleaseResumeClaimResult,
@@ -98,6 +102,7 @@ export type {
98
102
  TerminalOutcome,
99
103
  TransitionWaitPhaseInput,
100
104
  TransitionWaitPhaseResult,
105
+ TryClaimDiscoveryInput,
101
106
  WaitAcknowledgement,
102
107
  WaitDependencyInput,
103
108
  WaitDependencyRecord,