pi-radar 0.6.0 → 0.9.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 (29) hide show
  1. package/README.md +15 -1
  2. package/dist/extension/baseline.js +395 -125
  3. package/dist/extension/radar-children.js +240 -0
  4. package/dist/extension/radar-client.js +42 -8
  5. package/dist/extension/radar-execution.js +11 -10
  6. package/dist/extension/radar-launch.js +23 -24
  7. package/dist/extension/radar-publication.js +10 -9
  8. package/dist/extension/radar-runtime.js +1 -1
  9. package/dist/index.js +1772 -1615
  10. package/extension/baseline.ts +2 -0
  11. package/extension/generated-contracts.ts +12 -3
  12. package/extension/radar-children.ts +176 -0
  13. package/extension/radar-client.ts +60 -6
  14. package/extension/radar-execution.ts +5 -5
  15. package/extension/radar-launch.ts +42 -63
  16. package/extension/radar-publication.ts +12 -13
  17. package/extension/radar-runtime.ts +4 -1
  18. package/index.ts +1 -0
  19. package/package.json +8 -3
  20. package/schema/public/v1/observation-get-response.deserialize.schema.json +9 -1
  21. package/schema/public/v1/observation-get-response.serialize.schema.json +9 -1
  22. package/schema/public/v1/observation-snapshot.deserialize.schema.json +9 -1
  23. package/schema/public/v1/observation-snapshot.serialize.schema.json +9 -1
  24. package/schema/public/v1/presence-answer.deserialize.schema.json +1 -0
  25. package/schema/public/v1/presence-answer.serialize.schema.json +1 -0
  26. package/schema/public/v1/presence-report.deserialize.schema.json +0 -7
  27. package/schema/public/v1/presence-report.serialize.schema.json +0 -8
  28. package/schema/public/v1/spawn-list-page.deserialize.schema.json +7 -0
  29. package/schema/public/v1/spawn-list-page.serialize.schema.json +7 -0
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { createRadarClient } from "./radar-client.ts";
4
+ import { createRadarChildren } from "./radar-children.ts";
4
5
  import { createRadarHandoffPort } from "./radar-communication.ts";
