@gajae-code/coding-agent 0.18.6 → 0.18.7

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 (58) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/config/settings.d.ts +4 -0
  4. package/dist/types/discovery/helpers.d.ts +2 -2
  5. package/dist/types/runtime-mcp/elicitation.d.ts +12 -0
  6. package/dist/types/sdk/broker/lifecycle.d.ts +9 -0
  7. package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
  8. package/dist/types/sdk/bus/telegram-daemon.d.ts +3 -2
  9. package/dist/types/sdk/host/host.d.ts +6 -0
  10. package/dist/types/sdk/router/session-router.d.ts +7 -0
  11. package/dist/types/session/internal/managed-session-storage.d.ts +11 -1
  12. package/dist/types/session/internal/task-artifact-owner-access.d.ts +17 -0
  13. package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +3 -0
  14. package/dist/types/session/session-retirement.d.ts +47 -0
  15. package/dist/types/session/session-storage.d.ts +0 -46
  16. package/dist/types/session/session-transcript-header.d.ts +2 -0
  17. package/dist/types/session/task-artifact-owner-codec.d.ts +107 -0
  18. package/dist/types/session/task-artifact-owner-retirement.d.ts +7 -0
  19. package/dist/types/session/task-artifact-owner.d.ts +10 -0
  20. package/package.json +7 -7
  21. package/scripts/resident-memory-bench.ts +174 -20
  22. package/src/capability/index.ts +1 -0
  23. package/src/capability/types.ts +4 -0
  24. package/src/cli/config-cli.ts +8 -18
  25. package/src/cli-main.ts +7 -8
  26. package/src/commands/sdk.ts +112 -21
  27. package/src/config/settings.ts +44 -4
  28. package/src/discovery/helpers.ts +9 -3
  29. package/src/discovery/ssh.ts +8 -3
  30. package/src/gjc-runtime/gc-runtime.ts +2 -1
  31. package/src/hindsight/config.ts +9 -3
  32. package/src/internal-urls/docs-index.generated.ts +2 -2
  33. package/src/modes/acp/acp-agent.ts +75 -3
  34. package/src/runtime-mcp/client.ts +13 -4
  35. package/src/runtime-mcp/elicitation.ts +142 -0
  36. package/src/sdk/broker/broker.ts +8 -1
  37. package/src/sdk/broker/lifecycle.ts +109 -7
  38. package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
  39. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  40. package/src/sdk/bus/chat-daemon-runtime.ts +3 -0
  41. package/src/sdk/bus/index.ts +3 -3
  42. package/src/sdk/bus/telegram-daemon.ts +4 -2
  43. package/src/sdk/host/host.ts +6 -0
  44. package/src/sdk/host/session-runtime.test.ts +43 -2
  45. package/src/sdk/host/session-runtime.ts +2 -2
  46. package/src/sdk/mcp/server.ts +15 -3
  47. package/src/sdk/router/session-router.ts +19 -2
  48. package/src/sdk/session.ts +14 -0
  49. package/src/session/agent-session.ts +57 -6
  50. package/src/session/internal/managed-session-storage.ts +107 -1
  51. package/src/session/internal/task-artifact-owner-access.ts +229 -0
  52. package/src/session/internal/task-artifact-owner-transcript.ts +68 -0
  53. package/src/session/session-retirement.ts +155 -0
  54. package/src/session/session-storage.ts +1 -160
  55. package/src/session/session-transcript-header.ts +17 -0
  56. package/src/session/task-artifact-owner-codec.ts +578 -0
  57. package/src/session/task-artifact-owner-retirement.ts +475 -0
  58. package/src/session/task-artifact-owner.ts +289 -0
@@ -1426,6 +1426,21 @@ const THINKING_CONFIG_OPTIONS = ["off", "minimal", "low", "medium", "high", "xhi
1426
1426
  name: value,
1427
1427
  }));
1428
1428
 
