@astrosheep/keiyaku 4.6.13 → 4.6.14

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 (56) hide show
  1. package/build/plugins/claude/skills/keiyaku/SKILL.md +3 -0
  2. package/build/plugins/claude/skills/keiyaku-akuma/SKILL.md +20 -0
  3. package/build/plugins/codex/skills/keiyaku/SKILL.md +3 -0
  4. package/build/plugins/codex/skills/keiyaku-akuma/SKILL.md +20 -0
  5. package/build/plugins/opencode/skills/keiyaku/SKILL.md +3 -0
  6. package/build/plugins/opencode/skills/keiyaku-akuma/SKILL.md +20 -0
  7. package/build/skills/keiyaku/SKILL.md +3 -0
  8. package/build/skills/keiyaku-akuma/SKILL.md +20 -0
  9. package/build/src/akuma/akuma-instance.js +7 -5
  10. package/build/src/akuma/akuma-observe.js +1 -0
  11. package/build/src/akuma/akuma-owner.js +3 -2
  12. package/build/src/akuma/akuma-wait.d.ts +1 -2
  13. package/build/src/akuma/akuma-wait.js +1 -4
  14. package/build/src/akuma/akuma.d.ts +9 -4
  15. package/build/src/akuma/akuma.js +208 -35
  16. package/build/src/akuma/body.js +4 -0
  17. package/build/src/akuma/caller.d.ts +2 -0
  18. package/build/src/akuma/caller.js +6 -0
  19. package/build/src/akuma/custody.d.ts +11 -0
  20. package/build/src/akuma/custody.js +102 -0
  21. package/build/src/akuma/lineage-cache.d.ts +39 -0
  22. package/build/src/akuma/lineage-cache.js +162 -0
  23. package/build/src/akuma/nuke.d.ts +1 -3
  24. package/build/src/akuma/nuke.js +11 -102
  25. package/build/src/akuma/retention.d.ts +44 -0
  26. package/build/src/akuma/retention.js +106 -0
  27. package/build/src/akuma/selection-owner-port.d.ts +1 -1
  28. package/build/src/akuma/selection-owner-port.js +3 -6
  29. package/build/src/akuma/selection-request.d.ts +131 -4
  30. package/build/src/akuma/selection-request.js +25 -15
  31. package/build/src/alias/index.d.ts +11 -0
  32. package/build/src/alias/index.js +20 -1
  33. package/build/src/cli/commands/contract-grammar.d.ts +12 -0
  34. package/build/src/cli/commands/contract-grammar.js +65 -13
  35. package/build/src/cli/commands/contract-help.d.ts +3 -1
  36. package/build/src/cli/commands/contract-help.js +5 -2
  37. package/build/src/cli/commands/contract.js +31 -4
  38. package/build/src/cli/render/catalog.d.ts +2 -1
  39. package/build/src/cli/render/catalog.js +8 -2
  40. package/build/src/cli/square-identity.d.ts +0 -5
  41. package/build/src/cli/square-identity.js +51 -101
  42. package/build/src/index.d.ts +1 -1
  43. package/build/src/library/akuma-retention.d.ts +11 -0
  44. package/build/src/library/akuma-retention.js +131 -0
  45. package/build/src/library/akumas.d.ts +8 -0
  46. package/build/src/library/akumas.js +42 -2
  47. package/build/src/library/selection.js +22 -9
  48. package/build/src/plugin/akuma-signals.d.ts +1 -0
  49. package/build/src/plugin/akuma-signals.js +17 -0
  50. package/build/src/plugin/public.d.ts +7 -0
  51. package/build/src/plugin/runtime.d.ts +1 -0
  52. package/build/src/plugin/runtime.js +85 -13
  53. package/build/src/runtime/proc/windows-launch.exe +0 -0
  54. package/package.json +3 -3
  55. package/skills/keiyaku/SKILL.md +3 -0
  56. package/skills/keiyaku-akuma/SKILL.md +20 -0
@@ -1,61 +1,60 @@
1
1
  import { createHash } from "node:crypto";
2
- import { realpath } from "node:fs/promises";
3
- import { join, resolve } from "node:path";
4
- import { inbox, Square, SquareError, squareAssignedParticipantName } from "@astrosheep/square";
2
+ import { join } from "node:path";
3
+ import { Square, SquareError } from "@astrosheep/square";
5
4
  /**
6
- * The Square integration's submitting-edge identity policy. Call, fork, tell, and
7
- * ask share this one adapter, so no command and no generic caller decoder has to
8
- * know how Square names a participant. Every Square read below is rooted in the
9
- * submitted environment's own coordinates, never in this process's ambient ones.
5
+ * The submitting-edge identity policy. Every Square read is rooted in the
6
+ * submitted environment's actual Body, never this process's ambient identity.
10
7
  */
