pi-amq 0.1.1 → 0.1.2

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.
@@ -2,7 +2,7 @@ import { spawn as spawnChild } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { access, readFile } from "node:fs/promises";
4
4
  import { constants } from "node:fs";
5
- import { delimiter, isAbsolute, join } from "node:path";
5
+ import { delimiter, isAbsolute, join, normalize, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
8
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -55,7 +55,12 @@ import {
55
55
  NodePiProcessFactory,
56
56
  WorkerProcessSupervisor,
57
57
  } from "../workflow/worker-process-supervisor.js";
58
- import { WorkflowLeaseManager } from "../workflow/workflow-lease.js";
58
+ import {
59
+ WorkflowLeaseManager,
60
+ type ProcessIdentity,
61
+ type ProcessIdentityInspector,
62
+ type WorkflowLeaseOwnerV1,
63
+ } from "../workflow/workflow-lease.js";
59
64
  import type {
60
65
  RunProjectionV2,
61
66
  ModelSpec,
@@ -94,6 +99,37 @@ const CAPABILITY_TOOLS: Readonly<Record<string, readonly string[]>> = {
94
99
  message_worker: ["message_worker"],
95
100
  };
96
101
 
102
+ const WORKER_RUNTIME_REGISTRY_SYMBOL = Symbol.for("pi-amq.worker-runtime-registry.v1");
103
+
104
+ interface WorkerRuntimeCoreV1 {
105
+ readonly schema: 1;
106
+ readonly key: string;
107
+ readonly workspacePath: string;
108
+ readonly workspaceId: string;
109
+ readonly namespaceDirectory: string;
110
+ readonly processIdentity: ProcessIdentity;
111
+ readonly supervisorGeneration: string;
112
+ readonly leaseManager: WorkflowLeaseManager;
113
+ readonly store: WorkflowRunStore;
114
+ readonly supervisor: WorkerProcessSupervisor;
115
+ readonly cleanup: WorkflowRunCleanup;
116
+ readonly doctor: WorkflowDoctor;
117
+ readonly artifactDirectory: string;
118
+ readonly requestKey: string;
119
+ }
120
+
121
+ interface WorkerRuntimeRegistryV1 {
122
+ readonly schema: 1;
123
+ readonly entries: Map<string, unknown>;
124
+ }
125
+
126
+ interface WorkerRuntimeAttachment {
127
+ readonly core: WorkerRuntimeCoreV1;
128
+ readonly notifier: WorkerCompletionNotifier;
129
+ active: boolean;
130
+ unsubscribe: () => void;
131
+ }
132
+
97
133
  export class PiWorkerRuntime implements WorkerToolProvider {
98
134
  readonly #publishFleet: (records: readonly FleetAgentRecord[]) => void;
99
135
  readonly #unlimitedDeclaration: () => unknown;
@@ -111,15 +147,7 @@ export class PiWorkerRuntime implements WorkerToolProvider {
111
147
  #requestKey: string | undefined;
112
148
  #error: string | undefined;
113
149
  #guidance = DRIVER_DOCTRINE;
114
- readonly #runtimes = new Map<string, {
115
- readonly store: WorkflowRunStore;
116
- readonly supervisor: WorkerProcessSupervisor;
117
- readonly cleanup: WorkflowRunCleanup;
118
- readonly doctor: WorkflowDoctor;
119
- readonly artifactDirectory: string;
120
- readonly requestKey: string;
121
- readonly unsubscribe: () => void;
122
- }>();
150
+ #attachment: WorkerRuntimeAttachment | undefined;
123
151
 
124
152
  constructor(
125
153
  publishFleet: (records: readonly FleetAgentRecord[]) => void = () => {},
@@ -134,36 +162,44 @@ export class PiWorkerRuntime implements WorkerToolProvider {
134
162
  }
135
163
 
136
164
  async start(ctx: ExtensionContext): Promise<void> {
165
+ this.#detach();
137
166
  await this.refreshGuidance(ctx);
138
- const existing = this.#runtimes.get(ctx.cwd);
139
- if (existing) {
140
- this.#cwd = ctx.cwd;
141
- this.#store = existing.store;
142
- this.#supervisor = existing.supervisor;
143
- this.#cleanup = existing.cleanup;
144
- this.#doctor = existing.doctor;
145
- this.#artifactDirectory = existing.artifactDirectory;
146
- this.#requestKey = existing.requestKey;
147
- existing.supervisor.updateLaunchAuthority(await launchAuthority(ctx));
148
- this.#publish(await requiredRun(existing.store, existing.requestKey));
149
- void this.#publishWithConversation(await requiredRun(existing.store, existing.requestKey));
150
- return;
151
- }
152
167
  try {
153
- const runBudget = await confirmedRunBudget(ctx, this.#unlimitedDeclaration());
154
- const authority = await launchAuthority(ctx);
155
- const namespaceDirectory = workflowNamespaceDirectory(defaultPiStateDirectory(), ctx.cwd);
168
+ const workspacePath = canonicalWorkerWorkspace(ctx.cwd);
169
+ const workspaceId = workflowWorkspaceId(workspacePath);
170
+ const namespaceDirectory = workflowNamespaceDirectory(defaultPiStateDirectory(), workspacePath);
171
+ const key = workerRuntimeRegistryKey(namespaceDirectory, workspaceId);
172
+ const registry = processWorkerRuntimeRegistry();
156
173
  const inspector = new SystemProcessIdentityInspector();
157
174
  const leaseManager = new WorkflowLeaseManager({
158
175
  namespaceDirectory,
159
- workspaceId: workflowWorkspaceId(ctx.cwd),
176
+ workspaceId,
160
177
  inspector,
161
178
  });
179
+ const registered = registry.entries.get(key);
180
+ if (registered !== undefined) {
181
+ const existing = parseWorkerRuntimeCore(registered, key);
182
+ await validateRuntimeAdoption(existing, {
183
+ workspacePath,
184
+ workspaceId,
185
+ namespaceDirectory,
186
+ inspector,
187
+ leaseManager,
188
+ });
189
+ existing.supervisor.updateLaunchAuthority(await launchAuthority(ctx));
190
+ const current = await requiredRun(existing.store, existing.requestKey);
191
+ this.#attach(existing, current);
192
+ this.#error = undefined;
193
+ return;
194
+ }
195
+
196
+ const runBudget = await confirmedRunBudget(ctx, this.#unlimitedDeclaration());
197
+ const authority = await launchAuthority(ctx);
162
198
  const acquired = await leaseManager.acquireSupervisor();
163
199
  if (acquired.kind !== "acquired") {
164
200
  throw new Error(
165
201
  acquired.kind === "held"
166
- ? `worker supervisor is held by PID ${acquired.owner.pid}`
202
+ ? `worker supervisor is held by PID ${acquired.owner.pid}; fully restart Pi without deleting the supervisor lease`
167
203
  : acquired.diagnostic,
168
204
  );
169
205
  }
@@ -173,7 +209,7 @@ export class PiWorkerRuntime implements WorkerToolProvider {
173
209
  supervisorGeneration: acquired.owner.generation,
174
210
  });
175
211
  const sessionIdentity = ctx.sessionManager.getSessionFile() ?? `process:${process.pid}`;
176
- const requestKey = `pi-session:${hash(`${ctx.cwd}\0${sessionIdentity}`)}`;
212
+ const requestKey = `pi-session:${hash(`${workspacePath}\0${sessionIdentity}`)}`;
177
213
  await store.startRun({
178
214
  requestKey,
179
215
  source: {
@@ -212,53 +248,37 @@ export class PiWorkerRuntime implements WorkerToolProvider {
212
248
  leases: leaseManager,
213
249
  inspector,
214
250
  });
215
- const initial = await requiredRun(store, requestKey);
216
- this.#baselineLifecycle(initial);
217
- const notifier = new WorkerCompletionNotifier({ publish: this.#publishNotice });
218
- notifier.baseline(initial);
219
- const unsubscribeStore = store.subscribe((projection) => {
220
- this.#publish(projection);
221
- void this.#publishWithConversation(projection);
222
- void this.#publishLifecycleChanges(projection).catch(() => {});
223
- notifier.observe(projection);
224
- });
225
- const unsubscribe = (): void => {
226
- unsubscribeStore();
227
- notifier.dispose();
228
- };
229
251
  const reconciled = await supervisor.reconcileRun(requestKey);
230
- this.#cwd = ctx.cwd;
231
- this.#store = store;
232
- this.#supervisor = supervisor;
233
- this.#cleanup = cleanup;
234
- this.#doctor = doctor;
235
- this.#artifactDirectory = artifactDirectory;
236
- this.#requestKey = requestKey;
237
- this.#runtimes.set(ctx.cwd, {
252
+ const core: WorkerRuntimeCoreV1 = {
253
+ schema: 1,
254
+ key,
255
+ workspacePath,
256
+ workspaceId,
257
+ namespaceDirectory,
258
+ processIdentity: processIdentityFromOwner(acquired.owner),
259
+ supervisorGeneration: acquired.owner.generation,
260
+ leaseManager,
238
261
  store,
239
262
  supervisor,
240
263
  cleanup,
241
264
  doctor,
242
265
  artifactDirectory,
243
266
  requestKey,
244
- unsubscribe,
245
- });
267
+ };
268
+ registry.entries.set(key, core);
269
+ this.#attach(core, reconciled);
246
270
  this.#error = undefined;
247
- this.#publish(reconciled);
248
271
  } catch (error) {
272
+ this.#detach();
249
273
  this.#error = toError(error).message;
250
- this.#store = undefined;
251
- this.#supervisor = undefined;
252
- this.#cleanup = undefined;
253
- this.#doctor = undefined;
254
- this.#artifactDirectory = undefined;
255
- this.#requestKey = undefined;
256
274
  }
257
275
  }
258
276
 
259
277
  async shutdown(): Promise<void> {
260
278
  // Workers own a parent-liveness descriptor. Session changes do not imply cancellation,
261
- // so the runtime intentionally retains the supervisor instead of inventing a stop policy.
279
+ // so only this extension instance's advisory subscribers are detached. The process-global
280
+ // core and its supervisor lease remain intact for a replacement extension instance.
281
+ this.#detach();
262
282
  }
263
283
 
264
284
  async refreshGuidance(ctx: ExtensionContext): Promise<void> {
@@ -278,7 +298,7 @@ export class PiWorkerRuntime implements WorkerToolProvider {
278
298
  this.#guidance = `${DRIVER_DOCTRINE}\n\n${guidance.text}${
279
299
  guidance.notices.length > 0 ? `\n${guidance.notices.join("\n")}` : ""
280
300
  }`;
281
- if (this.#cwd === ctx.cwd) {
301
+ if (this.#cwd === canonicalWorkerWorkspace(ctx.cwd)) {
282
302
  this.#supervisor?.updateLaunchAuthority(await launchAuthority(ctx));
283
303
  }
284
304
  } catch (error) {
@@ -704,11 +724,67 @@ export class PiWorkerRuntime implements WorkerToolProvider {
704
724
  };
705
725
  }
706
726
 
727
+ #attach(core: WorkerRuntimeCoreV1, projection: RunProjectionV2): void {
728
+ this.#detach();
729
+ this.#cwd = core.workspacePath;
730
+ this.#store = core.store;
731
+ this.#supervisor = core.supervisor;
732
+ this.#cleanup = core.cleanup;
733
+ this.#doctor = core.doctor;
734
+ this.#artifactDirectory = core.artifactDirectory;
735
+ this.#requestKey = core.requestKey;
736
+ this.#lifecycleStates.clear();
737
+ this.#baselineLifecycle(projection);
738
+ const notifier = new WorkerCompletionNotifier({ publish: this.#publishNotice });
739
+ notifier.baseline(projection);
740
+ const attachment: WorkerRuntimeAttachment = {
741
+ core,
742
+ notifier,
743
+ active: true,
744
+ unsubscribe: () => {},
745
+ };
746
+ this.#attachment = attachment;
747
+ attachment.unsubscribe = core.store.subscribe((run) => {
748
+ if (!this.#isAttached(attachment)) return;
749
+ this.#publish(run);
750
+ void this.#publishWithConversation(run, attachment);
751
+ void this.#publishLifecycleChanges(run, attachment).catch(() => {});
752
+ notifier.observe(run);
753
+ });
754
+ this.#publish(projection);
755
+ void this.#publishWithConversation(projection, attachment);
756
+ }
757
+
758
+ #detach(): void {
759
+ const attachment = this.#attachment;
760
+ this.#attachment = undefined;
761
+ if (attachment) {
762
+ attachment.active = false;
763
+ attachment.unsubscribe();
764
+ attachment.notifier.dispose();
765
+ }
766
+ this.#lifecycleStates.clear();
767
+ this.#cwd = undefined;
768
+ this.#store = undefined;
769
+ this.#supervisor = undefined;
770
+ this.#cleanup = undefined;
771
+ this.#doctor = undefined;
772
+ this.#artifactDirectory = undefined;
773
+ this.#requestKey = undefined;
774
+ }
775
+
776
+ #isAttached(attachment: WorkerRuntimeAttachment): boolean {
777
+ return attachment.active && this.#attachment === attachment;
778
+ }
779
+
707
780
  #publish(run: RunProjectionV2): void {
708
781
  this.#publishFleet(projectFleetRecords(run));
709
782
  }
710
783
 
711
- async #publishWithConversation(run: RunProjectionV2): Promise<void> {
784
+ async #publishWithConversation(
785
+ run: RunProjectionV2,
786
+ attachment: WorkerRuntimeAttachment,
787
+ ): Promise<void> {
712
788
  const records = await Promise.all(Object.values(run.workers).map(async (worker): Promise<FleetAgentRecord> => {
713
789
  const attempt = worker.attempts.at(-1);
714
790
  const contract = effectiveWorkerContract(worker);
@@ -739,7 +815,9 @@ export class PiWorkerRuntime implements WorkerToolProvider {
739
815
  ],
740
816
  };
741
817
  }));
742
- if (this.#requestKey === run.requestKey) this.#publishFleet(records);
818
+ if (this.#isAttached(attachment) && attachment.core.requestKey === run.requestKey) {
819
+ this.#publishFleet(records);
820
+ }
743
821
  }
744
822
 
745
823
  #baselineLifecycle(run: RunProjectionV2): void {
@@ -748,16 +826,160 @@ export class PiWorkerRuntime implements WorkerToolProvider {
748
826
  }
749
827
  }
750
828
 
751
- async #publishLifecycleChanges(run: RunProjectionV2): Promise<void> {
829
+ async #publishLifecycleChanges(
830
+ run: RunProjectionV2,
831
+ attachment: WorkerRuntimeAttachment,
832
+ ): Promise<void> {
752
833
  for (const worker of Object.values(run.workers)) {
834
+ if (!this.#isAttached(attachment)) return;
753
835
  const key = `${run.requestKey}\0${worker.workerId}`;
754
836
  if (this.#lifecycleStates.get(key) === worker.state) continue;
755
837
  this.#lifecycleStates.set(key, worker.state);
838
+ if (!this.#isAttached(attachment)) return;
756
839
  await this.#publishLifecycle(projectWorkerLifecycle(worker.workerId, worker.state)).catch(() => {});
757
840
  }
758
841
  }
759
842
  }
760
843
 
844
+ function processWorkerRuntimeRegistry(): WorkerRuntimeRegistryV1 {
845
+ const existing = Reflect.get(globalThis, WORKER_RUNTIME_REGISTRY_SYMBOL) as unknown;
846
+ if (existing === undefined) {
847
+ const created: WorkerRuntimeRegistryV1 = { schema: 1, entries: new Map() };
848
+ Reflect.set(globalThis, WORKER_RUNTIME_REGISTRY_SYMBOL, created);
849
+ return created;
850
+ }
851
+ try {
852
+ if (!isRecord(existing) || existing["schema"] !== 1 || !(existing["entries"] instanceof Map)) {
853
+ throw new Error("registry envelope is invalid");
854
+ }
855
+ const registry = existing as unknown as WorkerRuntimeRegistryV1;
856
+ for (const [key, value] of registry.entries) {
857
+ if (typeof key !== "string" || key.length === 0) throw new Error("registry key is invalid");
858
+ parseWorkerRuntimeCore(value, key);
859
+ }
860
+ return registry;
861
+ } catch (error) {
862
+ throw reloadAdoptionRefused(`process-global registry is corrupt: ${toError(error).message}`);
863
+ }
864
+ }
865
+
866
+ function parseWorkerRuntimeCore(value: unknown, expectedKey: string): WorkerRuntimeCoreV1 {
867
+ if (!isRecord(value) || value["schema"] !== 1) {
868
+ throw reloadAdoptionRefused("process-global registry entry is corrupt");
869
+ }
870
+ const core = value as unknown as WorkerRuntimeCoreV1;
871
+ if (core.key !== expectedKey
872
+ || !nonEmptyString(core.workspacePath)
873
+ || !nonEmptyString(core.workspaceId)
874
+ || !nonEmptyString(core.namespaceDirectory)
875
+ || !nonEmptyString(core.supervisorGeneration)
876
+ || !nonEmptyString(core.artifactDirectory)
877
+ || !nonEmptyString(core.requestKey)
878
+ || !runtimeProcessIdentity(core.processIdentity)
879
+ || !hasMethods(core.leaseManager, ["inspectSupervisor"])
880
+ || !hasMethods(core.store, ["readRun", "subscribe"])
881
+ || !hasMethods(core.supervisor, ["updateLaunchAuthority", "steerWorker", "cancelWorker"])
882
+ || !hasMethods(core.cleanup, ["plan", "execute"])
883
+ || !hasMethods(core.doctor, ["inspect"])) {
884
+ throw reloadAdoptionRefused("process-global registry entry is corrupt");
885
+ }
886
+ return core;
887
+ }
888
+
889
+ async function validateRuntimeAdoption(
890
+ core: WorkerRuntimeCoreV1,
891
+ expected: {
892
+ readonly workspacePath: string;
893
+ readonly workspaceId: string;
894
+ readonly namespaceDirectory: string;
895
+ readonly inspector: ProcessIdentityInspector;
896
+ readonly leaseManager: WorkflowLeaseManager;
897
+ },
898
+ ): Promise<void> {
899
+ if (core.workspacePath !== expected.workspacePath
900
+ || core.workspaceId !== expected.workspaceId
901
+ || core.namespaceDirectory !== expected.namespaceDirectory
902
+ || core.key !== workerRuntimeRegistryKey(expected.namespaceDirectory, expected.workspaceId)) {
903
+ throw reloadAdoptionRefused("workspace or namespace identity changed");
904
+ }
905
+ const current = await expected.inspector.current();
906
+ if (!sameProcessIdentity(core.processIdentity, current)) {
907
+ throw reloadAdoptionRefused("process-start identity changed");
908
+ }
909
+ const lease = await expected.leaseManager.inspectSupervisor();
910
+ if (lease.kind !== "owned") {
911
+ throw reloadAdoptionRefused(
912
+ lease.kind === "corrupt" ? `supervisor lease is corrupt: ${lease.diagnostic}` : "supervisor lease is missing",
913
+ );
914
+ }
915
+ if (lease.owner.kind !== "supervisor"
916
+ || lease.owner.workspaceId !== expected.workspaceId
917
+ || lease.owner.generation !== core.supervisorGeneration) {
918
+ throw reloadAdoptionRefused("supervisor generation or workspace does not match the registry");
919
+ }
920
+ const leaseIdentity = processIdentityFromOwner(lease.owner);
921
+ if (!sameProcessIdentity(leaseIdentity, current)
922
+ || !sameProcessIdentity(leaseIdentity, core.processIdentity)) {
923
+ throw reloadAdoptionRefused("supervisor lease process identity does not match the registry");
924
+ }
925
+ }
926
+
927
+ function canonicalWorkerWorkspace(workspacePath: string): string {
928
+ if (!isAbsolute(workspacePath)) throw new TypeError("Worker workspace must be absolute");
929
+ return normalize(resolve(workspacePath));
930
+ }
931
+
932
+ function workerRuntimeRegistryKey(namespaceDirectory: string, workspaceId: string): string {
933
+ return `${normalize(resolve(namespaceDirectory))}\0${workspaceId}`;
934
+ }
935
+
936
+ function processIdentityFromOwner(owner: WorkflowLeaseOwnerV1): ProcessIdentity {
937
+ return {
938
+ pid: owner.pid,
939
+ processStartIdentity: owner.processStartIdentity,
940
+ bootIdentity: owner.bootIdentity,
941
+ executable: owner.executable,
942
+ hostname: owner.hostname,
943
+ };
944
+ }
945
+
946
+ function sameProcessIdentity(left: ProcessIdentity, right: ProcessIdentity): boolean {
947
+ return left.pid === right.pid
948
+ && left.processStartIdentity === right.processStartIdentity
949
+ && left.bootIdentity === right.bootIdentity
950
+ && normalize(left.executable) === normalize(right.executable)
951
+ && left.hostname === right.hostname;
952
+ }
953
+
954
+ function runtimeProcessIdentity(value: unknown): value is ProcessIdentity {
955
+ return isRecord(value)
956
+ && Number.isSafeInteger(value["pid"])
957
+ && (value["pid"] as number) > 0
958
+ && nonEmptyString(value["processStartIdentity"])
959
+ && (value["bootIdentity"] === null || nonEmptyString(value["bootIdentity"]))
960
+ && nonEmptyString(value["executable"])
961
+ && isAbsolute(value["executable"] as string)
962
+ && nonEmptyString(value["hostname"]);
963
+ }
964
+
965
+ function hasMethods(value: unknown, methods: readonly string[]): boolean {
966
+ return isRecord(value) && methods.every((method) => typeof value[method] === "function");
967
+ }
968
+
969
+ function nonEmptyString(value: unknown): value is string {
970
+ return typeof value === "string" && value.length > 0;
971
+ }
972
+
973
+ function isRecord(value: unknown): value is Record<string, unknown> {
974
+ return typeof value === "object" && value !== null && !Array.isArray(value);
975
+ }
976
+
977
+ function reloadAdoptionRefused(reason: string): Error {
978
+ return new Error(
979
+ `worker runtime reload adoption refused: ${reason}; fully restart Pi without deleting the supervisor lease`,
980
+ );
981
+ }
982
+
761
983
  export function projectFleetRecords(run: RunProjectionV2): readonly FleetAgentRecord[] {
762
984
  return Object.values(run.workers).map((worker): FleetAgentRecord => {
763
985
  const attempt = worker.attempts.at(-1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-amq",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A daemonless AMQ bridge and governed worker runtime for pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,6 +39,7 @@
39
39
  "typecheck": "tsc --project tsconfig.json",
40
40
  "test:compile": "tsc --project tsconfig.test.json",
41
41
  "test": "npm run test:compile && node --test .test-dist/test/*.test.js",
42
+ "test:package": "npm run test:compile && node --test .test-dist/test/package/*.test.js",
42
43
  "prepack": "npm run build"
43
44
  },
44
45
  "peerDependencies": {