5
6
  import {
6
7
  createRadarExecutionAdapter,
@@ -33,6 +34,7 @@ export default function registerPiRadar(pi: PiRadarHost): void {
33
34
  const runtime = lazyRadarRuntime(async () => ({
34
35
  publication: execution.publication,
35
36
  communication: createRadarHandoffPort(client, execution.publication),
37
+ children: createRadarChildren(client, execution.publication),
36
38
  execution,
37
39
  }));
38
40
  provideRadarRuntime(pi.events, runtime);
@@ -11,7 +11,6 @@ export interface PresenceReportDeserialize {
11
11
  run?: string | null;
12
12
  session_id: string;
13
13
  source: string;
14
- spawn_token?: string | null;
15
14
  subject_id?: string | null;
16
15
  }
17
16
  /**
@@ -54,7 +53,7 @@ export interface PresenceReportProcessIdentity {
54
53
  start_ticks: number;
55
54
  [k: string]: unknown;
56
55
  }
57
- export type PresenceAnswerStanding = "pending" | "granted" | "duplicate" | "released";
56
+ export type PresenceAnswerStanding = "reserved" | "pending" | "granted" | "duplicate" | "released";
58
57
 
59
58
  /**
60
59
  * The small public receipt; session/location context stays inside Radar.
@@ -284,7 +283,7 @@ export type Freshness = "fresh" | "stale";
284
283
  * refused rather than silently dropped.
285
284
  */
286
285
  export type Report = Snapshot | AssignmentSnapshot | FactsSnapshot;
287
- export type Standing = "pending" | "granted" | "duplicate" | "released";
286
+ export type Standing = "reserved" | "pending" | "granted" | "duplicate" | "released";
288
287
  /**
289
288
  * How a registration relates to the live panes.
290
289
  */
@@ -675,6 +674,11 @@ export interface RegistryAgent {
675
674
  facts: PublicChannelFacts | null;
676
675
  process: ProcessEvidence;
677
676
  registration: PublicRegistration;
677
+ /**
678
+ * Whether this process runs inside a Radar-established execution scope. A
679
+ * process started outside the managed launch path has none and is observed.
680
+ */
681
+ scoped?: boolean | null;
678
682
  }
679
683
  /**
680
684
  * A channel read for a reader that is not its writer: the same identity,
@@ -1472,6 +1476,11 @@ export interface SpawnListPageSerializeBoundChild {
1472
1476
  */
1473
1477
  registration_id?: string | null;
1474
1478
  source: string;
1479
+ /**
1480
+ * The durable agent the bound child attaches as. Absent for an edge without
1481
+ * a tracked launch and in records written before it was recorded.
1482
+ */
1483
+ subject_id?: string | null;
1475
1484
  }
1476
1485
  /**
1477
1486
  * Where an agent's incarnation lives on one backend, in that backend's own
@@ -0,0 +1,176 @@
1
+ import type { RadarClient, RadarChannelSnapshot, RadarResult } from "./radar-client.ts";
2
+ import type { SpawnListPageSerializePublicSpawnTopology as ListedEdge } from "./generated-contracts.ts";
3
+ import { inspectRadarChild, type RadarInspection } from "./radar-inspection.ts";
4
+ import { observeRadarChild, type RadarChildObservation } from "./radar-launch.ts";
5
+ import type { RadarPublication } from "./radar-publication.ts";
6
+
7
+ const PAGE = 100;
8
+ const MAX_PAGES = 20;
9
+
10
+ /** One child of the caller, named by the agent that survives its restarts. */
11
+ export type RadarChild = {
12
+ subject_id: string;
13
+ /** The launch that last bound this child. */
14
+ request_id: string;
15
+ pane_id?: string;
16
+ state: "unbound" | "bound" | "unresolved";
17
+ exited: boolean;
18
+ };
19
+
20
+ /** The assignment a child is held to: published again when its process is replaced. */
21
+ export type RadarChildAssignment = {
22
+ update(snapshot: RadarChannelSnapshot): void;
23
+ retire(): void;
24
+ };
25
+
26
+ export type RadarChildren = {
27
+ list(): Promise<RadarResult<RadarChild[]>>;
28
+ assignment(subject: string, owner: string): RadarChildAssignment;
29
+ retire(subject: string): void;
30
+ observe(
31
+ subject: string,
32
+ session_id: string | undefined,
33
+ ): Promise<RadarChildObservation>;
34
+ inspect(subject: string, session_id?: string): Promise<RadarInspection>;
35
+ };
36
+
37
+ type Resolved = { child: RadarChild; registration_id: string };
38
+
39
+ /**
40
+ * The caller's children, addressed by subject. A subject gains an edge per launch,
41
+ * so the newest bound edge is the current process; its registration is read per
42
+ * call and never remembered, because a remembered registration is the stale key
43
+ * this surface exists to hide. An edge bound without a subject is not addressable.
44
+ */
45
+ export function createRadarChildren(
46
+ client: Pick<RadarClient, "spawnList" | "spawnGet" | "agentGet" | "output" | "processInfo">,
47
+ publication: Pick<RadarPublication, "assignment" | "retireAssignment">,
48
+ ): RadarChildren {
49
+ const held = new Map<
50
+ string,
51
+ { writer: RadarChildAssignment; published?: string; publish?: (target: Resolved) => void }
52
+ >();
53
+
54
+ async function resolveAll(): Promise<RadarResult<Map<string, Resolved & { at: number }>>> {
55
+ const newest = new Map<string, Resolved & { at: number }>();
56
+ let after: string | undefined;
57
+ for (let page = 0; page < MAX_PAGES; page++) {
58
+ const read = await client.spawnList({ ...(after !== undefined ? { after } : {}), limit: PAGE });
59
+ if (read.ok === false) return read;
60
+ for (const edge of read.value.spawns as ListedEdge[]) {
61
+ const bound = edge.bound;
62
+ if (!bound?.subject_id || !bound.registration_id || edge.launched !== "completed") continue;
63
+ const at = Date.parse(bound.bound_at);
64
+ if (Number.isNaN(at)) continue;
65
+ const current = newest.get(bound.subject_id);
66
+ if (current !== undefined && current.at >= at) continue;
67
+ newest.set(bound.subject_id, {
68
+ at,
69
+ registration_id: bound.registration_id,
70
+ child: {
71
+ subject_id: bound.subject_id,
72
+ request_id: edge.request_id,
73
+ ...(edge.location?.pane ? { pane_id: edge.location.pane } : {}),
74
+ state: edge.state,
75
+ exited: edge.exit_observation != null && edge.exit_observation.state !== "unknown",
76
+ },
77
+ });
78
+ }
79
+ if (!read.value.next) return { ok: true, value: newest };
80
+ after = read.value.next;
81
+ }
82
+ return { ok: false, code: "protocol", message: "the caller's launches exceed the readable pages" };
83
+ }
84
+
85
+ async function resolve(subject: string): Promise<Resolved | undefined> {
86
+ const all = await resolveAll();
87
+ return all.ok ? all.value.get(subject) : undefined;
88
+ }
89
+
90
+ async function resolveAndFollow(subject: string): Promise<Resolved | undefined> {
91
+ const all = await resolveAll();
92
+ if (!all.ok) return undefined;
93
+ follow(all.value);
94
+ return all.value.get(subject);
95
+ }
96
+
97
+ /**
98
+ * A child replaced under its subject has a new process with no assignment. Each
99
+ * read the caller already makes finds the new registration and moves the held
100
+ * assignment onto it, so no caller has to notice a replacement.
101
+ */
102
+ function follow(all: Map<string, Resolved>): void {
103
+ for (const [subject, entry] of held) {
104
+ const target = all.get(subject);
105
+ if (target !== undefined && entry.published !== target.registration_id)
106
+ entry.publish?.(target);
107
+ }
108
+ }
109
+
110
+ return {
111
+ async list() {
112
+ const all = await resolveAll();
113
+ if (!all.ok) return all;
114
+ follow(all.value);
115
+ return { ok: true, value: [...all.value.values()].map((entry) => entry.child) };
116
+ },
117
+ assignment(subject, owner) {
118
+ const existing = held.get(subject);
119
+ if (existing !== undefined) return existing.writer;
120
+ let latest: RadarChannelSnapshot | undefined;
121
+ const entry: {
122
+ writer: RadarChildAssignment;
123
+ published?: string;
124
+ publish?: (target: Resolved) => void;
125
+ } = { writer: undefined as unknown as RadarChildAssignment };
126
+ const send = (target: Resolved): void => {
127
+ if (latest === undefined) return;
128
+ if (entry.published !== undefined && entry.published !== target.registration_id)
129
+ publication.retireAssignment(entry.published);
130
+ entry.published = target.registration_id;
131
+ publication.assignment(target.registration_id, owner).update(latest);
132
+ };
133
+ entry.publish = send;
134
+ entry.writer = {
135
+ update(snapshot) {
136
+ latest = snapshot;
137
+ void resolve(subject).then((target) => {
138
+ if (target !== undefined) send(target);
139
+ });
140
+ },
141
+ retire() {
142
+ latest = undefined;
143
+ if (entry.published !== undefined) publication.retireAssignment(entry.published);
144
+ entry.published = undefined;
145
+ },
146
+ };
147
+ held.set(subject, entry);
148
+ return entry.writer;
149
+ },
150
+ retire(subject) {
151
+ held.get(subject)?.writer.retire();
152
+ held.delete(subject);
153
+ },
154
+ async observe(subject, session_id) {
155
+ const target = await resolveAndFollow(subject);
156
+ if (target === undefined)
157
+ return { kind: "unknown", diagnostic: "the daemon records no bound launch for this child" };
158
+ return observeRadarChild(client as RadarClient, {
159
+ request_id: target.child.request_id,
160
+ pane_id: target.child.pane_id,
161
+ registration_id: target.registration_id,
162
+ session_id,
163
+ });
164
+ },
165
+ async inspect(subject, session_id) {
166
+ const target = await resolveAndFollow(subject);
167
+ if (target?.child.pane_id === undefined)
168
+ throw new Error("Inspection target is not a bound child with a pane");
169
+ return inspectRadarChild(client as RadarClient, {
170
+ agentId: target.registration_id,
171
+ paneId: target.child.pane_id,
172
+ ...(session_id !== undefined ? { session: session_id } : {}),
173
+ });
174
+ },
175
+ };
176
+ }
@@ -34,15 +34,18 @@ import type {
34
34
  export const RADAR_PROTOCOL_VERSION = 2;
35
35
  /**
36
36
  * Revision 5 names attempts by claim_id and requires exact-claim controls.
37
+ * Revision 7 starts every managed launch through `radar run --launch` and lets a
38
+ * parent continue or restart its own child by subject.
37
39
  * Earlier daemons expose different identity fields; require a coordinated update.
38
40
  */
39
- export const RADAR_MINIMUM_REVISION = 5;
41
+ export const RADAR_MINIMUM_REVISION = 7;
40
42
  /** Public daemon contracts this client may preflight. */
41
43
  export const RADAR_REGISTRY_CONTRACT = "registry.v1";
42
44
  export const RADAR_REGISTRY_FACTS_CONTRACT = "registry.facts.v1";
43
45
  export const RADAR_SPAWN_CONTRACT = "spawn.v1";
44
46
  export const RADAR_SPAWN_LIST_CONTRACT = "spawn.list.v1";
45
47
  export const RADAR_SPAWN_CLOSE_CONTRACT = "spawn.close.v1";
48
+ export const RADAR_LIFECYCLE_RESTART_CONTRACT = "lifecycle.restart.v1";
46
49
  export const RADAR_SPAWN_LAUNCH_CONTEXT_CONTRACT = "spawn.launch_context.v1";
47
50
  export const RADAR_EVENTS_CONTRACT = "events.v1";
48
51
  export const RADAR_COMMUNICATION_CONTRACT = "communication.handoff.v1";
@@ -304,7 +307,7 @@ export type RadarSpawnEdge = {
304
307
  created: RadarEffectOutcome;
305
308
  launched: RadarEffectOutcome;
306
309
  location?: RadarLocation;
307
- bound?: { source: string; incarnation: string; registration_id?: string };
310
+ bound?: { source: string; incarnation: string; registration_id?: string; subject_id?: string };
308
311
  exit_observation?: RadarSpawnExitObservation;
309
312
  };
310
313
 
@@ -517,7 +520,7 @@ function decodePresenceAnswer(value: unknown): RadarResult<RadarPresenceAnswer>
517
520
  if (
518
521
  !isUuid(registrationId) ||
519
522
  !isUuid(claimId) ||
520
- !["pending", "granted", "duplicate", "released"].includes(String(standing)) ||
523
+ !["reserved", "pending", "granted", "duplicate", "released"].includes(String(standing)) ||
521
524
  (subjectId !== undefined && !isUuid(subjectId)) ||
522
525
  (standing === "granted" && !isUuid(subjectId))
523
526
  )
@@ -745,7 +748,7 @@ function unresolvedOperation(requestId: string, detail: string): RadarResult<nev
745
748
  };
746
749
  }