1429
+ function thinkingConfigOptions(query: unknown, currentModel: string | undefined): { value: string; name: string }[] {
1430
+ if (!currentModel) return THINKING_CONFIG_OPTIONS;
1431
+ const separator = currentModel.indexOf("/");
1432
+ if (separator <= 0 || separator === currentModel.length - 1) return THINKING_CONFIG_OPTIONS;
1433
+ const provider = currentModel.slice(0, separator);
1434
+ const id = currentModel.slice(separator + 1);
1435
+ const model = pageItems(query)
1436
+ .map(item => object(item))
1437
+ .find(item => item?.provider === provider && item.id === id);
1438
+ const validLevels = object(model?.thinking)?.validLevels;
1439
+ if (!Array.isArray(validLevels) || validLevels.length === 0) return THINKING_CONFIG_OPTIONS;
1440
+ const levels = validLevels.filter((value): value is string => typeof value === "string");
1441
+ return levels.length === 0 ? THINKING_CONFIG_OPTIONS : levels.map(value => ({ value, name: value }));
1442
+ }
1443
+
1429
1444
  /** Maps live canonical SDK config and the selected model catalog into the ACP 1.2.1 session state surface. */
1430
1445
  export function acpSessionStateFromConfig(
1431
1446
  query: unknown,
@@ -1436,6 +1451,7 @@ export function acpSessionStateFromConfig(
1436
1451
  const values = configValues(query);
1437
1452
  const useModelPresets = modelPreset !== undefined;
1438
1453
  const currentModeId = values.get(MODE_CONFIG_ID) === ACP_PLAN_MODE_ID ? ACP_PLAN_MODE_ID : ACP_DEFAULT_MODE_ID;
1454
+ const thinkingOptions = thinkingConfigOptions(modelCatalogQuery, values.get(MODEL_CONFIG_ID));
1439
1455
  return {
1440
1456
  configOptions: [
1441
1457
  {
@@ -1461,7 +1477,7 @@ export function acpSessionStateFromConfig(
1461
1477
  ? modelPresetConfigOptions(modelCatalogQuery, value)
1462
1478
  : modelConfigOptions(modelCatalogQuery, value, activeProviders)
1463
1479
  : option.id === THINKING_CONFIG_ID
1464
- ? THINKING_CONFIG_OPTIONS
1480
+ ? thinkingOptions
1465
1481
  : [...option.options];
1466
1482
  return [
1467
1483
  {
@@ -1798,11 +1814,13 @@ export class AcpAgent implements Agent {
1798
1814
  if (adapter) adapter.acceptFrame(acpFrame);
1799
1815
  else this.#pendingRouterFrames.get(attachment.sessionId)?.push(acpFrame);
1800
1816
  },
1801
- onSessionRemoved: attachment => {
1817
+ onSessionRemoved: (attachment, removalReason) => {
1802
1818
  const adapter =
1803
1819
  this.#sessions.get(attachment.sessionId)?.adapter ??
1804
1820
  this.#pendingRouterAdapters.get(attachment.sessionId);
1805
1821
  adapter?.revokeAttachment(attachment);
1822
+ if (adapter && removalReason !== "replaced_same_generation")
1823
+ this.#settlePromptAfterHostClose(attachment.sessionId, removalReason ?? "host_exit");
1806
1824
  },
1807
1825
  },
1808
1826
  });
@@ -2158,7 +2176,22 @@ export class AcpAgent implements Agent {
2158
2176
  }
2159
2177
  break;
2160
2178
  case THINKING_CONFIG_ID:
2161
- await this.#adapter(params.sessionId).control("thinking.set", { level: params.value });
2179
+ {
2180
+ const before = await this.#sessionState(params.sessionId);
2181
+ const thinkingOption = before.configOptions?.find(option => option.id === THINKING_CONFIG_ID);
2182
+ if (
2183
+ thinkingOption?.type !== "select" ||
2184
+ !thinkingOption.options.some(option => "value" in option && option.value === params.value)
2185
+ )
2186
+ throw new Error(`Unsupported thinking level: ${params.value}`);
2187
+ await this.#adapter(params.sessionId).control("thinking.set", { level: params.value });
2188
+ const after = await this.#sessionState(params.sessionId);
2189
+ const currentValue = after.configOptions?.find(option => option.id === THINKING_CONFIG_ID)?.currentValue;
2190
+ if (currentValue !== params.value)
2191
+ throw new Error(
2192
+ `Thinking level was not applied: requested ${params.value}, current ${currentValue ?? "unknown"}`,
2193
+ );
2194
+ }
2162
2195
  break;
2163
2196
  default: {
2164
2197
  const operation = ACP_CONFIG_CONTROL_OPERATIONS[params.configId];
@@ -4459,6 +4492,37 @@ export class AcpAgent implements Agent {
4459
4492
  return undefined;
4460
4493
  }
4461
4494
 
4495
+ /** Settles the active prompt when the host explicitly reports that its session closed. */
4496
+ #settlePromptAfterHostClose(id: string, reason: string): void {
4497
+ const record = this.#sessions.get(id);
4498
+ if (!record) return;
4499
+ const waiter = record.activePrompt;
4500
+ if (!waiter || waiter.settled || waiter.terminalReserved) return;
4501
+ if (record.cancelRequested && waiter.cancelAcknowledged) {
4502
+ void this.#settleCancelledPrompt(id, record, waiter);
4503
+ return;
4504
+ }
4505
+ // An accepted mutation without its acknowledgement remains uncertain. Keep the
4506
+ // existing reconciliation path so a host exit never turns an ambiguous prompt
4507
+ // into a retryable failure.
4508
+ if (waiter.dispatched && !waiter.acknowledged) {
4509
+ this.#startUncertainPromptRecovery(id, record, waiter);
4510
+ return;
4511
+ }
4512
+ record.busy = record.backgroundBusy;
4513
+ void this.#rejectPrompt(
4514
+ record,
4515
+ id,
4516
+ waiter,
4517
+ new AcpPromptAbandonedError(
4518
+ "prompt_abandoned",
4519
+ `ACP prompt was abandoned because the SDK session host closed (${reason}). The turn was settled so the ` +
4520
+ `client stops waiting; a new prompt requires a valid or re-established session attachment.`,
4521
+ waiter.planSnapshot,
4522
+ ),
4523
+ );
4524
+ }
4525
+
4462
4526
  /**
4463
4527
  * Settles the ACP prompt only. The agent's own work is left alone: this reports that the
4464
4528
  * turn can no longer be observed, it does not cancel or tear down the session.
@@ -4532,6 +4596,14 @@ export class AcpAgent implements Agent {
4532
4596
  else logger.warn(`ACP session ${id} dropped an event from a foreign session identity.`);
4533
4597
  return;
4534
4598
  }
4599
+ if (ingressEvent?.type === "session_closed" || ingressEvent?.type === "session_terminated") {
4600
+ const reason =
4601
+ typeof ingressEvent.reason === "string" && ingressEvent.reason.length > 0
4602
+ ? ingressEvent.reason
4603
+ : ingressEvent.type;
4604
+ this.#settlePromptAfterHostClose(id, reason);
4605
+ return;
4606
+ }
4535
4607
  if (frame.type !== "hello" && frame.type !== "server_hello" && typeof frame.connectionId === "string")
4536
4608
  record.connectionId = frame.connectionId;
4537
4609
  // Ingress ordering is recorded before queued work begins.
@@ -451,6 +451,9 @@ function isModernMethodNotFound(error: unknown): boolean {
451
451
  * The original request is retried with `inputResponses` and a verbatim
452
452
  * `requestState` echo under a fresh JSON-RPC id, at most MAX_MRTR_RETRIES times;
453
453
  * the retry is fenced to the originating exchange by a stable correlation id.
454
+ * `roots/list` input requests are answered locally; every other input request
455
+ * requires `options.inputHandler`, and its absence fails the exchange before any
456
+ * retry is sent (no write replay, no implicit accept).
454
457
  */
455
458
  async function requestWithInputHandling<T>(
456
459
  connection: MCPServerConnection,
@@ -474,17 +477,20 @@ async function requestWithInputHandling<T>(
474
477
  if (!inputRequired) return stripResultType(initial);
475
478
 
476
479
  const handler = options?.inputHandler;
477
- if (!handler) {
478
- throw new MCPExpectedFailure(
480
+ const correlationId = crypto.randomUUID();
481
+
482
+ const missingHandler = () =>
483
+ new MCPExpectedFailure(
479
484
  new Error(
480
485
  `MCP server "${connection.name}" requested additional input (input_required) for ${method}, but no interactive input handler is available`,
481
486
  ),
482
487
  );
483
- }
484
- const correlationId = crypto.randomUUID();
485
488
 
486
489
  for (let attempt = 0; attempt < MAX_MRTR_RETRIES; attempt++) {
487
490
  const inputResponses: Record<string, unknown> = {};
491
+ // A requestState-only round has no local answer to give; without an
492
+ // interactive handler it is never replayed unattended.
493
+ if (!handler && Object.keys(inputRequired.inputRequests).length === 0) throw missingHandler();
488
494
  for (const [key, request] of Object.entries(inputRequired.inputRequests)) {
489
495
  if (options?.signal?.aborted) {
490
496
  throw options.signal.reason instanceof Error ? options.signal.reason : new Error("Aborted");
@@ -494,6 +500,9 @@ async function requestWithInputHandling<T>(
494
500
  inputResponses[key] = await defaultRequestHandler("roots/list", undefined);
495
501
  continue;
496
502
  }
503
+ // Everything else (elicitation, sampling, ...) needs a real interactive
504
+ // handler. It is never answered implicitly; fail before any retry is sent.
505
+ if (!handler) throw missingHandler();
497
506
  const outcome = await handler(key, request, {
498
507
  serverName: connection.name,
499
508
  originMethod: method,
@@ -0,0 +1,142 @@
1
+ import { untilAborted } from "@gajae-code/utils";
2
+ import type { ExtensionUIContext } from "../extensibility/extensions/types";
3
+ import type { AskAnswerSource, AskRemoteReceipt } from "../tools";
4
+ import type { MCPInputRequestHandler } from "./types";
5
+
6
+ export interface MCPFormInputDependencies {
7
+ getUi(): { ui?: Pick<ExtensionUIContext, "select" | "input">; hasUI: boolean };
8
+ getAskAnswerSource(): AskAnswerSource | undefined;
9
+ }
10
+
11
+ interface FormField {
12
+ key: string;
13
+ label: string;
14
+ type: "string" | "boolean";
15
+ choices?: string[];
16
+ }
17
+
18
+ function isRecord(value: unknown): value is Record<string, unknown> {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+
22
+ function displayText(value: string): string {
23
+ return value.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ").slice(0, 4000);
24
+ }
25
+
26
+ /** Unsupported constraints are rejected, never silently discarded. */
27
+ function decodeField(schema: unknown): FormField | undefined {
28
+ if (!isRecord(schema) || schema.type !== "object" || !isRecord(schema.properties)) return undefined;
29
+ const allowed = new Set(["type", "properties", "required", "additionalProperties", "title", "description"]);
30
+ if (Object.keys(schema).some(key => !allowed.has(key))) return undefined;
31
+ if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== "boolean") return undefined;
32
+ const entries = Object.entries(schema.properties);
33
+ if (entries.length !== 1) return undefined;
34
+ const [key, property] = entries[0]!;
35
+ if (!isRecord(property) || (property.type !== "string" && property.type !== "boolean")) return undefined;
36
+ if (
37
+ schema.required !== undefined &&
38
+ (!Array.isArray(schema.required) || schema.required.some(value => value !== key))
39
+ )
40
+ return undefined;
41
+ const propertyKeys = new Set(["type", "title", "description", "enum"]);
42
+ if (Object.keys(property).some(name => !propertyKeys.has(name))) return undefined;
43
+ if (property.title !== undefined && typeof property.title !== "string") return undefined;
44
+ if (property.description !== undefined && typeof property.description !== "string") return undefined;
45
+ let choices: string[] | undefined;
46
+ if (property.enum !== undefined) {
47
+ if (
48
+ property.type !== "string" ||
49
+ !Array.isArray(property.enum) ||
50
+ property.enum.length === 0 ||
51
+ !property.enum.every(value => typeof value === "string") ||
52
+ new Set(property.enum).size !== property.enum.length ||
53
+ property.enum.some(value => displayText(value) !== value)
54
+ )
55
+ return undefined;
56
+ choices = property.enum;
57
+ }
58
+ const description = typeof property.description === "string" ? ` — ${property.description}` : "";
59
+ return { key, label: displayText(`${property.title ?? key}${description}`), type: property.type, choices };
60
+ }
61
+
62
+ /** Register on an owned manager; dependencies are resolved per call for late UI registration. */
63
+ export function createMCPFormInputHandler(deps: MCPFormInputDependencies): MCPInputRequestHandler {
64
+ return async (_key, request, context) => {
65
+ if (context.signal?.aborted) return { kind: "failed", reason: "cancelled" };
66
+ if (request.method !== "elicitation/create") return { kind: "failed", reason: "unavailable" };
67
+ const params = request.params;
68
+ if (!params || typeof params.message !== "string" || (params.mode !== undefined && params.mode !== "form"))
69
+ return { kind: "failed", reason: "unavailable", message: "Unsupported MCP form request" };
70
+ const field = decodeField(params.requestedSchema);
71
+ if (!field) return { kind: "failed", reason: "unavailable", message: "Unsupported MCP form schema" };
72
+ const local = deps.getUi();
73
+ const ui = local.hasUI ? local.ui : undefined;
74
+ const source = ui ? undefined : deps.getAskAnswerSource();
75
+ if (!ui && !source)
76
+ return { kind: "failed", reason: "unavailable", message: "No user input surface is available" };
77
+ const signal = context.signal;
78
+ const ask = async (question: string, choices?: string[]): Promise<string | undefined> => {
79
+ return untilAborted(signal, async () => {
80
+ if (ui)
81
+ return choices ? ui.select(question, choices, { signal }) : ui.input(question, undefined, { signal });
82
+ if (!source) return undefined;
83
+ const answer = source.awaitAnswerRequest
84
+ ? await source.awaitAnswerRequest(
85
+ {
86
+ question,
87
+ options: choices ?? [],
88
+ interaction: choices ? "selector" : "custom_editor",
89
+ controls: [],
90
+ },
91
+ signal,
92
+ )
93
+ : await source.awaitAnswer(question, choices ?? [], signal);
94
+ let receipt: AskRemoteReceipt | undefined;
95
+ let value: string | undefined;
96
+ if (typeof answer === "string") value = answer;
97
+ else if (answer) {
98
+ receipt = answer;
99
+ if (answer.interaction.kind === "value") value = answer.interaction.value;
100
+ }
101
+ if (signal?.aborted) {
102
+ await receipt?.settle({ kind: "resolve_without_commit", reason: "aborted" });
103
+ return undefined;
104
+ }
105
+ if (receipt?.interaction.kind === "control") {
106
+ await receipt.settle({ kind: "invalid", reason: "invalid_control" });
107
+ throw new Error("Invalid MCP form control");
108
+ }
109
+ if (value !== undefined && choices && !choices.includes(value)) {
110
+ await receipt?.settle({ kind: "invalid", reason: "invalid_option" });
111
+ throw new Error("Invalid MCP form answer");
112
+ }
113
+ const settlement = await receipt?.settle(
114
+ value === undefined ? { kind: "resolve_without_commit", reason: "cancelled" } : { kind: "commit" },
115
+ );
116
+ if (receipt && value !== undefined && settlement?.kind !== "committed") return undefined;
117
+ return value;
118
+ });
119
+ };
120
+ try {
121
+ const title = `MCP server ${displayText(context.serverName)} requests input (untrusted server text): ${displayText(params.message)}`;
122
+ const action = await ask(title, ["Accept", "Decline"]);
123
+ if (signal?.aborted) return { kind: "failed", reason: "cancelled" };
124
+ if (action === undefined) return { kind: "result", result: { action: "cancel" } };
125
+ if (action === "Decline") return { kind: "result", result: { action: "decline" } };
126
+ if (action !== "Accept") return { kind: "failed", reason: "error", message: "Invalid MCP form action" };
127
+ const choices = field.type === "boolean" ? ["Yes", "No"] : field.choices;
128
+ const answer = await ask(
129
+ `MCP server ${displayText(context.serverName)} (untrusted server text): ${field.label}`,
130
+ choices,
131
+ );
132
+ if (signal?.aborted) return { kind: "failed", reason: "cancelled" };
133
+ if (answer === undefined) return { kind: "result", result: { action: "cancel" } };
134
+ if (choices && !choices.includes(answer))
135
+ return { kind: "failed", reason: "error", message: "Invalid MCP form answer" };
136
+ const value = field.type === "boolean" ? answer === "Yes" : answer;
137
+ return { kind: "result", result: { action: "accept", content: { [field.key]: value } } };
138
+ } catch {
139
+ return { kind: "failed", reason: signal?.aborted ? "cancelled" : "error" };
140
+ }
141
+ };
142
+ }
@@ -1525,6 +1525,7 @@ export class Broker {
1525
1525
  #publishedAt: bigint | null = null;
1526
1526
  #startedAt: bigint | null = null;
1527
1527
  #watchInFlight = false;
1528
+ #checkpointInFlight = false;
1528
1529
  #stopping = false;
1529
1530
  #transport: BrokerTransport | null = null;
1530
1531
  #heartbeatTimer: NodeJS.Timeout | null = null;
@@ -3786,6 +3787,7 @@ export class Broker {
3786
3787
  this.#publishedAt = null;
3787
3788
  this.#startedAt = process.hrtime.bigint();
3788
3789
  this.#watchInFlight = false;
3790
+ this.#checkpointInFlight = false;
3789
3791
  this.#throwIfStartupAborted();
3790
3792
  await Promise.all([this.ledger.assertSupportedStateVersions(), readBrokerDiscovery(this.settings.agentDir)]);
3791
3793
  this.#throwIfStartupAborted();
@@ -4192,7 +4194,7 @@ export class Broker {
4192
4194
  return;
4193
4195
  }
4194
4196
  this.#startupAdmissions.reopen();
4195
- if (this.#publicationState === "healthy-owned") await this.#checkpointSessionHeartbeats();
4197
+ if (this.#publicationState === "healthy-owned") void this.#checkpointSessionHeartbeats();
4196
4198
  return;
4197
4199
  }
4198
4200
  this.#fence(observation === "ambiguous" ? "observation-ambiguous" : "suspect-unpublished");
@@ -4233,10 +4235,14 @@ export class Broker {
4233
4235
  return await this.index.checkpointLiveHeartbeats(now);
4234
4236
  }
4235
4237
  async #checkpointSessionHeartbeats(): Promise<void> {
4238
+ if (this.#checkpointInFlight || this.#stopping) return;
4239
+ this.#checkpointInFlight = true;
4236
4240
  try {
4237
4241
  await this.heartbeatSessions();
4238
4242
  } catch (error) {
4239
4243
  logger.warn(`sdk broker: session heartbeat checkpoint failed: ${String(error)}`);
4244
+ } finally {
4245
+ this.#checkpointInFlight = false;
4240
4246
  }
4241
4247
  }
4242
4248
  async #complete(
@@ -4260,6 +4266,7 @@ export class Broker {
4260
4266
  };
4261
4267
  (mode === "lost-root" ? logger.warn : logger.info)("sdk broker: exiting", exitRecord);
4262
4268
  this.#stopping = true;
4269
+ this.#checkpointInFlight = false;
4263
4270
  this.#publicationState = "stopping";
4264
4271
  // A lost-root broker has been fenced: it no longer owns the published root, and
4265
4272
  // its settlement is bounded, so any startup still queued behind it would be
@@ -1667,9 +1667,9 @@ export async function reapDeadLifecycleMarkers(
1667
1667
  let inspected = 0;
1668
1668
  for await (const entry of directoryHandle) {
1669
1669
  if (inspected >= inspectionLimit) break;
1670
- inspected += 1;
1671
1670
  const name = entry.name;
1672
1671
  if (!entry.isFile() || !name.endsWith(".lifecycle.json")) continue;
1672
+ inspected += 1;
1673
1673
  const id = name.slice(0, -".lifecycle.json".length);
1674
1674
  if (!isCanonicalSessionId(id)) continue;
1675
1675
  const markerPath = path.join(directory, name);
@@ -1741,6 +1741,108 @@ export async function reapDeadLifecycleMarkers(
1741
1741
  return reaped;
1742
1742
  }
1743
1743
 
1744
+ /**
1745
+ * Retires one launch id's lifecycle marker pair after its recorded owner has
1746
+ * exited. Unlike the bounded background sweep, this launch-local repair is
1747
+ * not age-gated and does not require the ready sibling to contain the same
1748
+ * effect marker: the primary marker is the owner authority for this id.
1749
+ */
1750
+ type RetireExitedLifecycleMarkerPairHook = () => void | Promise<void>;
1751
+
1752
+ async function retireExitedLifecycleMarkerPairImpl(
1753
+ root: string,
1754
+ id: string,
1755
+ afterObservation?: RetireExitedLifecycleMarkerPairHook,
1756
+ ): Promise<boolean> {
1757
+ if (!isCanonicalSessionId(id)) return false;
1758
+ let directory: string;
1759
+ let directoryIdentity: { dev: bigint; ino: bigint };
1760
+ try {
1761
+ const canonicalRoot = fsSync.realpathSync(root);
1762
+ directory = path.join(canonicalRoot, "sdk");
1763
+ const directoryStat = fsSync.lstatSync(directory, { bigint: true });
1764
+ if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) return false;
1765
+ directoryIdentity = { dev: directoryStat.dev, ino: directoryStat.ino };
1766
+ } catch {
1767
+ return false;
1768
+ }
1769
+ const markerPath = lifecycleMarkerPath(path.dirname(directory), id);
1770
+ const primary = captureLifecycleFile(markerPath, true, true);
1771
+ if (!primary) return false;
1772
+ let marker: EffectMarker;
1773
+ try {
1774
+ const parsed = parseLifecycleJson(primary.bytes);
1775
+ if (!isExactEffectMarker(parsed)) return false;
1776
+ marker = parsed;
1777
+ } catch {
1778
+ return false;
1779
+ }
1780
+ if (observeProcess(marker.pid, marker.incarnation) !== "exited") return false;
1781
+ await afterObservation?.();
1782
+ const readyPath = lifecycleReadyPath(path.dirname(directory), id);
1783
+ const ready = captureLifecycleFile(readyPath, true, true);
1784
+ try {
1785
+ const currentParent = lifecycleParentIdentity(directory);
1786
+ if (
1787
+ !currentParent ||
1788
+ BigInt(currentParent.dev) !== directoryIdentity.dev ||
1789
+ BigInt(currentParent.ino) !== directoryIdentity.ino
1790
+ )
1791
+ return false;
1792
+ const currentPrimary = captureLifecycleFile(markerPath, true, true);
1793
+ await afterObservation?.();
1794
+ if (
1795
+ !currentPrimary ||
1796
+ !sameLifecycleCleanupIdentity(
1797
+ currentPrimary.identity,
1798
+ serializeCleanupIdentity({ ...primary.identity, size: Number(primary.identity.size) }),
1799
+ )
1800
+ )
1801
+ return false;
1802
+ const currentMarker = parseLifecycleJson(currentPrimary.bytes);
1803
+ if (!isExactEffectMarker(currentMarker) || !sameEffectMarker(currentMarker, marker)) return false;
1804
+ if (ready) {
1805
+ const currentReady = captureLifecycleFile(readyPath, true, true);
1806
+ if (
1807
+ !currentReady ||
1808
+ !sameLifecycleCleanupIdentity(
1809
+ currentReady.identity,
1810
+ serializeCleanupIdentity({ ...ready.identity, size: Number(ready.identity.size) }),
1811
+ )
1812
+ )
1813
+ return false;
1814
+ const removedReady = nativeLifecycle().exactUnlinkDirect(readyPath, {
1815
+ ...ready.identity,
1816
+ parentDev: directoryIdentity.dev,
1817
+ parentIno: directoryIdentity.ino,
1818
+ quarantineName: `.gjc-reap-${randomUUID()}-${path.basename(readyPath)}`,
1819
+ });
1820
+ if (!removedReady.ok && removedReady.code !== "not_found") return false;
1821
+ }
1822
+ const removedMarker = nativeLifecycle().exactUnlinkDirect(markerPath, {
1823
+ ...primary.identity,
1824
+ parentDev: directoryIdentity.dev,
1825
+ parentIno: directoryIdentity.ino,
1826
+ quarantineName: `.gjc-reap-${randomUUID()}-${path.basename(markerPath)}`,
1827
+ });
1828
+ return removedMarker.ok || removedMarker.code === "not_found";
1829
+ } catch {
1830
+ return false;
1831
+ }
1832
+ }
1833
+
1834
+ export async function retireExitedLifecycleMarkerPair(root: string, id: string): Promise<boolean> {
1835
+ return retireExitedLifecycleMarkerPairImpl(root, id);
1836
+ }
1837
+
1838
+ export async function retireExitedLifecycleMarkerPairForTest(
1839
+ root: string,
1840
+ id: string,
1841
+ afterObservation: RetireExitedLifecycleMarkerPairHook,
1842
+ ): Promise<boolean> {
1843
+ return retireExitedLifecycleMarkerPairImpl(root, id, afterObservation);
1844
+ }
1845
+
1744
1846
  export async function writeEffectMarker(root: string, id: string, marker: EffectMarker): Promise<void> {
1745
1847
  const directory = path.join(root, "sdk");
1746
1848
  await fs.mkdir(directory, { recursive: true, mode: 0o700 });
@@ -5392,12 +5494,11 @@ async function currentReadyAuthority(
5392
5494
  token?: unknown;
5393
5495
  pid?: unknown;
5394
5496
  };
5395
- // Native-alive owned readiness is the admission authority. `record.live`
5396
- // also requires a fresh index heartbeat projection, which can lag a just-
5397
- // registered detached Windows host. Never admit a terminal/uncertain or
5398
- // native-dead child; do not refuse a native-alive ready host for a stale live bit.
5399
- await broker.heartbeatSessions();
5400
- await broker.index.refresh();
5497
+ // Native-alive owned readiness is the admission authority. The indexed
5498
+ // projection can lag a just-registered detached Windows host, so the
5499
+ // change-stamp refresh is deliberately separate from heartbeat liveness.
5500
+ // Never admit a terminal/uncertain or native-dead child.
5501
+ await broker.index.refreshIfChanged();
5401
5502
  const record = broker.index
5402
5503
  .listSessions()
5403
5504
  .sessions.find(
@@ -6650,6 +6751,7 @@ async function executeLifecycleResponse(
6650
6751
  terminationStartDeadline = childDeadlines.terminationStartDeadlineAt;
6651
6752
  lifecycleDeadline = childDeadlines.lifecycleCleanupDeadlineAt;
6652
6753
  }
6754
+ await retireExitedLifecycleMarkerPair(launch.root, launch.id);
6653
6755
  await reapDeadLifecycleMarkers(launch.root);
6654
6756
  } catch (error) {
6655
6757
  if (launch.worktreePlan && worktreeReceipt?.created && !worktreeReceipt.reused) {