11
- /** Square's own harness-session coordinates, in its resolution order. */
12
8
  const HARNESS_SESSIONS = [
13
- { provider: "claude", variable: "CLAUDE_CODE_SESSION_ID" },
14
- { provider: "codex", variable: "CODEX_THREAD_ID" },
15
- { provider: "opencode", variable: "OPENCODE_SESSION_ID" },
16
- { provider: "pi", variable: "PI_SESSION_ID" },
17
- { provider: undefined, variable: "PASEO_AGENT_ID" },
9
+ { provider: "claude", channel: "claude-code", variable: "CLAUDE_CODE_SESSION_ID" },
10
+ { provider: "codex", channel: "codex", variable: "CODEX_THREAD_ID" },
11
+ { provider: "opencode", channel: "opencode", variable: "OPENCODE_SESSION_ID" },
12
+ { provider: "pi", channel: "pi", variable: "PI_SESSION_ID" },
13
+ { provider: undefined, channel: "paseo", variable: "PASEO_AGENT_ID" },
18
14
  ];
19
15
  const SEAT_PREFIX = "keiyaku-assigned-";
20
16
  const SEAT_DIGEST_DOMAIN = "keiyaku/square-seat/v1";
21
17
  const SEAT_DIGEST_LENGTH = 12;
22
- /** One bounded identity observation; a non-cooperative Square never holds a submission. */
23
18
  const IDENTITY_BUDGET_MS = 5_000;
24
19
  function declared(environment, variable) {
25
20
  const value = environment[variable]?.trim();
26
21
  return value === undefined || value === "" ? undefined : value;
27
22
  }
28
- /** A stable, collision-resistant seat for one provider session coordinate. */
23
+ function sessionCoordinates(environment) {
24
+ return HARNESS_SESSIONS.flatMap(({ variable, channel, provider }) => {
25
+ const session = declared(environment, variable);
26
+ return session === undefined ? [] : [{ channel, ...(provider === undefined ? {} : { provider }), session }];
27
+ });
28
+ }
29
+ function squareBody(environment) {
30
+ if (environment.CLAUDE_CODE_CHILD_SESSION === "1")
31
+ return undefined;
32
+ const coordinates = sessionCoordinates(environment);
33
+ const native = coordinates.filter(({ provider }) => provider !== undefined);
34
+ if (native.length > 1)
35
+ return undefined;
36
+ const coordinate = native[0] ?? coordinates.find(({ channel }) => channel === "paseo");
37
+ return coordinate === undefined ? undefined : { channel: coordinate.channel, session: coordinate.session };
38
+ }
29
39
  function squareSeatName(provider, session) {
30
40
  const digest = createHash("sha256")
31
41
  .update(`${SEAT_DIGEST_DOMAIN}\u0000${provider}\u0000${session}`, "utf8")
32
42
  .digest("hex");
33
43
  return `${SEAT_PREFIX}${digest.slice(0, SEAT_DIGEST_LENGTH)}`;
34
44
  }