747
750
 
748
- export const RADAR_SPAWN_TOKEN_ENV = "PI_RADAR_SPAWN_TOKEN";
751
+ export const RADAR_REGISTRATION_ENV = "PI_RADAR_REGISTRATION_ID";
749
752
  const RADAR_MANAGED_IDENTITY_ENV = new Set([
750
753
  "PI_RADAR_MANAGED_SUBJECT_ID",
751
754
  "PI_RADAR_MANAGED_LAUNCH_ID",
@@ -825,6 +828,7 @@ function spawnEdge(value: unknown): RadarResult<RadarSpawnEdge> {
825
828
  const source = stringAt(bound, "source");
826
829
  const incarnation = stringAt(bound, "incarnation");
827
830
  const agentId = stringAt(bound, "registration_id");
831
+ const subjectId = stringAt(bound, "subject_id");
828
832
  const exit = exitAt(recordAt(value, "exit_observation"));
829
833
  return {
830
834
  ok: true,
@@ -840,6 +844,7 @@ function spawnEdge(value: unknown): RadarResult<RadarSpawnEdge> {
840
844
  source,
841
845
  incarnation,
842
846
  ...(isUuid(agentId) ? { registration_id: agentId } : {}),
847
+ ...(isUuid(subjectId) ? { subject_id: subjectId } : {}),
843
848
  },
844
849
  }
845
850
  : {}),
@@ -959,9 +964,12 @@ function spawnRequestShape(request: {
959
964
  cwd?: string;
960
965
  env?: Record<string, string>;
961
966
  managed?: RadarManagedLaunchMetadata;
967
+ continue_subject_id?: string;
962
968
  }): string | undefined {
963
969
  if (!isUuid(request.request_id))
964
970
  return "spawn requires a canonical request UUID";
971
+ if (request.continue_subject_id !== undefined && !isUuid(request.continue_subject_id))
972
+ return "continue_subject_id must be a canonical UUID";
965
973
  if (!isUuid(request.parent))
966
974
  return "spawn requires the parent's canonical agent UUID";
967
975
  if (!isUuid(request.parent_session_id))
@@ -975,8 +983,8 @@ function spawnRequestShape(request: {
975
983
  const env = Object.entries(request.env ?? {});
976
984
  if (env.length > 256) return "spawn env names more than 256 entries";
977
985
  for (const [name, value] of env) {
978
- if (name === RADAR_SPAWN_TOKEN_ENV)
979
- return `spawn env cannot set the reserved ${RADAR_SPAWN_TOKEN_ENV}`;
986
+ if (name === RADAR_REGISTRATION_ENV)
987
+ return `spawn env cannot set the reserved ${RADAR_REGISTRATION_ENV}`;
980
988
  if (RADAR_MANAGED_IDENTITY_ENV.has(name))
981
989
  return `spawn env cannot set Radar-issued identity variable ${name}`;
982
990
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || /\p{Cc}/u.test(value))
@@ -1100,7 +1108,23 @@ export type RadarClient = {
1100
1108
  cwd?: string;
1101
1109
  env?: Record<string, string>;
1102
1110
  managed?: RadarManagedLaunchMetadata;
1111
+ /** Start this released child agent again, in the session the daemon recorded for it. */
1112
+ continue_subject_id?: string;
1103
1113
  }): Promise<RadarResult<RadarSpawnEdge>>;
1114
+ /**
1115
+ * Restart this caller's own live child under a new program. The daemon finds the
1116
+ * child's current process from the subject and replaces it in its own pane and
1117
+ * session; a program with a bare argument that may be a message is refused.
1118
+ */
1119
+ restart(request: {
1120
+ request_id: string;
1121
+ parent: string;
1122
+ subject_id: string;
1123
+ executable: string;
1124
+ argv: string[];
1125
+ cwd?: string;
1126
+ env?: Record<string, string>;
1127
+ }): Promise<RadarResult<{ request_id: string }>>;
1104
1128
  /** List launches owned by this client's registered process. */
1105
1129
  spawnList(request: Pick<SpawnListParamsDeserialize, "after" | "limit">): Promise<RadarResult<SpawnListPageSerialize>>;
1106
1130
  spawnGet(request_id: string): Promise<RadarResult<RadarSpawnTopology>>;
@@ -1671,6 +1695,9 @@ export function createRadarClient(options: RadarClientOptions = {}): RadarClient
1671
1695
  ...(request.managed !== undefined
1672
1696
  ? { managed: request.managed }
1673
1697
  : {}),
1698
+ ...(request.continue_subject_id !== undefined
1699
+ ? { continue_subject_id: request.continue_subject_id }
1700
+ : {}),
1674
1701
  },
1675
1702
  requester,
1676
1703
  },
@@ -1706,6 +1733,33 @@ export function createRadarClient(options: RadarClientOptions = {}): RadarClient
1706
1733
  },
1707
1734
  );
