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.
- package/CHANGELOG.md +12 -0
- package/node_modules/pi-better-background-tasks/package.json +2 -1
- package/node_modules/pi-better-background-tasks/src/observed-waits.ts +302 -21
- package/node_modules/pi-better-background-tasks/src/shared-callback-batcher.ts +8 -3
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/assignment-observation.mjs +43 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/index.ts +5 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/assignment-outcomes.ts +172 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/discovery.ts +128 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/errors.ts +2 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/operations.ts +88 -6
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/outbox.ts +11 -2
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/recovery.ts +55 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/rows.ts +2 -1
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/schema.ts +114 -2
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/store.ts +83 -1
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/types.ts +46 -1
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/waits.ts +47 -5
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/shared-assignment-observation.mjs +43 -0
- package/node_modules/pi-better-goal/src/shared-assignment-projection.d.mts +15 -0
- package/node_modules/pi-better-goal/src/shared-assignment-projection.mjs +110 -0
- package/node_modules/pi-better-goal/src/subagents.ts +7 -3
- package/node_modules/pi-better-subagents/README.md +13 -0
- package/node_modules/pi-better-subagents/assignment-outcomes.ts +157 -0
- package/node_modules/pi-better-subagents/assignment-projection.d.mts +14 -0
- package/node_modules/pi-better-subagents/assignment-projection.mjs +109 -0
- package/node_modules/pi-better-subagents/cleanup.ts +12 -0
- package/node_modules/pi-better-subagents/docs/usage.md +14 -0
- package/node_modules/pi-better-subagents/finalization.ts +2 -0
- package/node_modules/pi-better-subagents/health-surface.mjs +2 -0
- package/node_modules/pi-better-subagents/historical-attention.ts +35 -0
- package/node_modules/pi-better-subagents/index.ts +206 -48
- package/node_modules/pi-better-subagents/list.mjs +7 -1
- package/node_modules/pi-better-subagents/navigator.mjs +23 -10
- package/node_modules/pi-better-subagents/outage-observations.ts +73 -0
- package/node_modules/pi-better-subagents/output-payload.ts +25 -6
- package/node_modules/pi-better-subagents/package.json +3 -1
- package/node_modules/pi-better-subagents/registry.ts +16 -3
- package/node_modules/pi-better-subagents/session-store.ts +1 -1
- package/node_modules/pi-better-subagents/shared-callback-batcher.ts +8 -3
- package/node_modules/pi-better-subagents/shared-operation-coordinator/assignment-observation.mjs +43 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/index.ts +5 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/assignment-outcomes.ts +172 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/discovery.ts +128 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/errors.ts +2 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/operations.ts +88 -6
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/outbox.ts +11 -2
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/recovery.ts +55 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/rows.ts +2 -1
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/schema.ts +114 -2
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/store.ts +83 -1
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/types.ts +46 -1
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/waits.ts +47 -5
- package/node_modules/pi-better-subagents/task-policy.ts +2 -0
- package/node_modules/pi-better-subagents/tools.ts +24 -7
- package/node_modules/pi-better-subagents/worker-waits.ts +258 -94
- 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.
|
|
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 =
|
|
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:
|
|
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.
|
|
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.
|
|
1105
|
-
|
|
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
|
-
|
|
1132
|
-
|
|
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
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
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
|
|
2707
|
-
// the next
|
|
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)
|
|
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,
|