@astrosheep/keiyaku 4.3.0 → 4.5.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.
@@ -1,11 +1,36 @@
1
1
  import { AkumaHandle } from "./akuma-handle.js";
2
2
  import type { AkumaCallContext, AkumaCallInput, AkumaConfiguration, AkumaList, AkumaListInput } from "./akuma.js";
3
3
  import { CALL_WITH_CONTEXT } from "./akuma-product-symbols.js";
4
- import type { ResultRoute } from "./result-route.js";
5
4
  import type { WorldRoot } from "../world.js";
6
- type AkumaCallLaunchInput = AkumaCallInput & Readonly<{
7
- resultRoute?: ResultRoute;
5
+ import type { BodyLaunch } from "./body.js";
6
+ import type { AllocatedAkuma } from "./identity.js";
7
+ type AkumaCallRecipe = Omit<NonNullable<BodyLaunch["seed"]>, "id" | "archetype" | "cwd" | "origin">;
8
+ type BornExecution = Readonly<{
9
+ cwd: string;
10
+ source: "input" | "caller" | "process" | "world";
8
11
  }>;
12
+ export type BornAkumaCall = Readonly<{
13
+ kind: "born";
14
+ allocated: AllocatedAkuma;
15
+ seed: AkumaCallRecipe & Readonly<{
16
+ id: AllocatedAkuma["id"];
17
+ archetype: string;
18
+ cwd: string;
19
+ origin: {
20
+ kind: "direct";
21
+ };
22
+ }>;
23
+ initialBody: string;
24
+ execution: BornExecution;
25
+ }>;
26
+ export type RequestedAkumaCall = Readonly<{
27
+ kind: "requested";
28
+ id: AllocatedAkuma["id"];
29
+ cwd: string;
30
+ execution: BornExecution;
31
+ }>;
32
+ export type AkumaBornCall = BornAkumaCall | RequestedAkumaCall;
33
+ type AkumaCallLaunchInput = AkumaCallInput;
9
34
  export declare class Akuma {
10
35
  private readonly path;
11
36
  private readonly configuration;
@@ -16,8 +41,12 @@ export declare class Akuma {
16
41
  }>): AkumaHandle;
17
42
  listArchetypes(): Promise<readonly string[]>;
18
43
  call(input: AkumaCallLaunchInput): Promise<AkumaHandle>;
44
+ beginCall(input: AkumaCallLaunchInput, context: AkumaCallContext): Promise<AkumaBornCall>;
45
+ finishCall(born: AkumaBornCall): Promise<AkumaHandle>;
19
46
  [CALL_WITH_CONTEXT](input: AkumaCallLaunchInput, context: AkumaCallContext): Promise<AkumaHandle>;
20
47
  list(input?: AkumaListInput): Promise<AkumaList>;
21
48
  }
22
49
  export declare function callAkumaWithContext(akuma: Akuma, input: AkumaCallLaunchInput, context: AkumaCallContext): Promise<AkumaHandle>;
50
+ export declare function beginAkumaCall(akuma: Akuma, input: AkumaCallLaunchInput, context: AkumaCallContext): Promise<AkumaBornCall>;
51
+ export declare function finishAkumaCall(akuma: Akuma, born: AkumaBornCall): Promise<AkumaHandle>;
23
52
  export {};
@@ -6,7 +6,7 @@ import { CALL_WITH_CONTEXT } from "./akuma-product-symbols.js";
6
6
  import { fleetListRow, readAkumaBirthCwd } from "./akuma-observe.js";
7
7
  import { akuIdFromDirectoryName, akumaPaths, akumaRunRoot, archetypeName, parseAkuId } from "./identity.js";
8
8
  import { loadArchetype, listArchetypes as readArchetypes } from "./archetype.js";
9
- import { publishAkuma } from "./publication.js";
9
+ import { birthAkuma, launchAkuma } from "./publication.js";
10
10
  import { spawnAkumaBody } from "./body.js";
11
11
  import { injectedBodyRequests, requestBodyCall } from "./requests.js";
12
12
  import { decodeAllowedActions, unionAllowedActions } from "./allowed.js";