1708
1735
  },
1736
+ async restart(request) {
1737
+ if (!isUuid(request.request_id) || !isUuid(request.parent) || !isUuid(request.subject_id))
1738
+ return {
1739
+ ok: false,
1740
+ code: "bad_params",
1741
+ message: "restart requires canonical request, parent and subject UUIDs",
1742
+ };
1743
+ return withContract(RADAR_LIFECYCLE_RESTART_CONTRACT, async () => {
1744
+ const answer = await call(
1745
+ "lifecycle.restart",
1746
+ {
1747
+ request: {
1748
+ subject_id: request.subject_id,
1749
+ parent: request.parent,
1750
+ executable: request.executable,
1751
+ argv: request.argv,
1752
+ ...(request.cwd !== undefined ? { cwd: request.cwd } : {}),
1753
+ ...(request.env !== undefined ? { env: request.env } : {}),
1754
+ },
1755
+ requester,
1756
+ },
1757
+ request.request_id,
1758
+ );
1759
+ if (answer.ok === false) return answer;
1760
+ return focusOutcome(recordAt(answer.value, "request"), request.request_id);
1761
+ });
1762
+ },
1709
1763
  async spawnList(request) {
1710
1764
  const registrationId = processRegistrationId;
1711
1765
  if (registrationId === undefined)
@@ -51,10 +51,10 @@ export type RadarExecutionOptions = {
51
51
  */
52
52
  location?: () => Promise<RadarLocationResolution>;
53
53
  /**
54
- * A daemon-spawned child's one-use launch token, captured before this process
55
- * could observe anything else. It travels with the registration request only.
54
+ * Registration allocated by Radar before this process launched. Normally
55
+ * captured from the launch environment by the process-local publication.
56
56
  */
57
- spawnToken?: string;
57
+ launchRegistrationId?: string;
58
58
  /** Only a real fixed managed assignment is part of immutable registration. */
59
59
  managedBinding?: { key: string; run: string; owner: string; label: string };
60
60
  /**
@@ -207,8 +207,8 @@ export function createRadarExecutionAdapter(
207
207
  ? { location: options.location }
208
208
  : {}),
209
209
  ...(managed !== undefined ? { binding: managed } : {}),
210
- ...(options.spawnToken !== undefined
211
- ? { spawnToken: options.spawnToken }
210
+ ...(options.launchRegistrationId !== undefined
211
+ ? { launchRegistrationId: options.launchRegistrationId }
212
212
  : {}),
213
213
  });
214
214
  } catch {
@@ -8,6 +8,7 @@ import {
8
8
  type RadarSpawnEdge,
9
9
  } from "./radar-client.ts";
10
10
  import { radarPaneHandle } from "./radar-location.ts";
11
+ import type { RadarPublication } from "./radar-publication.ts";
11
12
 
12
13
  /** The startup acknowledgement a launch waits for, named by its identity. */
13
14
  export type StartupExpectation = {
@@ -64,21 +65,22 @@ export type LaunchMailbox<State extends LaunchState> = {
64
65
  * different leaves the launch unresolved rather than placed.
65
66
  */
66
67
 
67
- /** The exact managed parent this launch may use, and nothing inferred about it. */
68
- export type RadarLaunchParent = {
69
- registration_id: string;
70
- /** Stable subject from this process's current Radar registration. */
71
- subject_id: string;
72
- location?: RadarLocation;
73
- };
74
-
75
68
  /** Everything one fresh managed worker is launched with. */
76
69
  export type RadarWorkerLaunch = {
77
70
  /** The caller's durable key, persisted before dispatch and never replaced. */
78
71
  request_id: string;
79
72
  /** The nonce the child must repeat in its startup acknowledgment. */
80
73
  generation: string;
81
- parent: RadarLaunchParent;
74
+ /**
75
+ * Start this released child agent again rather than a new one. The daemon takes
76
+ * the session from its own record, so no session id or path is sent.
77
+ */
78
+ continues?: string;
79
+ /**
80
+ * The pane this launch splits. The parent is the calling process itself, read
81
+ * from its own publication, and must be registered on exactly this pane.
82
+ */
83
+ pane_id: string;
82
84
  parent_session_id: string;
83
85
  executable: string;
84
86
  argv: string[];
@@ -94,35 +96,13 @@ export type RadarWorkerLaunch = {
94
96
  workspaceId: string;
95
97
  agentLabel: string;
96
98
  };
97
- /**
98
- * The child's own published registration, once it has one. It is the second
99
- * witness that the edge's bound id is this launch's child, and it is read
100
- * through the owner's publication store rather than assumed from the pane.
101
- */
102
- observedChildAgent?: () => string | undefined;
103
- /**
104
- * Persist the daemon's own binding of this launch's edge, the subject it bound
105
- * and the pane it recorded, before readiness is waited for. This is the only
106
- * moment those facts are authoritative: the launch can end without ever
107
- * reaching an acknowledgment, and a reload in that window still has to read
108
- * where the daemon put this child. Throwing leaves the launch unresolved,
109
- * because a binding that was not persisted is one no later reader can hold the
110
- * child by.
111
- */
112
- onBound?: (bound: { registration_id: string; pane_id: string }) => void;
113
99
  };
114
100
 
115
101
  export type RadarWorkerLaunched<State = unknown> = {
116
102
  request_id: string;
117
- registration_id: string;
103
+ /** The child's durable identity: the key that survives its restart and resume. */
104
+ subject_id: string;
118
105
  pane_id: string;
119
- /**
120
- * The process the daemon bound this launch's edge to, taken from that same
121
- * answer: the source and incarnation the child registered under. They are what
122
- * an assignment to this child is addressed with, so the first task of a fresh
123
- * launch is addressed exactly as a retained continuation is.
124
- */
125
- bound_process: { source: string; incarnation: string };
126
106
  state: State;
127
107
  };
128
108
 
@@ -315,6 +295,8 @@ export async function relaunchRefusalForChild(
315
295
 
316
296
  export type RadarLaunchOptions<State extends LaunchState = LaunchState> = {
317
297
  client: RadarClient;
298
+ /** This process's own publication, which names the parent of the launch. */
299
+ publication: Pick<RadarPublication, "subject">;
318
300
  readers: LaunchMailbox<State>;
319
301
  launch: RadarWorkerLaunch;
320
302
  timeoutMs: number;
@@ -392,29 +374,35 @@ export async function launchRadarWorker<State extends LaunchState>(
392
374
  const pollMs = options.pollMs ?? 250;
393
375
  const deadline = Date.now() + options.timeoutMs;
394
376
  const remaining = (): number => Math.max(0, deadline - Date.now());
395
- const pane = launch.parent.location?.pane;
396
- if (!isUuid(launch.parent.registration_id))
377
+ const parent = await options.publication.subject();
378
+ if (parent === undefined || !isUuid(parent.registration_id))
397
379
  return refusedLaunch(
398
- "the launch has no exact parent registration to spawn under",
380
+ "this process has no Radar registration, so a worker cannot be spawned under it",
399
381
  );
400
- if (!isUuid(launch.parent.subject_id))
382
+ if (parent.subject_id === undefined || !isUuid(parent.subject_id))
401
383
  return refusedLaunch(
402
- "the launch has no managed Radar subject for its parent",
384
+ "this process has no managed Radar subject, so a worker cannot be spawned under it",
403
385
  );
404
386
  // A spawn is created under the pane its parent registered, and this port
405
387
  // registers no location it was not given: without one there is nothing to
406
388
  // place the child beside, and a name or session lookup is not a substitute.
389
+ const pane = parent.location?.pane;
407
390
  if (pane === undefined || pane === "")
408
391
  return refusedLaunch(
409
- "this process's Radar registration names no pane, so it cannot be a spawn parent",
392
+ "this process's Radar registration names no pane, so a worker cannot be placed beside it",
393
+ );
394
+ if (pane !== launch.pane_id)
395
+ return refusedLaunch(
396
+ `this process's Radar registration places it in pane ${pane}, not in the pane this launch would split`,
410
397
  );
411
398
 
412
399
  const spawned = await client.spawn({
413
400
  request_id: launch.request_id,
414
- parent: launch.parent.registration_id,
401
+ parent: parent.registration_id,
415
402
  parent_session_id: launch.parent_session_id,
416
403
  executable: launch.executable,
417
- managed: { parent_subject_id: launch.parent.subject_id },
404
+ managed: { parent_subject_id: parent.subject_id },
405
+ ...(launch.continues !== undefined ? { continue_subject_id: launch.continues } : {}),
418
406
  argv: launch.argv,
419
407
  ...(launch.cwd !== "" ? { cwd: launch.cwd } : {}),
420
408
  ...(Object.keys(launch.env).length > 0 ? { env: launch.env } : {}),
@@ -438,20 +426,6 @@ export async function launchRadarWorker<State extends LaunchState>(
438
426
  });
439
427
  if (bound.kind !== "bound") return bound;
440
428
  const agentId = bound.registration_id;
441
- // The binding is recorded before the readiness wait, not after it: the child
442
- // may never acknowledge, and the subject and pane the daemon named are still
443
- // what a reload has to prove this child by.
444
- if (launch.onBound !== undefined) {
445
- try {
446
- launch.onBound({ registration_id: agentId, pane_id: bound.pane_id });
447
- } catch (error) {
448
- return unresolvedLaunch(
449
- launch.request_id,
450
- `the daemon's binding of the child ${agentId} could not be recorded (${String(error)})`,
451
- );
452
- }
453
- }
454
-
455
429
  try {
456
430
  const state = await options.readers.waitForState(
457
431
  launch.mailbox,
@@ -466,21 +440,15 @@ export async function launchRadarWorker<State extends LaunchState>(
466
440
  // the pane the daemon created is what refuses a child that inherited
467
441
  // another process's coordinates.
468
442
  paneId: radarPaneHandle(bound.pane_id),
469
- }) &&
470
- (launch.observedChildAgent === undefined ||
471
- launch.observedChildAgent() === agentId),
443
+ }),
472
444
  { timeoutMs: remaining(), ...(signal !== undefined ? { signal } : {}) },
473
445
  );
474
446
  return {
475
447
  kind: "ready",
476
448
  value: {
477
449
  request_id: launch.request_id,
478
- registration_id: agentId,
450
+ subject_id: bound.subject_id,
479
451
  pane_id: bound.pane_id,
480
- bound_process: {
481
- source: bound.source,
482
- incarnation: bound.incarnation,
483
- },
484
452
  state,
485
453
  },
486
454
  };
@@ -498,6 +466,7 @@ export async function launchRadarWorker<State extends LaunchState>(
498
466
  type BoundChild =
499
467
  | {
500
468
  kind: "bound";
469
+ subject_id: string;
501
470
  registration_id: string;
502
471
  pane_id: string;
503
472
  source: string;
@@ -577,8 +546,18 @@ async function waitForChild(
577
546
  requestId,
578
547
  `the daemon bound the child ${agentId} without recording the process it registered as`,
579
548
  );
549
+ // The edge names the agent as well as its process. Without it the child
550
+ // has no durable key to be returned by, and a registration is never
551
+ // offered in its place.
552
+ const subjectId = edge.bound?.subject_id;
553
+ if (subjectId === undefined || !isUuid(subjectId))
554
+ return unresolvedLaunch(
555
+ requestId,
556
+ `the daemon bound the child ${agentId} without recording its subject`,
557
+ );
580
558
  return {
581
559
  kind: "bound",
560
+ subject_id: subjectId,
582
561
  registration_id: agentId,
583
562
  pane_id: pane,
584
563
  source,