35
- /** Every harness session coordinate this process declares, able to name a seat or not. */
36
- function sessionIds(environment) {
37
- return HARNESS_SESSIONS.flatMap(({ variable }) => {
38
- const session = declared(environment, variable);
39
- return session === undefined ? [] : [session];
40
- });
41
- }
42
- /**
43
- * The deterministic candidate for this process. Only one provider-backed session
44
- * may exist: a second coordinate — another provider, or the same raw token under
45
- * a different one — is ambiguous and selects nothing.
46
- */
47
45
  function seatCandidate(environment) {
48
- const names = HARNESS_SESSIONS.flatMap(({ provider, variable }) => {
49
- const session = declared(environment, variable);
50
- return provider === undefined || session === undefined ? [] : [squareSeatName(provider, session)];
46
+ const candidates = sessionCoordinates(environment).flatMap(({ provider, session }) => provider === undefined ? [] : [squareSeatName(provider, session)]);
47
+ return candidates.length === 1 ? candidates[0] : undefined;
48
+ }
49
+ function priorSeatNames(environment) {
50
+ return sessionCoordinates(environment).flatMap(({ provider, session }) => {
51
+ if (provider === undefined)
52
+ return [];
53
+ const legacy = createHash("sha256").update(session, "utf8").digest("hex").slice(0, 12);
54
+ const assigned = squareSeatName(provider, session);
55
+ return [`${provider}-${legacy}`, assigned, assigned.replace(SEAT_PREFIX, "keiyaku-seat-")];
51
56
  });
52
- return names.length === 1 ? names[0] : undefined;
53
57
  }
54
- /**
55
- * Square owns the name grammar, so the edge borrows one of Square's own handles
56
- * instead of restating it. An in-memory square validates without touching any
57
- * artifact.
58
- */
59
58
  async function usableName(name) {
60
59
  try {
61
60
  await Square.inMemory({ markdown: "" }).participant(name);
@@ -65,66 +64,27 @@ async function usableName(name) {
65
64
  return false;
66
65
  }
67
66
  }
68
- /** Square's own canonical coordinate for a location it already published. */
69
- async function canonical(path) {
70
- try {
71
- return await realpath(path);
72
- }
73
- catch {
74
- return resolve(path);
75
- }
76
- }
77
- /**
78
- * Bindings this session already holds in this Square. Square's session inbox
79
- * reads the same host registry as native hooks, and it is the only public read
80
- * that takes the caller's own environment coordinate. Its page is bounded, so a
81
- * view that hides rows proves nothing about absence here.
82
- */
83
- async function joinedBindings(environment, ids, location, signal) {
84
- const pages = await Promise.all(ids.map(async (session) => await inbox({ env: environment }, session, {}, { signal })));
85
- const here = await canonical(location);
86
- return {
87
- here: pages.flatMap(({ rows }) => rows).filter(({ squarePath }) => squarePath === here).length,
88
- complete: pages.every(({ rows, total }) => total === rows.length),
89
- };
90
- }
91
- /**
92
- * A session whose former participant already finished here has no replacement
93
- * identity: releasing the binding leaves Square's roster as the only evidence of
94
- * that standing, and a fresh seat would rename a finished caller into a new one.
95
- */
96
- async function finishedHere(square, environment, signal) {
97
- const former = squareAssignedParticipantName(environment);
98
- const previous = seatCandidate(environment)?.replace(SEAT_PREFIX, "keiyaku-seat-");
99
- if (former === undefined && previous === undefined)
100
- return false;
101
- const { participants } = await square.participants({}, { signal });
102
- return participants.some(({ name, state }) => (name === former || name === previous) && state === "done");
103
- }
104
- /**
105
- * A binding this edge cannot stand on is never replaced by a fresh name: Square
106
- * adjudicates standing, ownership, and lifecycle. The edge only distinguishes a
107
- * Square that is genuinely not there yet from evidence it cannot trust.
108
- */
109
- async function standingAt(location, environment, ids, signal) {
67
+ async function standingAt(location, environment, body, signal) {
110
68
  let square;
111
69
  try {
112
- square = await Square.at({ path: location, env: environment }, { signal });
70
+ square = await Square.at({ path: location, env: environment, body }, { signal });
113
71
  }
114
72
  catch (error) {
115
- // Only a Square that is genuinely not there yet may receive a new seat.
116
73
  return error instanceof SquareError && error.code === "unavailable"
117
74
  ? { kind: "absent" }
118
75
  : { kind: "indeterminate" };
119
76
  }
120
77
  try {
121
- const recognized = await square.recognize(environment);
122
- if (recognized !== null)
123
- return { kind: "standing", name: recognized.name };
124
- const bindings = await joinedBindings(environment, ids, square.location, signal);
125
- if (bindings.here > 0 || !bindings.complete)
78
+ const recognized = await square.recognize();
79
+ if (recognized.length === 1)
80
+ return { kind: "standing", name: recognized[0].name };
81
+ if (recognized.length > 1)
82
+ return { kind: "indeterminate" };
83
+ const known = new Set(priorSeatNames(environment));
84
+ const participants = (await square.participants({}, { signal })).participants;
85
+ if (participants.some(({ name, state }) => known.has(name) && state !== "not joined"))
126
86
  return { kind: "indeterminate" };
127
- return (await finishedHere(square, environment, signal)) ? { kind: "indeterminate" } : { kind: "absent" };
87
+ return { kind: "absent" };
128
88
  }
129
89
  catch {
130
90
  return { kind: "indeterminate" };
@@ -133,11 +93,6 @@ async function standingAt(location, environment, ids, signal) {
133
93
  await square.close().catch(() => undefined);
134
94
  }
135
95
  }
136
- /**
137
- * The one bounded attempt. A late settlement keeps no authority: the adapter only
138
- * reads Square, so nothing arrives after the caller has settled, and no refusal
139
- * escapes as an unhandled rejection.
140
- */
141
96
  async function bounded(attempt, control) {
142
97
  const budget = new AbortController();
143
98
  const timer = setTimeout(() => budget.abort(new Error("Square identity observation expired")), control.budgetMs ?? IDENTITY_BUDGET_MS);
@@ -159,22 +114,17 @@ function whenAborted(signal) {
159
114
  signal.addEventListener("abort", () => settle(undefined), { once: true });
160
115
  });
161
116
  }
162
- /**
163
- * The Square participant name this submitting session should speak as, or
164
- * undefined when no honest choice exists. Identity preparation is optional, so it
165
- * never refuses the enclosing operation and always settles within one budget.
166
- */
167
117
  export async function squareSeat(input, control = {}) {
168
118
  const name = declared(input.environment, "SQUARE_PARTICIPANT_NAME");
169
119
  if (name !== undefined)
170
120
  return (await usableName(name)) ? name : undefined;
171
- const ids = sessionIds(input.environment);
172
- // No session coordinate: nothing to reuse, nothing to derive, nothing to read.
173
- if (ids.length === 0)
121
+ const body = squareBody(input.environment);
122
+ const candidate = seatCandidate(input.environment);
123
+ if (body === undefined)
174
124
  return undefined;
175
125
  const location = join(input.path, ".square", "KEIYAKU.square");
176
- const standing = await bounded(async (signal) => await standingAt(location, input.environment, ids, signal), control);
126
+ const standing = await bounded(async (signal) => await standingAt(location, input.environment, body, signal), control);
177
127
  if (standing?.kind === "standing")
178
128
  return standing.name;
179
- return standing?.kind === "absent" ? seatCandidate(input.environment) : undefined;
129
+ return standing?.kind === "absent" ? candidate : undefined;
180
130
  }
@@ -63,7 +63,7 @@ export type { ReconcileCompletion, RepoContractReconcileReport, RepoReconcileRep
63
63
  export type { KeiyakuRefusal, KeiyakuRetryReason, NukeConfirmationRefusal, NukeConfirmationRequiredRefusal, OperationRetries, } from "./library/refusal.js";
64
64
  export type { RegionOverlap } from "./library/region.js";
65
65
  export type { RepoAtInput } from "./library/repo.js";
66
- export type { KeiyakuPlugin, PluginAkumaCaller, PluginAkumaOrigin, PluginBodyEnd, PluginContext, PluginHooks, PluginInstance, PluginManifest, PluginOutcome, PluginSignal, PluginSignalMap, } from "./plugin/public.js";
66
+ export type { KeiyakuPlugin, PluginAkumaCaller, PluginAkumaOrigin, PluginBodyEnd, PluginContext, PluginHooks, PluginInstance, PluginManifest, PluginOutcome, PluginWaitDeferral, PluginWaitDeferralInput, PluginSignal, PluginSignalMap, } from "./plugin/public.js";
67
67
  export type { DeliveryPreviewReport } from "./protocol/delivery-preview.js";
68
68
  export type { IntegrationConflictMaterialized, VerificationReuse } from "./protocol/deliver.js";
69
69
  export type { ExecutionObservation, ExecutionObserver } from "./protocol/execution-observation.js";
@@ -0,0 +1,11 @@
1
+ import type { AkuId } from "../akuma/identity.js";
2
+ import type { WorldRoot } from "../world.js";
3
+ /**
4
+ * One deterministic, bounded retention sweep over the World's recognized Akuma
5
+ * entries. The semantic age, lifecycle, and custody judgment stay in the Akuma
6
+ * owner; this composition supplies only the optional Alias, Dispatch, and
7
+ * Contract associations that owner must not read itself.
8
+ */
9
+ export declare function sweepInactiveAkumas(world: WorldRoot, options?: Readonly<{
10
+ now?: number;
11
+ }>): Promise<readonly AkuId[]>;
@@ -0,0 +1,131 @@
1
+ /** @architectureCompositionRoot */
2
+ import { tryAcquireAliasCustody, readAliases } from "../alias/index.js";
3
+ import { knownAkuma } from "../akuma/akuma.js";
4
+ import { boundedMap } from "../akuma/read-pool.js";
5
+ import { readRetentionCandidate, retentionCandidateBound, retentionNow, retireInactiveAkuma, } from "../akuma/retention.js";
6
+ import { observeDispatchAssociations } from "../dispatch/index.js";
7
+ import { observeContractAt } from "../git/observe.js";
8
+ import { isPrivateStateSeatContention, withPrivateStatePublicationSeat } from "../git/private-state-seat.js";
9
+ import { withGitDecodeChannel } from "../git/read-observation.js";
10
+ import { NoGitWorldError, repositoryAt } from "../git/repository.js";
11
+ /**
12
+ * A World that never became a Git repository has no Dispatch or Contract
13
+ * authority, so every candidate is free of those associations. Any other
14
+ * failure to prove the repository keeps all candidates.
15
+ */
16
+ async function retentionRepository(world) {
17
+ try {
18
+ return await repositoryAt(world);
19
+ }
20
+ catch (error) {
21
+ if (error instanceof NoGitWorldError)
22
+ return undefined;
23
+ throw error;
24
+ }
25
+ }
26
+ /**
27
+ * A Dispatch association is retirable only when it is absent or names a
28
+ * terminal Contract. Unproven, corrupt, missing-Contract, and active Contract
29
+ * associations all keep the Akuma.
30
+ */
31
+ async function dispatchAssociationIsRetirable(repository, association) {
32
+ if (association.kind === "none")
33
+ return true;
34
+ if (association.kind === "failed" || repository === undefined)
35
+ return false;
36
+ return await withGitDecodeChannel(repository, async (channel) => {
37
+ try {
38
+ const terminal = (await observeContractAt(repository, channel, association.contractId)).state?.terminal;
39
+ return terminal?.kind === "claimed" || terminal?.kind === "abandoned";
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ });
45
+ }
46
+ async function retireCandidate(world, repository, entry, now) {
47
+ try {
48
+ const receipt = await retireInactiveAkuma({
49
+ entry,
50
+ now,
51
+ retireUnderCustody: async (retire) => {
52
+ const retireOrRetain = async () => ((await retire()) ? "retired" : "retained");
53
+ // Alias writers use this same authority lock. Holding it across the
54
+ // Dispatch judgment and removal means an Alias cannot appear between
55
+ // the final check and the deletion.
56
+ const aliases = await tryAcquireAliasCustody(world);
57
+ if (aliases === null)
58
+ return "retained";
59
+ try {
60
+ if (aliases.bindings.some((binding) => binding.akuId === entry.id))
61
+ return "retained";
62
+ if (repository === undefined)
63
+ return await retireOrRetain();
64
+ try {
65
+ // Dispatch publication takes this same private-state seat. Holding
66
+ // it across the judgment and removal means a Dispatch cannot appear
67
+ // between the final check and the deletion.
68
+ const retired = await withPrivateStatePublicationSeat(repository, async () => {
69
+ const associations = await observeDispatchAssociations(repository, [entry.id]);
70
+ if (!(await dispatchAssociationIsRetirable(repository, associations.get(entry.id))))
71
+ return "retained";
72
+ return await retireOrRetain();
73
+ }, { timeoutMs: 0 });
74
+ return retired.value;
75
+ }
76
+ catch (error) {
77
+ if (isPrivateStateSeatContention(error))
78
+ return "retained";
79
+ throw error;
80
+ }
81
+ }
82
+ finally {
83
+ aliases.release();
84
+ }
85
+ },
86
+ });
87
+ return receipt === "retired";
88
+ }
89
+ catch {
90
+ // One unreadable, corrupt, or unremovable candidate never fails the sweep
91
+ // or the ordinary operation that triggered it.
92
+ return false;
93
+ }
94
+ }
95
+ /**
96
+ * One deterministic, bounded retention sweep over the World's recognized Akuma
97
+ * entries. The semantic age, lifecycle, and custody judgment stay in the Akuma
98
+ * owner; this composition supplies only the optional Alias, Dispatch, and
99
+ * Contract associations that owner must not read itself.
100
+ */
101
+ export async function sweepInactiveAkumas(world, options = {}) {
102
+ const now = options.now ?? retentionNow();
103
+ const known = await knownAkuma(world);
104
+ if (known.rows.length === 0)
105
+ return [];
106
+ const candidates = (await boundedMap(known.rows, async (entry) => {
107
+ try {
108
+ if (!(await retentionCandidateBound(entry.paths, now)))
109
+ return null;
110
+ return await readRetentionCandidate(entry, now);
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ })).filter((entry) => entry !== null);
116
+ if (candidates.length === 0)
117
+ return [];
118
+ const aliases = new Set((await readAliases(world)).map((binding) => binding.akuId));
119
+ const repository = await retentionRepository(world);
120
+ const associations = await observeDispatchAssociations(repository, candidates.map((entry) => entry.id));
121
+ const retired = [];
122
+ for (const entry of candidates) {
123
+ if (aliases.has(entry.id))
124
+ continue;
125
+ if (!(await dispatchAssociationIsRetirable(repository, associations.get(entry.id))))
126
+ continue;
127
+ if (await retireCandidate(world, repository, entry, now))
128
+ retired.push(entry.id);
129
+ }
130
+ return retired;
131
+ }
@@ -28,6 +28,14 @@ declare class AkumasHandle {
28
28
  wait(input: AkumaWaitInput): Promise<AkumaWaitResult>;
29
29
  kill(input: AkumaKillInput): Promise<AkumaKillResult>;
30
30
  history(input: AkumaHistoryInput): Promise<AkumaHistoryResult>;
31
+ /**
32
+ * The one write-side maintenance point: a successful birth is already durable,
33
+ * so one best-effort sweep may follow it. Only an in-flight sweep is
34
+ * coalesced; a completed or failed sweep leaves the next successful birth free
35
+ * to start a fresh one. It never changes the birth result or any observation.
36
+ */
37
+ private maintain;
38
+ private sweep;
31
39
  private withWorld;
32
40
  }
33
41
  /**
@@ -4,6 +4,7 @@ import { readAkumaRoster } from "../akuma/akuma.js";
4
4
  import { localExecutionContext } from "../akuma/requests.js";
5
5
  import { readAliases } from "../alias/index.js";
6
6
  import { World } from "../world.js";
7
+ import { sweepInactiveAkumas } from "./akuma-retention.js";
7
8
  import { callAkumas, forkAkumas } from "./akuma-creation.js";
8
9
  import { requireInput } from "./input.js";
9
10
  import { askAkumaOn, historyAkuma, killAkumaOn, selectionSeam, statusAkuma, tellAkumaOn, waitAkumaOn, } from "./selection.js";
@@ -13,6 +14,7 @@ class AkumasHandle {
13
14
  #execution;
14
15
  #selection;
15
16
  #reportDiagnostic;
17
+ #maintenance;
16
18
  constructor(world, execution, host) {
17
19
  this.#world = world;
18
20
  this.#execution = execution;
@@ -29,10 +31,19 @@ class AkumasHandle {
29
31
  return withRosterAliases(roster, await readAliases(path));
30
32
  }
31
33
  call(input) {
32
- return callAkumas(this.withWorld(input, "Akumas.call input"), this.#execution, this.#reportDiagnostic);
34
+ const pending = callAkumas(this.withWorld(input, "Akumas.call input"), this.#execution, this.#reportDiagnostic);
35
+ return pending.then(async (result) => {
36
+ await this.maintain(this.#world);
37
+ return result;
38
+ });
33
39
  }
34
40
  fork(input) {
35
- return forkAkumas(this.withWorld(input, "Akumas.fork input"));
41
+ const pending = forkAkumas(this.withWorld(input, "Akumas.fork input"));
42
+ return pending.then(async (result) => {
43
+ if (result.kind === "forked")
44
+ await this.maintain(this.#world);
45
+ return result;
46
+ });
36
47
  }
37
48
  status(input) {
38
49
  return statusAkuma(this.withWorld(input, "Akumas.status input"), this.#reportDiagnostic);
@@ -52,6 +63,35 @@ class AkumasHandle {
52
63
  history(input) {
53
64
  return historyAkuma(this.withWorld(input, "Akumas.history input"), this.#reportDiagnostic);
54
65
  }
66
+ /**
67
+ * The one write-side maintenance point: a successful birth is already durable,
68
+ * so one best-effort sweep may follow it. Only an in-flight sweep is
69
+ * coalesced; a completed or failed sweep leaves the next successful birth free
70
+ * to start a fresh one. It never changes the birth result or any observation.
71
+ */
72
+ maintain(world) {
73
+ const inFlight = this.#maintenance;
74
+ if (inFlight !== undefined)
75
+ return inFlight;
76
+ const pending = this.sweep(world).finally(() => {
77
+ this.#maintenance = undefined;
78
+ });
79
+ this.#maintenance = pending;
80
+ return pending;
81
+ }
82
+ async sweep(world) {
83
+ try {
84
+ await sweepInactiveAkumas(world);
85
+ }
86
+ catch (error) {
87
+ try {
88
+ this.#reportDiagnostic?.(`akuma retention sweep failed: ${error instanceof Error ? error.message : String(error)}`);
89
+ }
90
+ catch {
91
+ // A diagnostic sink failure cannot change the ordinary operation.
92
+ }
93
+ }
94
+ }
55
95
  withWorld(input, label) {
56
96
  const values = requireInput(input, label);
57
97
  if (Object.hasOwn(values, "path"))
@@ -1,4 +1,5 @@
1
1
  /** @architectureCompositionRoot */
2
+ import { observeStatusOutcome } from "../akuma/akuma-observe.js";
2
3
  import { withoutReportedChanges } from "../akuma/projection.js";
3
4
  import { AkumaNotBornError, AkumaObservationError } from "../akuma/akuma-errors.js";
4
5
  import { AkumaOwner } from "../akuma/akuma-owner.js";
@@ -12,15 +13,26 @@ import { observeDispatchAssociations } from "../dispatch/index.js";
12
13
  import { observeContractAt } from "../git/observe.js";
13
14
  import { withGitDecodeChannel } from "../git/read-observation.js";
14
15
  import { observeCreatedDebtObservations } from "../debt/created-observation.js";
15
- import { outcomePluginObserver, stderrPluginDiagnostic } from "../plugin/akuma-signals.js";
16
+ import { outcomePluginObserver, stderrPluginDiagnostic, withAkumaWaitDeferrals } from "../plugin/akuma-signals.js";
16
17
  import { addressAkuma, addressAkumaSet, resolveAkuma, resolveAkumaSet, } from "./address.js";
17
18
  import { requireInput } from "./input.js";
18
19
  import { scopeForRepo } from "./repo.js";
19
20
  export function selectionSeam(execution, reportDiagnostic = stderrPluginDiagnostic) {
20
21
  const channel = executionChannel(execution);
21
- return channel.kind === "body-request" ? forwardedSelection(channel.directory) : localSelection(reportDiagnostic);
22
+ const routed = channel.kind === "body-request"
23
+ ? forwardedSelection(channel.directory, reportDiagnostic)
24
+ : localSelection(reportDiagnostic);
25
+ return {
26
+ ...routed,
27
+ wait: async (addressed, mode) => await withAkumaWaitDeferrals({ world: addressed.path, reportDiagnostic }, addressed.orderedIds, mode.signal, async () => {
28
+ const result = await routed.wait(addressed, mode);
29
+ const observer = outcomePluginObserver({ world: addressed.path, reportDiagnostic });
30
+ await Promise.all(result.observations.map(({ status }) => observeStatusOutcome(status, observer)));
31
+ return result;
32
+ }),
33
+ };
22
34
  }
23
- function forwardedSelection(directory) {
35
+ function forwardedSelection(directory, reportDiagnostic) {
24
36
  return {
25
37
  addressOne: resolveAkuma,
26
38
  addressSet: resolveAkumaSet,
@@ -39,7 +51,7 @@ function forwardedSelection(directory) {
39
51
  ...(abort === undefined ? {} : { signal: abort }),
40
52
  }),
41
53
  ask: async (addressed, input, abort) => {
42
- const result = await requestForwardedSelectionAsk({
54
+ const { result, historyIds } = await requestForwardedSelectionAsk({
43
55
  directory,
44
56
  target: addressed.id,
45
57
  body: input.body,
@@ -49,6 +61,7 @@ function forwardedSelection(directory) {
49
61
  ...(input.initiator === undefined ? {} : { initiator: input.initiator }),
50
62
  ...(abort === undefined ? {} : { signal: abort }),
51
63
  });
64
+ await outcomePluginObserver({ world: addressed.path, reportDiagnostic })(result.akuma, historyIds);
52
65
  return (input.schema === undefined
53
66
  ? result
54
67
  : { ...result, observation: decodeAskObservation(result.observation, input.schema) });
@@ -59,7 +72,7 @@ function localSelection(reportDiagnostic) {
59
72
  return {
60
73
  addressOne: addressAkuma,
61
74
  addressSet: addressAkumaSet,
62
- wait: async (addressed, mode) => await localWait(addressed, mode, reportDiagnostic),
75
+ wait: async (addressed, mode) => await localWait(addressed, mode),
63
76
  kill: async (addressed, abort) => await executeKillAkuma({
64
77
  path: addressed.path,
65
78
  ids: addressed.orderedIds,
@@ -231,21 +244,21 @@ function completionMode(value) {
231
244
  return value;
232
245
  }
233
246
  async function forwardedWait(addressed, input) {
234
- return await attachWaitAssociations(addressed.path, input.repo, await requestForwardedSelectionWait({
247
+ const result = await requestForwardedSelectionWait({
235
248
  directory: input.directory,
236
249
  targets: addressed.orderedIds,
237
250
  completion: input.completion,
238
251
  ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
239
252
  ...(input.signal === undefined ? {} : { signal: input.signal }),
240
- }));
253
+ });
254
+ return await attachWaitAssociations(addressed.path, input.repo, result);
241
255
  }
242
- async function localWait(addressed, input, reportDiagnostic) {
256
+ async function localWait(addressed, input) {
243
257
  try {
244
258
  return await composeWaitResult(addressed.path, input.repo, await executeWaitAkuma({
245
259
  path: addressed.path,
246
260
  ids: addressed.orderedIds,
247
261
  completion: input.completion,
248
- outcomeObserved: outcomePluginObserver({ world: addressed.path, reportDiagnostic }),
249
262
  ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
250
263
  ...(input.signal === undefined ? {} : { signal: input.signal }),
251
264
  }));
@@ -14,6 +14,7 @@ type PluginSignalEmission = Readonly<{
14
14
  * problem reaches the producer only as a diagnostic, never as a thrown error.
15
15
  */
16
16
  export declare function emitPluginSignal(emission: PluginSignalEmission, signal: PluginSignal): Promise<void>;
17
+ export declare function withAkumaWaitDeferrals<T>(emission: PluginSignalEmission, akumaIds: readonly string[], signal: AbortSignal | undefined, action: () => Promise<T>): Promise<T>;
17
18
  /** One public observer operation, never shared with incidental owner reads. */
18
19
  export declare function outcomePluginObserver(emission: PluginSignalEmission, observer?: string): (akumaId: string, historyIds: readonly string[]) => Promise<void>;
19
20
  export {};
@@ -21,6 +21,23 @@ export async function emitPluginSignal(emission, signal) {
21
21
  reportPluginDiagnostic(emission.reportDiagnostic, "runtime", "signal", error);
22
22
  }
23
23
  }
24
+ export async function withAkumaWaitDeferrals(emission, akumaIds, signal, action) {
25
+ signal?.throwIfAborted();
26
+ let runtime;
27
+ try {
28
+ runtime = await pluginRuntime({
29
+ world: emission.world,
30
+ ...(emission.settings === undefined ? {} : { settings: emission.settings }),
31
+ reportDiagnostic: emission.reportDiagnostic,
32
+ });
33
+ }
34
+ catch (error) {
35
+ signal?.throwIfAborted();
36
+ reportPluginDiagnostic(emission.reportDiagnostic, "runtime", "wait deferral", error);
37
+ return await action();
38
+ }
39
+ return await runtime.withWaitDeferrals(akumaIds, signal, action, emission.reportDiagnostic);
40
+ }
24
41
  /** One public observer operation, never shared with incidental owner reads. */
25
42
  export function outcomePluginObserver(emission, observer) {
26
43
  const seen = new Set();
@@ -97,6 +97,13 @@ export type KeiyakuPlugin = Readonly<{
97
97
  manifest: PluginManifest;
98
98
  activate(context: PluginContext, cancellation?: AbortSignal): Promise<PluginInstance> | PluginInstance;
99
99
  }>;
100
+ export type PluginWaitDeferralInput = Readonly<{
101
+ akumaIds: readonly string[];
102
+ }>;
103
+ export type PluginWaitDeferral = Readonly<{
104
+ close(cancellation?: AbortSignal): Promise<void> | void;
105
+ }>;
100
106
  export type PluginInstance = Readonly<{
101
107
  signals?: PluginHooks;
108
+ waitDeferral?: (input: PluginWaitDeferralInput, cancellation?: AbortSignal) => Promise<PluginWaitDeferral | undefined> | PluginWaitDeferral | undefined;
102
109
  }>;
@@ -9,6 +9,7 @@ type PluginRuntimeInput = Readonly<{
9
9
  }>;
10
10
  export type PluginRuntime = Readonly<{
11
11
  emit(signal: PluginSignal, reportDiagnostic?: PluginDiagnostic): Promise<void>;
12
+ withWaitDeferrals<T>(akumaIds: readonly string[], signal: AbortSignal | undefined, action: () => Promise<T>, reportDiagnostic?: PluginDiagnostic): Promise<T>;
12
13
  }>;
13
14
  export declare function reportPluginDiagnostic(report: PluginDiagnostic | undefined, subject: string, stage: string, error: unknown): void;
14
15
  export declare function pluginRuntime(input: PluginRuntimeInput): Promise<PluginRuntime>;