@@ -51,7 +51,7 @@ export class Akuma {
51
51
  async call(input) {
52
52
  return await this[CALL_WITH_CONTEXT](input, { initiatorCwd: process.cwd() });
53
53
  }
54
- async [CALL_WITH_CONTEXT](input, context) {
54
+ async beginCall(input, context) {
55
55
  const readonly = callReadonly(input.readonly);
56
56
  const name = archetypeName(input.archetype);
57
57
  const home = this.configuration.home === undefined ? {} : { home: this.configuration.home };
@@ -84,32 +84,50 @@ export class Akuma {
84
84
  recipe: requestRecipe,
85
85
  });
86
86
  const bornCwd = await readAkumaBirthCwd(this.path, child);
87
- return new AkumaHandle(child, this.path, { cwd: bornCwd, source: cwd === undefined ? "caller" : "input" });
87
+ return {
88
+ kind: "requested",
89
+ id: child,
90
+ cwd: bornCwd,
91
+ execution: { cwd: bornCwd, source: cwd === undefined ? "caller" : "input" },
92
+ };
88
93
  }
89
94
  const initiatorCwd = context.initiatorCwd;
90
95
  const selectedCwd = input.cwd ?? initiatorCwd ?? this.path;
91
96
  const cwd = input.cwd !== undefined && context?.cwdCanonical === true ? input.cwd : await canonicalBirthCwd(selectedCwd);
92
- const published = await publishAkuma({
93
- worldPath: this.path,
94
- archetype: archetype.name,
95
- launch: async (allocated) => await spawnAkumaBody({
96
- paths: allocated.paths,
97
- seed: {
98
- id: allocated.id,
99
- archetype: allocated.archetype,
100
- ...requestRecipe,
101
- cwd,
102
- origin: { kind: "direct" },
103
- },
104
- initialBody: input.body,
105
- ...(input.resultRoute === undefined ? {} : { resultRoute: input.resultRoute }),
106
- }),
97
+ const allocated = await birthAkuma({ worldPath: this.path, archetype: archetype.name });
98
+ return {
99
+ kind: "born",
100
+ allocated,
101
+ seed: {
102
+ id: allocated.id,
103
+ archetype: allocated.archetype,
104
+ ...requestRecipe,
105
+ cwd,
106
+ origin: { kind: "direct" },
107
+ },
108
+ initialBody: input.body,
109
+ execution: {
110
+ cwd,
111
+ source: input.cwd !== undefined ? "input" : initiatorCwd === undefined ? "world" : "process",
112
+ },
113
+ };
114
+ }
115
+ async finishCall(born) {
116
+ if (born.kind === "requested") {
117
+ return new AkumaHandle(born.id, this.path, { cwd: born.cwd, source: born.execution.source });
118
+ }
119
+ const published = await launchAkuma({
120
+ allocated: born.allocated,
121
+ launch: async (allocated) => await spawnAkumaBody({ paths: allocated.paths, seed: born.seed, initialBody: born.initialBody }),
107
122
  });
108
123
  return new AkumaHandle(published.id, this.path, {
109
- cwd,
110
- source: input.cwd !== undefined ? "input" : initiatorCwd === undefined ? "world" : "process",
124
+ cwd: born.execution.cwd,
125
+ source: born.execution.source,
111
126
  });
112
127
  }
128
+ async [CALL_WITH_CONTEXT](input, context) {
129
+ return await this.finishCall(await this.beginCall(input, context));
130
+ }
113
131
  async list(input = {}) {
114
132
  if (typeof input !== "object" || input === null || Array.isArray(input))
115
133
  throw new TypeError("Akuma list input must be an object");
@@ -153,3 +171,9 @@ export class Akuma {
153
171
  export async function callAkumaWithContext(akuma, input, context) {
154
172
  return await akuma[CALL_WITH_CONTEXT](input, context);
155
173
  }
174
+ export async function beginAkumaCall(akuma, input, context) {
175
+ return await akuma.beginCall(input, context);
176
+ }
177
+ export async function finishAkumaCall(akuma, born) {
178
+ return await akuma.finishCall(born);
179
+ }
@@ -62,7 +62,6 @@ export type AkumaCallContext = Readonly<{
62
62
  cwdCanonical?: true;
63
63
  }>;
64
64
  export type { TellResult, TellWake } from "./body.js";
65
- export type { ResultRoute } from "./result-route.js";
66
65
  export type InterruptReceipt = Readonly<{
67
66
  kind: "unavailable";
68
67
  evidence: "hung" | "untidy" | "unavailable";
@@ -1,7 +1,6 @@
1
1
  import { type SessionFact, type Soul } from "./heart/index.js";
2
- import type { AkumaPaths } from "./identity.js";
2
+ import { type AkumaPaths } from "./identity.js";
3
3
  import type { ProviderAdapter } from "./provider.js";
4
- import type { ResultRoute } from "./result-route.js";
5
4
  import { type UpstreamExecutionPort, type RequestChildLaunch } from "./request-serve.js";
6
5
  import { type OwnedProcess, type RunLogReference } from "../runtime/proc/run.js";
7
6
  export declare const LEASH_HELD_EXIT = 75;
@@ -12,7 +11,6 @@ export type BodyLaunch = Readonly<{
12
11
  birthSession?: Omit<SessionFact, "sequence">;
13
12
  initialBody?: string;
14
13
  refuseIfHeld?: boolean;
15
- resultRoute?: ResultRoute;
16
14
  }>;
17
15
  export type TellWake = Readonly<{
18
16
  kind: "told";
@@ -45,10 +43,6 @@ type BodyRuntime = Readonly<{
45
43
  now(): string;
46
44
  spawnChild?(launch: RequestChildLaunch): Promise<OwnedProcess>;
47
45
  spawnBody?(launch: BodyLaunch): Promise<OwnedProcess>;
48
- express?(route: ResultRoute, options: Readonly<{
49
- as: string;
50
- body: string;
51
- }>): Promise<unknown>;
52
46
  upstream?: UpstreamExecutionPort;
53
47
  }>;
54
48
  export declare function bodyProcessInput(launch: BodyLaunch, bodyModuleUrl?: string): Promise<{
@@ -1,9 +1,11 @@
1
1
  import { appendFile } from "node:fs/promises";
2
+ import { join } from "node:path";
2
3
  import { fileURLToPath } from "node:url";
3
4
  import { abortableDelay } from "./abort.js";
4
5
  import { BodySupervisor } from "./body-supervisor.js";
5
6
  import { driveTurn, turnRecipe } from "./turn-drive.js";
6
7
  import { HeldAkumaLeash, breakBody, endTurn, finishBodyIfIdle, heartExists, isHeartAbsent, readHeart, readNonterminalRequests, watchHeart, } from "./heart/index.js";
8
+ import { worldRootForAkumaPaths } from "./identity.js";
7
9
  import { resolveProviderExecution } from "./providers/index.js";
8
10
  import { clearBodyRequestTransport, settleBodyRequests, } from "./request-serve.js";
9
11
  import { spawnDetachedProcess, } from "../runtime/proc/run.js";
@@ -76,23 +78,37 @@ async function persistTurn(paths, turnSequence, result, completedAt) {
76
78
  await endTurn(paths, { turnSequence, outcome: { kind: "failed", diagnostic }, completedAt });
77
79
  return { outcome: "failed", diagnostic };
78
80
  }
81
+ const OUTCOME_PREVIEW_LIMIT = 1_000;
82
+ function outcomePreview(identity, outcome) {
83
+ const text = outcome.outcome === "answered" ? outcome.answer : outcome.diagnostic;
84
+ if (text.length <= OUTCOME_PREVIEW_LIMIT)
85
+ return text;
86
+ return `${text.slice(0, OUTCOME_PREVIEW_LIMIT)}\n\nkeiyaku history ${identity} --last`;
87
+ }
79
88
  function boundedDiagnostic(error) {
80
89
  const text = diagnostic(error);
81
90
  return text.length <= 500 ? text : `${text.slice(0, 500)}...`;
82
91
  }
83
- async function expressInitialOutcome(launch, soul, outcome, runtime) {
84
- if (launch.resultRoute === undefined)
85
- return;
92
+ async function expressInitialOutcome(launch, outcome) {
86
93
  try {
87
- const express = runtime.express ?? (await import("@astrosheep/square")).express;
88
- await express(launch.resultRoute, {
89
- as: "keiyaku",
90
- body: JSON.stringify({ akuma: soul.id, ...outcome }),
91
- });
94
+ const { Square } = await import("@astrosheep/square");
95
+ const identity = launch.seed?.id ?? (await readHeart(launch.paths)).soul?.id;
96
+ if (identity === undefined)
97
+ return;
98
+ const square = await Square.at({ path: join(worldRootForAkumaPaths(launch.paths), ".square", "PUBLIC.square") });
99
+ try {
100
+ const joined = await square.implicitJoin(identity);
101
+ if (joined.state === "done" || joined.participant === undefined)
102
+ return;
103
+ await joined.participant.express(outcomePreview(identity, outcome));
104
+ }
105
+ finally {
106
+ await square.close();
107
+ }
92
108
  }
93
109
  catch (error) {
94
110
  try {
95
- await appendFile(launch.paths.log, `result-route express failed: ${boundedDiagnostic(error)}\n`);
111
+ await appendFile(launch.paths.log, `square outcome express failed: ${boundedDiagnostic(error)}\n`);
96
112
  }
97
113
  catch {
98
114
  /* express loss never changes Heart truth */
@@ -201,7 +217,7 @@ async function runBodyTurns(input) {
201
217
  }
202
218
  const outcome = await persistTurn(launch.paths, result.turnSequence, result, runtime.now());
203
219
  if (initial !== undefined)
204
- await expressInitialOutcome(launch, soul, outcome, runtime);
220
+ await expressInitialOutcome(launch, outcome);
205
221
  if (outcome.outcome === "failed") {
206
222
  await breakBody(launch.paths, { sequence: bodySequence, end: "broke-off", at: runtime.now() });
207
223
  return;
@@ -2,7 +2,6 @@ import { type AkuId } from "./identity.js";
2
2
  import type { WorldRoot } from "../world.js";
3
3
  export { Akuma, AkumaHandle, AkumaNotBornError, defaultWaitComplete } from "./akuma.js";
4
4
  export type { AkumaCallInput } from "./akuma.js";
5
- export type { ResultRoute } from "./result-route.js";
6
5
  export { ALLOWED_ACTIONS } from "./allowed.js";
7
6
  export type { AllowedAction, AllowedActions } from "./allowed.js";
8
7
  export type { WorldRoot } from "../world.js";
@@ -1,11 +1,17 @@
1
1
  import { type AllocatedAkuma } from "./identity.js";
2
2
  import type { OwnedProcess } from "../runtime/proc/run.js";
3
3
  export declare const BIRTH_TIMEOUT_MS = 30000;
4
- export declare function publishAkuma(input: Readonly<{
4
+ export type BirthInput = Readonly<{
5
5
  worldPath: string;
6
6
  archetype: string;
7
+ signal?: AbortSignal;
8
+ }>;
9
+ export type LaunchInput = Readonly<{
10
+ allocated: AllocatedAkuma;
7
11
  awaitAsleep?: boolean;
8
- reserve?(allocated: AllocatedAkuma): Promise<void>;
9
12
  launch(allocated: AllocatedAkuma): Promise<OwnedProcess | void>;
10
13
  signal?: AbortSignal;
11
- }>): Promise<AllocatedAkuma>;
14
+ }>;
15
+ export declare function birthAkuma(input: BirthInput): Promise<AllocatedAkuma>;
16
+ export declare function launchAkuma(input: LaunchInput): Promise<AllocatedAkuma>;
17
+ export declare function publishAkuma(input: BirthInput & Omit<LaunchInput, "allocated">): Promise<AllocatedAkuma>;
@@ -156,15 +156,25 @@ async function sealLocalFailure(allocated, error) {
156
156
  /* the original local publication failure remains authoritative */
157
157
  }
158
158
  }
159
- export async function publishAkuma(input) {
159
+ export async function birthAkuma(input) {
160
160
  input.signal?.throwIfAborted();
161
161
  const allocated = await allocateAkumaDirectory({ worldRoot: input.worldPath, archetype: input.archetype });
162
162
  try {
163
163
  await initializeHeart(allocated.paths);
164
164
  input.signal?.throwIfAborted();
165
- await input.reserve?.(allocated);
165
+ return allocated;
166
+ }
167
+ catch (error) {
168
+ await sealLocalFailure(allocated, error);
169
+ throw error;
170
+ }
171
+ }
172
+ export async function launchAkuma(input) {
173
+ const { allocated } = input;
174
+ let owned;
175
+ try {
166
176
  input.signal?.throwIfAborted();
167
- const owned = await input.launch(allocated);
177
+ owned = await input.launch(allocated);
168
178
  try {
169
179
  input.signal?.throwIfAborted();
170
180
  const soul = await awaitBirth(allocated.paths, owned ?? undefined, input.signal);
@@ -183,3 +193,7 @@ export async function publishAkuma(input) {
183
193
  throw error;
184
194
  }
185
195
  }
196
+ export async function publishAkuma(input) {
197
+ const allocated = await birthAkuma(input);
198
+ return await launchAkuma({ ...input, allocated });
199
+ }
@@ -111,14 +111,10 @@ async function serveCall(input) {
111
111
  worldPath: world,
112
112
  archetype: request.archetype,
113
113
  signal: input.signal,
114
- reserve: async (allocated) => {
115
- if (!input.admissionOpen())
116
- throw new Error("body closed request admission");
117
- fact = await reserveRequest(input.paths, request.id, allocated.id);
118
- },
119
114
  launch: async (allocated) => {
120
115
  if (!input.admissionOpen())
121
116
  throw new Error("body closed request admission");
117
+ fact = await reserveRequest(input.paths, request.id, allocated.id);
122
118
  return await input.spawn({
123
119
  paths: allocated.paths,
124
120
  seed: {
@@ -1,4 +1,4 @@
1
- import { type AkuId, type ActivityHistory, type ResultRoute } from "../../akuma/index.js";
1
+ import { type AkuId, type ActivityHistory } from "../../akuma/index.js";
2
2
  import { Keiyaku, type AkumaKillResult, type AkumaHistoryResult, type AkumaObservation, type AkumaTellResult, type AkumaWaitResult, type CallResult, type ForkResult, type Keiyaku as KeiyakuContract, type Repo } from "../../index.js";
3
3
  import type { Settings } from "../../settings.js";
4
4
  import type { WorldRoot } from "../../world.js";
@@ -72,7 +72,7 @@ type InvokeInput = Readonly<{
72
72
  settings?: Settings;
73
73
  contract?: KeiyakuContract;
74
74
  repo?: Repo;
75
- resultRoute?: ResultRoute;
75
+ environment: NodeJS.ProcessEnv;
76
76
  readStdin(): Promise<string>;
77
77
  }>;
78
78
  export declare function invokeAkuma(command: InvokedAkumaCommand, input: InvokeInput): Promise<AkumaInvocationResult>;
@@ -1,4 +1,6 @@
1
1
  import { Keiyaku, } from "../../index.js";
2
+ import { beginCall, finishCall } from "../../library/akuma-creation.js";
3
+ import { recognizeAndListen } from "../square-edge.js";
2
4
  function inputAlias(selector) {
3
5
  return selector.startsWith("@") ? selector : undefined;
4
6
  }
@@ -100,7 +102,7 @@ export async function invokeAkuma(command, input) {
100
102
  switch (command.command) {
101
103
  case "call": {
102
104
  const body = await promptBody(command, input);
103
- const result = await Keiyaku.call({
105
+ const born = await beginCall({
104
106
  path: input.path,
105
107
  archetype: command.archetype,
106
108
  body,
@@ -113,8 +115,25 @@ export async function invokeAkuma(command, input) {
113
115
  ...(input.contract === undefined ? {} : { contract: input.contract }),
114
116
  ...(command.alias === undefined ? {} : { alias: command.alias }),
115
117
  ...(command.allowed === undefined ? {} : { allowed: command.allowed }),
116
- ...(input.resultRoute === undefined ? {} : { resultRoute: input.resultRoute }),
117
118
  });
119
+ const listener = born.born.kind === "born"
120
+ ? await recognizeAndListen(input.path, input.environment, born.born.allocated)
121
+ : undefined;
122
+ let result;
123
+ try {
124
+ result = await finishCall(born);
125
+ }
126
+ catch (error) {
127
+ if (listener?.committed === true) {
128
+ try {
129
+ await listener.rollback();
130
+ }
131
+ catch {
132
+ /* preserve launch failure */
133
+ }
134
+ }
135
+ throw error;
136
+ }
118
137
  return { kind: "akuma", action: "call", result, world: input.path };
119
138
  }
120
139
  case "wait":
@@ -1,4 +1,3 @@
1
- import { captureRoute } from "@astrosheep/square";
2
1
  import { resolveActor } from "./actor.js";
3
2
  import { isParsedAkumaCommand } from "./commands/akuma.js";
4
3
  import { invokeContractMutation } from "./commands/contract-invoke.js";
@@ -106,14 +105,13 @@ async function invokeAkumaFromEdge(parsed, input) {
106
105
  const contract = parsed.command === "call" && parsed.contract !== undefined
107
106
  ? (await import("./selectors.js")).contractFromInput(repo, parsed.contract).contract
108
107
  : undefined;
109
- const resultRoute = parsed.command === "call" ? captureRoute({ cwd: input.invocationCwd, env: edge.environment }) : null;
110
108
  return await invokeAkuma(parsed, {
111
109
  path,
112
110
  ...(statedCwd === undefined ? {} : { statedCwd }),
113
111
  ...(home === undefined ? {} : { home }),
114
112
  ...(configuration === undefined ? {} : { settings: configuration }),
115
113
  ...(contract === undefined ? (repo === undefined ? {} : { repo }) : { contract }),
116
- ...(resultRoute === null ? {} : { resultRoute }),
114
+ environment: edge.environment,
117
115
  readStdin: edge.readStdin,
118
116
  });
119
117
  }
@@ -0,0 +1,6 @@
1
+ import type { AllocatedAkuma } from "../akuma/identity.js";
2
+ import type { WorldRoot } from "../world.js";
3
+ export declare function recognizeAndListen(worldRoot: WorldRoot, environment: NodeJS.ProcessEnv, allocated: AllocatedAkuma): Promise<{
4
+ committed: boolean;
5
+ rollback(): Promise<void>;
6
+ } | void>;
@@ -0,0 +1,48 @@
1
+ import { Square } from "@astrosheep/square";
2
+ import { join } from "node:path";
3
+ export async function recognizeAndListen(worldRoot, environment, allocated) {
4
+ const path = join(worldRoot, ".square", "PUBLIC.square");
5
+ let square;
6
+ try {
7
+ square = await Square.at({ path });
8
+ }
9
+ catch (error) {
10
+ if (error.code === "ENOENT" || error.code === "unavailable")
11
+ return;
12
+ throw error;
13
+ }
14
+ try {
15
+ const recognize = square.recognize;
16
+ if (recognize === undefined)
17
+ return;
18
+ const participant = await recognize.call(square, environment);
19
+ if (participant === null)
20
+ return;
21
+ const listener = participant;
22
+ const change = await listener.listen(allocated.id);
23
+ if (change.activity === null)
24
+ return;
25
+ return {
26
+ committed: true,
27
+ rollback: async () => {
28
+ const rollbackSquare = await Square.at({ path });
29
+ try {
30
+ const current = rollbackSquare
31
+ .recognize;
32
+ if (current === undefined)
33
+ return;
34
+ const rollbackParticipant = await current.call(rollbackSquare, environment);
35
+ if (rollbackParticipant === null)
36
+ return;
37
+ await rollbackParticipant.ignore(allocated.id);
38
+ }
39
+ finally {
40
+ await rollbackSquare.close();
41
+ }
42
+ },
43
+ };
44
+ }
45
+ finally {
46
+ await square.close();
47
+ }
48
+ }
@@ -1,5 +1,6 @@
1
1
  import { type AliasBinding } from "../alias/index.js";
2
- import { type AkumaStatus, type ForkReceipt, type ReadonlyRestraint, type ResultRoute } from "../akuma/akuma.js";
2
+ import { type AkumaStatus, type ForkReceipt, type ReadonlyRestraint } from "../akuma/akuma.js";
3
+ import { type AkumaBornCall } from "../akuma/akuma-product.js";
3
4
  import type { AkuId } from "../akuma/identity.js";
4
5
  import { type Dispatch, type DispatchFailure } from "../dispatch/index.js";
5
6
  import { type AkumaAlias } from "../identity/selector.js";
@@ -48,11 +49,9 @@ export type CallInput = Readonly<{
48
49
  contract?: Keiyaku;
49
50
  alias?: AkumaAlias;
50
51
  allowed?: readonly AllowedAction[];
51
- resultRoute?: ResultRoute;
52
52
  }>;
53
53
  export type CallObservation = Readonly<{
54
54
  kind: "detached";
55
- resultRoute: "captured" | "not-captured";
56
55
  }> | Readonly<{
57
56
  kind: "observed";
58
57
  status: AkumaStatus;
@@ -72,6 +71,16 @@ export type CallResult = Readonly<{
72
71
  alias: AliasStage;
73
72
  observation: CallObservation;
74
73
  }>;
74
+ type CallExecution = CallResult["execution"];
75
+ export type BornCall = Readonly<{
76
+ path: WorldRoot;
77
+ born: AkumaBornCall;
78
+ execution: CallExecution;
79
+ mode: "wait" | "detach";
80
+ timeoutMs: number;
81
+ dispatch: DispatchStage;
82
+ alias: AliasStage;
83
+ }>;
75
84
  export type ForkInput = Readonly<{
76
85
  path: WorldRoot;
77
86
  akuma: string;
@@ -89,5 +98,7 @@ export type ForkResult = Readonly<{
89
98
  }>> & Readonly<{
90
99
  parent: AkuId;
91
100
  }>);
101
+ export declare function beginCall(input: CallInput): Promise<BornCall>;
102
+ export declare function finishCall(born: BornCall): Promise<CallResult>;
92
103
  export declare function callKeiyaku(input: CallInput): Promise<CallResult>;
93
104
  export declare function forkKeiyaku(input: ForkInput): Promise<ForkResult>;
@@ -1,6 +1,7 @@
1
1
  import { realpath, stat } from "node:fs/promises";
2
2
  import { moveAlias } from "../alias/index.js";
3
- import { Akuma, akumaCallExecution, callAkumaWithContext, } from "../akuma/akuma.js";
3
+ import { Akuma } from "../akuma/akuma.js";
4
+ import { beginAkumaCall, finishAkumaCall } from "../akuma/akuma-product.js";
4
5
  import { AuthorityCorruptionError } from "../core/facts/errors.js";
5
6
  import { publishDispatch, readDispatch } from "../dispatch/index.js";
6
7
  import { parseAkumaAlias } from "../identity/selector.js";
@@ -77,9 +78,9 @@ function callTimeout(value, mode) {
77
78
  }
78
79
  return value;
79
80
  }
80
- async function observeCall(handle, mode, timeoutMs, resultRoute) {
81
+ async function observeCall(handle, mode, timeoutMs) {
81
82
  if (mode === "detach") {
82
- return { kind: "detached", resultRoute: resultRoute === undefined ? "not-captured" : "captured" };
83
+ return { kind: "detached" };
83
84
  }
84
85
  try {
85
86
  return { kind: "observed", status: await handle.wait(undefined, { timeoutMs }) };
@@ -165,7 +166,7 @@ async function resolveCallExecution(input) {
165
166
  }
166
167
  return undefined;
167
168
  }
168
- export async function callKeiyaku(input) {
169
+ export async function beginCall(input) {
169
170
  const values = requireInput(input, "Keiyaku.call input");
170
171
  onlyKeys(values, [
171
172
  "path",
@@ -180,7 +181,6 @@ export async function callKeiyaku(input) {
180
181
  "contract",
181
182
  "alias",
182
183
  "allowed",
183
- "resultRoute",
184
184
  ], "Keiyaku.call input");
185
185
  const path = nonblank(values.path, "path");
186
186
  const archetype = nonblank(values.archetype, "archetype");
@@ -193,7 +193,6 @@ export async function callKeiyaku(input) {
193
193
  const settings = settingsOption(values.settings);
194
194
  const alias = values.alias === undefined ? undefined : parseAkumaAlias(nonblank(values.alias, "alias"));
195
195
  const seat = values.contract === undefined ? undefined : seatForKeiyaku(values.contract);
196
- const resultRoute = values.resultRoute;
197
196
  const execution = await resolveCallExecution({
198
197
  path,
199
198
  ...(cwd === undefined ? {} : { cwd }),
@@ -206,23 +205,20 @@ export async function callKeiyaku(input) {
206
205
  ...(readonlyRequested === undefined ? {} : { readonly: readonlyRequested }),
207
206
  ...(values.allowed === undefined ? {} : { allowed: values.allowed }),
208
207
  ...(execution === undefined ? {} : { cwd: execution.cwd }),
209
- ...(resultRoute === undefined ? {} : { resultRoute }),
210
208
  };
211
- const handle = execution === undefined ? await world.call(call) : await callAkumaWithContext(world, call, { cwdCanonical: true });
212
- const completedExecution = execution ?? akumaCallExecution(handle);
213
- if (completedExecution === undefined)
214
- throw new Error("Akuma call is missing its birth execution");
215
- const readonly = (await handle.status()).readonly;
209
+ const born = await beginAkumaCall(world, call, execution === undefined ? {} : { cwdCanonical: true });
210
+ const akuma = born.kind === "requested" ? born.id : born.allocated.id;
211
+ const completedExecution = execution ?? born.execution;
216
212
  const dispatch = seat === undefined
217
213
  ? { kind: "none" }
218
- : await dispatchStage({ repository: seat.scope, akuId: handle.id, contractId: seat.id });
214
+ : await dispatchStage({ repository: seat.scope, akuId: akuma, contractId: seat.id });
219
215
  let aliasStage = { kind: "none" };
220
216
  if (alias !== undefined) {
221
217
  if (dispatch.kind === "failed")
222
218
  aliasStage = { kind: "skipped", reason: "dispatch-failed" };
223
219
  else {
224
220
  try {
225
- const moved = await moveAlias({ world: path, alias, akuId: handle.id });
221
+ const moved = await moveAlias({ world: path, alias, akuId: akuma });
226
222
  aliasStage = { kind: "aliased", alias: moved.alias, previous: moved.previous };
227
223
  }
228
224
  catch (error) {
@@ -230,17 +226,34 @@ export async function callKeiyaku(input) {
230
226
  }
231
227
  }
232
228
  }
233
- const observation = await observeCall(handle, mode, timeoutMs, resultRoute);
234
229
  return {
235
- kind: "called",
236
- akuma: handle.id,
237
- ...(readonly === undefined ? {} : { readonly }),
230
+ path,
231
+ born,
238
232
  execution: completedExecution,
233
+ mode,
234
+ timeoutMs,
239
235
  dispatch,
240
236
  alias: aliasStage,
237
+ };
238
+ }
239
+ export async function finishCall(born) {
240
+ const world = akumaWorld(born.path);
241
+ const handle = await finishAkumaCall(world, born.born);
242
+ const readonly = (await handle.status()).readonly;
243
+ const observation = await observeCall(handle, born.mode, born.timeoutMs);
244
+ return {
245
+ kind: "called",
246
+ akuma: handle.id,
247
+ ...(readonly === undefined ? {} : { readonly }),
248
+ execution: born.execution,
249
+ dispatch: born.dispatch,
250
+ alias: born.alias,
241
251
  observation,
242
252
  };
243
253
  }
254
+ export async function callKeiyaku(input) {
255
+ return await finishCall(await beginCall(input));
256
+ }
244
257
  export async function forkKeiyaku(input) {
245
258
  const values = requireInput(input, "Keiyaku.fork input");
246
259
  onlyKeys(values, ["path", "akuma", "at", "repo"], "Keiyaku.fork input");
@@ -5,12 +5,17 @@ export function detachedExitStatus(code, signal) {
5
5
  export async function retainDetachedExitEvidence(log, path, from, code, signal) {
6
6
  const status = detachedExitStatus(code, signal);
7
7
  const marker = Buffer.from(`[child ${status}]\n`);
8
+ const before = await log.stat();
9
+ if (before.size < from)
10
+ throw new Error("run log shrank before exit evidence was retained");
8
11
  let written = 0;
12
+ let position = before.size;
9
13
  while (written < marker.byteLength) {
10
- const write = await log.write(marker, written, marker.byteLength - written);
14
+ const write = await log.write(marker, written, marker.byteLength - written, position);
11
15
  if (write.bytesWritten === 0)
12
16
  throw new Error("run log exit marker write made no progress");
13
17
  written += write.bytesWritten;
18
+ position += write.bytesWritten;
14
19
  }
15
20
  const evidence = await log.stat();
16
21
  if (evidence.size < from)
@@ -40,13 +40,6 @@ export async function spawnDetachedProcess(input) {
40
40
  return spawnWindowsRetainedProcess(input);
41
41
  const log = await open(input.log, "a");
42
42
  let launched = false;
43
- let logClosed = false;
44
- const closeLog = async () => {
45
- if (logClosed)
46
- return;
47
- logClosed = true;
48
- await log.close();
49
- };
50
43
  try {
51
44
  const from = (await log.stat()).size;
52
45
  const child = spawn(input.argv[0], input.argv.slice(1), spawnOptionsFor(input, ["ignore", log.fd, log.fd]));
@@ -62,14 +55,16 @@ export async function spawnDetachedProcess(input) {
62
55
  const status = detachedExitStatus(code, signal);
63
56
  let failure;
64
57
  let result;
58
+ let exitLog;
65
59
  try {
66
- result = await retainDetachedExitEvidence(log, input.log, from, code, signal);
60
+ exitLog = await open(input.log, "r+");
61
+ result = await retainDetachedExitEvidence(exitLog, input.log, from, code, signal);
67
62
  }
68
63
  catch (error) {
69
64
  failure = error;
70
65
  }
71
66
  try {
72
- await closeLog();
67
+ await exitLog?.close();
73
68
  }
74
69
  catch (error) {
75
70
  failure ??= error;
@@ -89,6 +84,7 @@ export async function spawnDetachedProcess(input) {
89
84
  if (child.pid === undefined)
90
85
  throw new Error("detached process spawned without a pid");
91
86
  const pid = child.pid;
87
+ await log.close();
92
88
  launched = true;
93
89
  return {
94
90
  pid,
@@ -99,7 +95,7 @@ export async function spawnDetachedProcess(input) {
99
95
  }
100
96
  finally {
101
97
  if (!launched)
102
- await closeLog();
98
+ await log.close();
103
99
  }
104
100
  }
105
101
  function terminalOutcome(code, stdout, stderr, consuming) {
@@ -1,3 +1,4 @@
1
1
  import { type ChildProcess } from "node:child_process";
2
2
  export declare function terminateWindowsTree(pid: number): Promise<void>;
3
+ export declare function settleWindowsTermination(exit: Promise<void>): Promise<void>;
3
4
  export declare function terminateOwnedProcess(child: ChildProcess, force?: boolean): Promise<void>;
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { setTimeout as delay } from "node:timers/promises";
3
3
  import { promisify } from "node:util";
4
4
  const TERMINATION_GRACE_MS = 250;
5
+ const WINDOWS_TERMINATION_SETTLE_MS = 250;
5
6
  const WINDOWS_TERMINATION_TIMEOUT_MS = 1_000;
6
7
  const execFileAsync = promisify(execFile);
7
8
  function ignoreMissingProcess(error) {
@@ -23,6 +24,20 @@ export async function terminateWindowsTree(pid) {
23
24
  throw error;
24
25
  }
25
26
  }
27
+ export async function settleWindowsTermination(exit) {
28
+ const settled = await Promise.race([
29
+ exit.then(() => true),
30
+ delay(WINDOWS_TERMINATION_TIMEOUT_MS, false, { ref: false }).then(() => false),
31
+ ]);
32
+ if (!settled)
33
+ throw new Error("Windows process did not exit after taskkill");
34
+ await delay(WINDOWS_TERMINATION_SETTLE_MS);
35
+ }
36
+ function closeWindowsStreams(child) {
37
+ child.stdin?.destroy();
38
+ child.stdout?.destroy();
39
+ child.stderr?.destroy();
40
+ }
26
41
  export async function terminateOwnedProcess(child, force = false) {
27
42
  const pid = child.pid;
28
43
  if (pid === undefined || child.exitCode !== null || child.signalCode !== null)
@@ -39,8 +54,13 @@ export async function terminateOwnedProcess(child, force = false) {
39
54
  });
40
55
  });
41
56
  if (process.platform === "win32") {
42
- await terminateWindowsTree(pid);
43
- await exit;
57
+ try {
58
+ await terminateWindowsTree(pid);
59
+ await settleWindowsTermination(exit);
60
+ }
61
+ finally {
62
+ closeWindowsStreams(child);
63
+ }
44
64
  return;
45
65
  }
46
66
  if (force) {
@@ -3,8 +3,8 @@ import { createInterface } from "node:readline";
3
3
  import { launchFailure, spawnWindowsLauncher } from "./launch.js";
4
4
  import { detachedExitStatus, retainDetachedExitEvidence } from "./process-exit.js";
5
5
  import { createProcessLifecycle } from "./lifecycle.js";
6
- import { terminateWindowsTree } from "./termination.js";
7
- function createWindowsContext(input, log, from, closeLog) {
6
+ import { settleWindowsTermination, terminateWindowsTree } from "./termination.js";
7
+ function createWindowsContext(input, from, closeLog) {
8
8
  const child = spawnWindowsLauncher(input);
9
9
  const reader = createInterface({ input: child.stdout });
10
10
  let startedResolve;
@@ -23,7 +23,6 @@ function createWindowsContext(input, log, from, closeLog) {
23
23
  child.stderr?.setEncoding("utf8");
24
24
  const context = {
25
25
  input,
26
- log,
27
26
  from,
28
27
  closeLog,
29
28
  child,
@@ -31,6 +30,7 @@ function createWindowsContext(input, log, from, closeLog) {
31
30
  stderr: "",
32
31
  started: false,
33
32
  targetPid: undefined,
33
+ released: false,
34
34
  finished: false,
35
35
  startedPromise,
36
36
  startedResolve,
@@ -45,6 +45,8 @@ function createWindowsContext(input, log, from, closeLog) {
45
45
  return context;
46
46
  }
47
47
  function failWindows(context, error) {
48
+ if (context.released)
49
+ return;
48
50
  context.lifecycle?.markInert();
49
51
  if (!context.started)
50
52
  context.startedReject(error);
@@ -76,14 +78,16 @@ function recordWindowsExit(context, code) {
76
78
  void (async () => {
77
79
  let failure;
78
80
  let result;
81
+ let exitLog;
79
82
  try {
80
- result = await retainDetachedExitEvidence(context.log, context.input.log, context.from, code, null);
83
+ exitLog = await open(context.input.log, "r+");
84
+ result = await retainDetachedExitEvidence(exitLog, context.input.log, context.from, code, null);
81
85
  }
82
86
  catch (error) {
83
87
  failure = error;
84
88
  }
85
89
  try {
86
- await context.closeLog();
90
+ await exitLog?.close();
87
91
  }
88
92
  catch (error) {
89
93
  failure ??= error;
@@ -95,6 +99,7 @@ function recordWindowsExit(context, code) {
95
99
  const status = detachedExitStatus(code, null);
96
100
  throw new Error(`pre-admission ${status}: run-log evidence unavailable: ${failure instanceof Error ? failure.message : String(failure)}`);
97
101
  }
102
+ await settleWindowsTermination(Promise.resolve());
98
103
  context.exitResolve(result);
99
104
  })().catch(context.exitReject);
100
105
  }
@@ -126,6 +131,8 @@ function handleWindowsLine(context, line) {
126
131
  function installWindowsProtocol(context) {
127
132
  context.child.once("error", (error) => failWindows(context, error));
128
133
  context.child.once("close", () => {
134
+ if (context.released)
135
+ return;
129
136
  if (!context.started) {
130
137
  failWindows(context, launchFailure(context.input.argv[0] ?? "windows-launch.exe", context.stderr.trim()));
131
138
  }
@@ -138,8 +145,9 @@ function installWindowsProtocol(context) {
138
145
  function ownedWindowsProcess(context, pid) {
139
146
  const lifecycle = createProcessLifecycle(async () => {
140
147
  await terminateWindowsTree(pid);
141
- await context.exited;
148
+ await settleWindowsTermination(context.exited.then(() => undefined));
142
149
  }, () => {
150
+ context.released = true;
143
151
  context.reader.close();
144
152
  context.child.stdin?.write("release\n");
145
153
  context.child.stdin?.end();
@@ -164,10 +172,11 @@ export async function spawnWindowsRetainedProcess(input) {
164
172
  logClosed = true;
165
173
  await log.close();
166
174
  };
167
- const context = createWindowsContext(input, log, (await log.stat()).size, closeLog);
175
+ const context = createWindowsContext(input, (await log.stat()).size, closeLog);
168
176
  installWindowsProtocol(context);
169
177
  try {
170
178
  const pid = await context.startedPromise;
179
+ await closeLog();
171
180
  return ownedWindowsProcess(context, pid);
172
181
  }
173
182
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/keiyaku",
3
- "version": "4.3.0",
3
+ "version": "4.5.0",
4
4
  "files": [
5
5
  "build"
6
6
  ],
@@ -59,7 +59,7 @@
59
59
  "dependencies": {
60
60
  "@agentclientprotocol/sdk": "^1.3.0",
61
61
  "@anthropic-ai/claude-agent-sdk": "^0.3.226",
62
- "@astrosheep/square": "0.3.13",
62
+ "@astrosheep/square": "0.3.17",
63
63
  "@earendil-works/pi-coding-agent": "0.80.10",
64
64
  "@opencode-ai/sdk": "1.18.3",
65
65
  "cross-spawn": "^7.0.6",
@@ -1,2 +0,0 @@
1
- /** Opaque, serializable caller result address carried across the Body process. */
2
- export type ResultRoute = Readonly<unknown>;
@@ -1 +0,0 @@
1
- export {};