@selesai/code 0.8.4 → 0.8.6

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 (147) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +7 -1
  3. package/dist/core/slash-commands.js +1 -0
  4. package/dist/defaults/models.json +46 -55
  5. package/dist/defaults/settings.json +7 -8
  6. package/dist/extensions/agent-browser.test.ts +178 -2
  7. package/dist/extensions/agent-browser.ts +2 -0
  8. package/dist/extensions/copy-turn.test.ts +229 -0
  9. package/dist/extensions/copy-turn.ts +11 -0
  10. package/dist/extensions/grep-app/index.test.ts +356 -1
  11. package/dist/extensions/grep-app/index.ts +6 -1
  12. package/dist/extensions/handoff-new.test.ts +351 -161
  13. package/dist/extensions/handoff-new.ts +3 -0
  14. package/dist/extensions/inline-skills.test.ts +93 -0
  15. package/dist/extensions/inline-skills.ts +3 -0
  16. package/dist/extensions/pi-subagents/CHANGELOG.md +26 -3
  17. package/dist/extensions/pi-subagents/README.md +2 -0
  18. package/dist/extensions/pi-subagents/agents/builder.md +5 -2
  19. package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
  20. package/dist/extensions/pi-subagents/docs/configuration.md +54 -2
  21. package/dist/extensions/pi-subagents/docs/observability.md +1 -1
  22. package/dist/extensions/pi-subagents/docs/tool-reference.md +1 -1
  23. package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
  24. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  25. package/dist/extensions/pi-subagents/package.json +1 -1
  26. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +1 -1
  27. package/dist/extensions/pi-subagents/src/agents/agents.ts +9 -3
  28. package/dist/extensions/pi-subagents/src/extension/config.ts +12 -0
  29. package/dist/extensions/pi-subagents/src/extension/doctor.ts +40 -0
  30. package/dist/extensions/pi-subagents/src/extension/index.ts +104 -14
  31. package/dist/extensions/pi-subagents/src/extension/public-execution.ts +5 -0
  32. package/dist/extensions/pi-subagents/src/extension/rpc.ts +3 -9
  33. package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
  34. package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -1
  35. package/dist/extensions/pi-subagents/src/missions/lifecycle.ts +4 -7
  36. package/dist/extensions/pi-subagents/src/missions/store.ts +4 -4
  37. package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +6 -2
  38. package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +374 -0
  39. package/dist/extensions/pi-subagents/src/runs/background/active-run-index.ts +9 -5
  40. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +117 -29
  41. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +12 -6
  42. package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -1
  43. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +14 -5
  44. package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +33 -15
  45. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
  46. package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +104 -0
  47. package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +17 -3
  48. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +19 -1
  49. package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +2 -2
  50. package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +5 -2
  51. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +12 -6
  52. package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -3
  53. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +84 -36
  54. package/dist/extensions/pi-subagents/src/runs/background/wait-subscriptions.ts +2 -1
  55. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +37 -2
  56. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +135 -22
  57. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +12 -0
  58. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +7 -4
  59. package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +171 -0
  60. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +653 -196
  61. package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +13 -4
  62. package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +286 -0
  63. package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +23 -0
  64. package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +2 -0
  65. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +44 -1
  66. package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +280 -0
  67. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +4 -2
  68. package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +19 -3
  69. package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +17 -5
  70. package/dist/extensions/pi-subagents/src/shared/session-lineage.ts +71 -0
  71. package/dist/extensions/pi-subagents/src/shared/types.ts +108 -0
  72. package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -1
  73. package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +7 -5
  74. package/dist/extensions/pi-subagents/src/tui/fleet.ts +226 -13
  75. package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +22 -5
  76. package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -0
  77. package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +87 -0
  78. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +96 -6
  79. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +28 -5
  80. package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +8 -5
  81. package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +83 -2
  82. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +211 -15
  83. package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +41 -3
  84. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +314 -10
  85. package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +31 -1
  86. package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +317 -0
  87. package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +21 -2
  88. package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +16 -1
  89. package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +6 -1
  90. package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +75 -4
  91. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +18 -0
  92. package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +29 -1
  93. package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +31 -5
  94. package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +139 -1
  95. package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
  96. package/dist/extensions/pi-subagents/test/unit/handoff-adoption.test.ts +103 -0
  97. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +22 -0
  98. package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +2 -1
  99. package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +171 -0
  100. package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +30 -4
  101. package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +14 -0
  102. package/dist/extensions/pi-subagents/test/unit/owned-process-tree.test.ts +69 -0
  103. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +83 -0
  104. package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +2 -2
  105. package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +55 -2
  106. package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +12 -1
  107. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +7 -7
  108. package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +121 -0
  109. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +9 -0
  110. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +4 -3
  111. package/dist/extensions/pi-subagents/test/unit/session-lineage.test.ts +73 -0
  112. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +28 -0
  113. package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +10 -1
  114. package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +39 -0
  115. package/dist/extensions/pi-subagents/test/unit/timeout-defaults.test.ts +65 -0
  116. package/dist/extensions/pi-subagents/test/unit/workflow-auto-relaunch.test.ts +45 -0
  117. package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +61 -0
  118. package/dist/extensions/ponytail/index.js +11 -0
  119. package/dist/extensions/ponytail/ponytail-config.cjs +2 -0
  120. package/dist/extensions/ponytail/ponytail-instructions.cjs +6 -0
  121. package/dist/extensions/ponytail/test/extension.test.js +274 -140
  122. package/dist/extensions/ponytail/test/helpers.test.js +280 -92
  123. package/dist/extensions/question/index.ts +18 -0
  124. package/dist/extensions/question/question-list.ts +22 -0
  125. package/dist/extensions/question/tests/batch.test.ts +103 -65
  126. package/dist/extensions/question/tests/helpers.test.ts +68 -35
  127. package/dist/extensions/question/tests/question-list.test.ts +506 -204
  128. package/dist/extensions/question/tests/row-layout.test.ts +180 -114
  129. package/dist/extensions/question/tests/schemas.test.ts +42 -19
  130. package/dist/extensions/question/tests/shortcuts.test.ts +106 -84
  131. package/dist/extensions/question/tests/wizard.test.ts +771 -50
  132. package/dist/extensions/rtk.test.ts +180 -1
  133. package/dist/extensions/tokenin-onboarding.ts +3 -0
  134. package/dist/extensions/undo.test.ts +720 -0
  135. package/dist/extensions/undo.ts +6 -0
  136. package/dist/extensions/web-agent-onboarding.test.ts +415 -0
  137. package/dist/extensions/workflow/extension.ts +3 -3
  138. package/dist/extensions/workflow/modes.ts +41 -19
  139. package/dist/modes/interactive/interactive-mode.d.ts +1 -0
  140. package/dist/modes/interactive/interactive-mode.js +48 -1
  141. package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
  142. package/docs/plans/workflow-autoloop-reference.md +1 -2
  143. package/docs/plans/workflow-handoff-carryover.md +108 -0
  144. package/docs/quickstart.md +27 -43
  145. package/docs/settings.md +4 -0
  146. package/docs/usage.md +1 -0
  147. package/package.json +5 -1
@@ -20,7 +20,6 @@ import {
20
20
  import { sanitizeDisplayText, truncateDisplayText } from "../shared/display-text.ts";
21
21
  import { readStatus } from "../shared/utils.ts";
22
22
  import { SubagentParams } from "./schemas.ts";
23
- import { formatWorkflowJsonPreview } from "../workflows/scripted-workflow.ts";
24
23
  import { normalizePublicSubagentExecution } from "./public-execution.ts";
25
24
 
26
25
  export const SUBAGENT_RPC_PROTOCOL_VERSION = 1;
@@ -92,6 +91,7 @@ export interface SubagentRpcFleetStatus {
92
91
  entries: SubagentRpcFleetEntry[];
93
92
  /** Total active children before the bounded entries window. */
94
93
  totalActive: number;
94
+ topLevelAsyncCapacity: { used: number; limit: number };
95
95
  omitted: number;
96
96
  }
97
97
 
@@ -155,7 +155,7 @@ function buildFleetStatus(
155
155
  }
156
156
  if (!state || !authoritativeSessionId || state.currentSessionId !== authoritativeSessionId) {
157
157
  keyState.keys.clear();
158
- return { version: 1, entries: [], totalActive: 0, omitted: 0 };
158
+ return { version: 1, entries: [], totalActive: 0, topLevelAsyncCapacity: { used: 0, limit: 0 }, omitted: 0 };
159
159
  }
160
160
 
161
161
  let totalActive = 0;
@@ -174,7 +174,6 @@ function buildFleetStatus(
174
174
  effort: child.thinking,
175
175
  startedAt: child.startedAt,
176
176
  tokens: { input: child.inputTokens ?? 0, output: child.outputTokens ?? 0, total: child.tokens ?? 0 },
177
- goal: child.description ?? control.description,
178
177
  });
179
178
  } else {
180
179
  addCandidate({
@@ -184,7 +183,6 @@ function buildFleetStatus(
184
183
  effort: control.thinking,
185
184
  startedAt: control.startedAt,
186
185
  tokens: { input: control.inputTokens ?? 0, output: control.outputTokens ?? 0, total: control.tokens ?? 0 },
187
- goal: control.description,
188
186
  });
189
187
  }
190
188
  }
@@ -192,13 +190,11 @@ function buildFleetStatus(
192
190
  if (job.sessionId !== authoritativeSessionId || !activeState(job.status)) continue;
193
191
  const startedAt = job.startedAt ?? job.updatedAt;
194
192
  if (job.mode === "workflow") {
195
- const latestEmit = job.workflow?.emits?.length ? formatWorkflowJsonPreview(job.workflow.emits.at(-1), 120) : undefined;
196
193
  addCandidate({
197
194
  internalKey: `async:${job.asyncId}`,
198
195
  agent: "workflow",
199
196
  startedAt,
200
197
  tokens: job.totalTokens,
201
- goal: latestEmit !== undefined ? `latest emit: ${latestEmit}` : job.description,
202
198
  });
203
199
  continue;
204
200
  }
@@ -215,7 +211,6 @@ function buildFleetStatus(
215
211
  agent: job.mode ?? "subagent",
216
212
  startedAt,
217
213
  tokens: job.totalTokens,
218
- goal: job.description,
219
214
  });
220
215
  continue;
221
216
  }
@@ -231,7 +226,6 @@ function buildFleetStatus(
231
226
  effort: step.thinking,
232
227
  startedAt: step.startedAt ?? startedAt,
233
228
  tokens: step.tokens ?? (steps.length === 1 ? job.totalTokens : undefined),
234
- goal: job.description,
235
229
  });
236
230
  }
237
231
  }
@@ -272,7 +266,7 @@ function buildFleetStatus(
272
266
  if (!activeKeys.has(internalKey)) keyState.keys.delete(internalKey);
273
267
  }
274
268
  const omitted = Math.max(0, totalActive - entries.length);
275
- return { version: 1, entries, totalActive, omitted };
269
+ return { version: 1, entries, totalActive, topLevelAsyncCapacity: state.activeAsyncCapacity ?? { used: 0, limit: 0 }, omitted };
276
270
  }
277
271
 
278
272
  interface RegisterSubagentRpcBridgeOptions {
@@ -337,8 +337,8 @@ const SubagentParamProperties = {
337
337
  })),
338
338
  async: Type.Optional(Type.Boolean({ description: "Run in background (default: false, or per config)" })),
339
339
  clarify: Type.Optional(Type.Boolean({ description: "Show TUI to preview/edit before execution. Explicit clarify: true keeps the run foreground for the clarify UI; omitted clarify can still run in the background when async: true is set." })),
340
- timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Run timeout. Foreground runs and async children default to 30m; async composites have no default parent deadline. Alias maxRuntimeMs." })),
341
- maxRuntimeMs: Type.Optional(Type.Integer({ minimum: 1, description: "Alias timeoutMs. Foreground runs and async children default to 30m; async composites have no default parent deadline." })),
340
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Timeout. Foreground and single async runs use config timeoutMs, else 30m; async composites have no default parent deadline. Alias maxRuntimeMs." })),
341
+ maxRuntimeMs: Type.Optional(Type.Integer({ minimum: 1, description: "Alias timeoutMs. Foreground and single async runs use config timeoutMs, else 30m; async composites have no default parent deadline." })),
342
342
  turnBudget: Type.Optional(TurnBudgetOverride),
343
343
  toolBudget: Type.Optional(ToolBudgetOverride),
344
344
  usageBudget: Type.Optional(UsageBudgetOverride),
@@ -63,7 +63,10 @@ export function resolveIntercomSessionTarget(sessionName: string | undefined, se
63
63
  if (trimmedName) return trimmedName;
64
64
  const fallbackSessionId = intercomSessionId?.trim() || sessionId;
65
65
  const normalizedSessionId = fallbackSessionId.startsWith("session-") ? fallbackSessionId.slice("session-".length) : fallbackSessionId;
66
- return `${DEFAULT_INTERCOM_TARGET_PREFIX}-${normalizedSessionId.slice(0, 8)}`;
66
+ // NOTE: keep slice length in sync with pi-intercom's resolveIntercomPresenceName
67
+ // (index.ts: DEFAULT_UNNAMED_SESSION_ALIAS_PREFIX + slice(0, 18)); mismatched lengths
68
+ // make fallback orchestrator targets unresolvable ("Session not found").
69
+ return `${DEFAULT_INTERCOM_TARGET_PREFIX}-${normalizedSessionId.slice(0, 18)}`;
67
70
  }
68
71
 
69
72
  function sanitizeIntercomTargetPart(value: string): string {
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
4
  import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
5
+ import { PROMPT_REDACTED } from "../shared/utils.ts";
5
6
  import type { Details, SubagentRunMode } from "../shared/types.ts";
6
7
  import { validateMissionLaunch } from "./actions.ts";
7
8
  import type { MissionArtifact, MissionRecord, MissionRunLink, MissionRunMode, MissionStatus, MissionStoreConfig, MissionStoreLocation } from "./types.ts";
@@ -47,11 +48,6 @@ function workflowObjective(params: MissionLaunchParams): string | undefined {
47
48
  return undefined;
48
49
  }
49
50
 
50
- function conciseTitle(objective: string): string {
51
- const firstLine = objective.split(/\r?\n/, 1)[0]?.trim() || objective.trim();
52
- return firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
53
- }
54
-
55
51
  export function prepareMissionLaunch(input: {
56
52
  params: MissionLaunchParams;
57
53
  projectRoot: string;
@@ -73,10 +69,11 @@ export function prepareMissionLaunch(input: {
73
69
  return { missionId, location, autoCreated: false, announceInContent: true };
74
70
  }
75
71
  const mission = input.params.mission !== undefined ? validateMissionLaunch(input.params.mission) : undefined;
76
- const title = mission?.title || conciseTitle(objective!);
72
+ const promptDerivedObjective = mission?.objective ?? (mission ? mission.title : objective ? PROMPT_REDACTED : undefined);
73
+ const title = mission?.title || PROMPT_REDACTED;
77
74
  const record = createMission(location, {
78
75
  title,
79
- objective: mission?.objective || objective || title,
76
+ objective: promptDerivedObjective || title,
80
77
  ...(mission?.goal === true ? { goal: true as const } : {}),
81
78
  ...(mission?.budget ? { budget: mission.budget } : {}),
82
79
  status: "active",
@@ -396,11 +396,11 @@ export class MissionNotFoundError extends Error {
396
396
  readonly missionId: string;
397
397
  readonly missionDir: string;
398
398
 
399
- constructor(missionId: string, missionDir: string) {
400
- super(`Mission '${missionId}' was not found in ${missionDir}`);
399
+ constructor(missionId: string, location: MissionStoreLocation) {
400
+ super(`Mission '${missionId}' was not found in mission directory '${location.missionDir}' for project root '${location.projectRoot}'. If it was created in another worktree, run the request from that worktree.`);
401
401
  this.name = "MissionNotFoundError";
402
402
  this.missionId = missionId;
403
- this.missionDir = missionDir;
403
+ this.missionDir = location.missionDir;
404
404
  }
405
405
  }
406
406
 
@@ -410,7 +410,7 @@ export function readMission(location: MissionStoreLocation, missionId: string):
410
410
  try {
411
411
  raw = fs.readFileSync(filePath, "utf-8");
412
412
  } catch (error) {
413
- if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new MissionNotFoundError(missionId, location.missionDir);
413
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new MissionNotFoundError(missionId, location);
414
414
  throw error;
415
415
  }
416
416
  try {
@@ -95,8 +95,12 @@ function stateLockIsStale(lockPath: string, now = Date.now()): boolean {
95
95
  const owner = readStateLockOwner(lockPath);
96
96
  if (owner) {
97
97
  if (!isProcessAlive(owner.pid)) return true;
98
- const currentProcessKey = processStartKey(owner.pid);
99
- return Boolean(owner.processKey && currentProcessKey && owner.processKey !== currentProcessKey);
98
+ if (owner.processKey) {
99
+ const currentProcessKey = owner.pid === process.pid ? CURRENT_PROCESS_KEY : processStartKey(owner.pid);
100
+ if (currentProcessKey) return owner.processKey !== currentProcessKey;
101
+ if (owner.pid === process.pid) return true;
102
+ }
103
+ return false;
100
104
  }
101
105
  try {
102
106
  return now - fs.statSync(lockPath).mtimeMs > STATE_LOCK_STALE_MS;
@@ -0,0 +1,374 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
5
+ import { TEMP_ROOT_DIR, type ActiveAsyncCapacitySnapshot, type AsyncStatus } from "../../shared/types.ts";
6
+ import { readStatus } from "../../shared/utils.ts";
7
+ import { readProcessTerminal } from "./process-terminal.ts";
8
+
9
+ export const ACTIVE_ASYNC_CAPACITY_DIR = path.join(TEMP_ROOT_DIR, "session-active-async-capacity");
10
+
11
+ export interface ActiveAsyncCapacityOwnerV1 {
12
+ version: 1;
13
+ reservationToken: string;
14
+ ownerSessionId: string;
15
+ ownerSessionKey: string;
16
+ slot: number;
17
+ runId: string;
18
+ sourceRunId?: string;
19
+ generation: number;
20
+ kind: "runner" | "workflow";
21
+ asyncDir: string;
22
+ reservedAt: number;
23
+ runnerProcessInstanceId?: string;
24
+ runnerStartedAt?: number;
25
+ }
26
+
27
+ export interface ActiveAsyncCapacityHandle {
28
+ readonly owner: ActiveAsyncCapacityOwnerV1;
29
+ markStarted(runnerProcessInstanceId: string): void;
30
+ markWorkflowStarted(): void;
31
+ rollback(): boolean;
32
+ reconcile(liveWorkflowRunIds?: ReadonlySet<string>): ActiveAsyncCapacitySnapshot;
33
+ }
34
+
35
+ interface CapacityOptions {
36
+ rootDir?: string;
37
+ now?: () => number;
38
+ token?: () => string;
39
+ afterSlotRename?: (releasedDir: string) => void;
40
+ }
41
+
42
+ export class ActiveAsyncCapacityError extends Error {
43
+ readonly snapshot: ActiveAsyncCapacitySnapshot;
44
+
45
+ constructor(snapshot: ActiveAsyncCapacitySnapshot) {
46
+ super(`Active async run capacity exhausted: ${snapshot.used}/${snapshot.limit} used.`);
47
+ this.name = "ActiveAsyncCapacityError";
48
+ this.snapshot = snapshot;
49
+ }
50
+ }
51
+
52
+ export function resolveMaxActiveAsyncRunsPerSession(value: unknown): number | undefined {
53
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return undefined;
54
+ return value === 0 ? undefined : value;
55
+ }
56
+
57
+ export function activeAsyncCapacitySessionKey(sessionId: string): string {
58
+ return createHash("sha256").update(sessionId).digest("hex");
59
+ }
60
+
61
+ function sessionDir(sessionId: string, rootDir: string): string {
62
+ return path.join(rootDir, activeAsyncCapacitySessionKey(sessionId));
63
+ }
64
+
65
+ function slotDir(poolDir: string, slot: number): string {
66
+ return path.join(poolDir, `slot-${slot}`);
67
+ }
68
+
69
+ function parseOwner(value: unknown): ActiveAsyncCapacityOwnerV1 | undefined {
70
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
71
+ const owner = value as Partial<ActiveAsyncCapacityOwnerV1>;
72
+ if (owner.version !== 1
73
+ || typeof owner.reservationToken !== "string" || !owner.reservationToken
74
+ || typeof owner.ownerSessionId !== "string" || !owner.ownerSessionId
75
+ || typeof owner.ownerSessionKey !== "string" || !owner.ownerSessionKey
76
+ || typeof owner.slot !== "number" || !Number.isInteger(owner.slot) || owner.slot < 0
77
+ || typeof owner.runId !== "string" || !owner.runId
78
+ || typeof owner.generation !== "number" || !Number.isInteger(owner.generation) || owner.generation < 0
79
+ || (owner.kind !== "runner" && owner.kind !== "workflow")
80
+ || typeof owner.asyncDir !== "string" || !owner.asyncDir
81
+ || typeof owner.reservedAt !== "number" || !Number.isFinite(owner.reservedAt)) return undefined;
82
+ if (owner.sourceRunId !== undefined && typeof owner.sourceRunId !== "string") return undefined;
83
+ if (owner.runnerProcessInstanceId !== undefined && (typeof owner.runnerProcessInstanceId !== "string" || !owner.runnerProcessInstanceId)) return undefined;
84
+ if (owner.runnerStartedAt !== undefined && (typeof owner.runnerStartedAt !== "number" || !Number.isFinite(owner.runnerStartedAt))) return undefined;
85
+ return owner as ActiveAsyncCapacityOwnerV1;
86
+ }
87
+
88
+ function readOwner(dir: string): ActiveAsyncCapacityOwnerV1 | undefined {
89
+ try {
90
+ return parseOwner(JSON.parse(fs.readFileSync(path.join(dir, "owner.json"), "utf-8")));
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ function occupiedSlots(poolDir: string): string[] {
97
+ try {
98
+ return fs.readdirSync(poolDir, { withFileTypes: true })
99
+ .filter((entry) => entry.isDirectory() && /^slot-\d+$/.test(entry.name))
100
+ .map((entry) => path.join(poolDir, entry.name));
101
+ } catch (error) {
102
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ function snapshotFor(sessionId: string, limit: number | undefined, rootDir: string): ActiveAsyncCapacitySnapshot {
108
+ return { used: occupiedSlots(sessionDir(sessionId, rootDir)).length, limit: limit ?? 0 };
109
+ }
110
+
111
+ function matchingOwner(dir: string, expected: ActiveAsyncCapacityOwnerV1): ActiveAsyncCapacityOwnerV1 | undefined {
112
+ const owner = readOwner(dir);
113
+ return owner?.reservationToken === expected.reservationToken
114
+ && owner.runId === expected.runId
115
+ && owner.generation === expected.generation
116
+ ? owner
117
+ : undefined;
118
+ }
119
+
120
+ function withSlotClaim<T>(dir: string, operation: () => T): { acquired: true; value: T } | { acquired: false } {
121
+ const claimPath = path.join(dir, "capacity.claim");
122
+ const claimToken = randomUUID();
123
+ let claim: number | undefined;
124
+ try {
125
+ claim = fs.openSync(claimPath, "wx", 0o600);
126
+ fs.writeFileSync(claim, claimToken, "utf-8");
127
+ fs.closeSync(claim);
128
+ claim = undefined;
129
+ } catch (error) {
130
+ if (claim !== undefined) fs.closeSync(claim);
131
+ if ((error as NodeJS.ErrnoException).code === "EEXIST" || (error as NodeJS.ErrnoException).code === "ENOENT") return { acquired: false };
132
+ throw error;
133
+ }
134
+ try {
135
+ return { acquired: true, value: operation() };
136
+ } finally {
137
+ try {
138
+ if (fs.readFileSync(claimPath, "utf-8") === claimToken) fs.rmSync(claimPath, { force: true });
139
+ } catch (error) {
140
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
141
+ }
142
+ }
143
+ }
144
+
145
+ function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwnerV1, options: CapacityOptions, requireUnstarted = false): boolean {
146
+ if (requireUnstarted && (expected.runnerProcessInstanceId || expected.runnerStartedAt)) return false;
147
+ const claimed = withSlotClaim(dir, () => {
148
+ const current = matchingOwner(dir, expected);
149
+ if (!current || (requireUnstarted && (current.runnerProcessInstanceId || current.runnerStartedAt))) return false;
150
+ const releasedDir = path.join(path.dirname(dir), `.${path.basename(dir)}.released-${randomUUID()}`);
151
+ fs.renameSync(dir, releasedDir);
152
+ options.afterSlotRename?.(releasedDir);
153
+ fs.rmSync(releasedDir, { recursive: true, force: true });
154
+ return true;
155
+ });
156
+ return claimed.acquired && claimed.value;
157
+ }
158
+
159
+ function terminalState(state: AsyncStatus["state"]): boolean {
160
+ return state !== "queued" && state !== "running" && state !== "paused";
161
+ }
162
+
163
+ function runnerCanRelease(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus): boolean {
164
+ if (!owner.runnerProcessInstanceId
165
+ || status.sessionId !== owner.ownerSessionId
166
+ || status.runId !== owner.runId
167
+ || !terminalState(status.state)) return false;
168
+ if (status.processTerminal?.state === "not-started"
169
+ && status.processTerminal.runId === owner.runId
170
+ && status.processTerminal.runnerProcessInstanceId === owner.runnerProcessInstanceId
171
+ && typeof status.error === "string"
172
+ && status.error) return true;
173
+ const proof = readProcessTerminal(owner.asyncDir, {
174
+ runId: owner.runId,
175
+ runnerProcessInstanceId: owner.runnerProcessInstanceId,
176
+ });
177
+ return proof?.state === "observed"
178
+ && proof.runId === owner.runId
179
+ && proof.runnerProcessInstanceId === owner.runnerProcessInstanceId;
180
+ }
181
+
182
+ function workflowCanRelease(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus, liveWorkflowRunIds: ReadonlySet<string>): boolean {
183
+ if (status.sessionId !== owner.ownerSessionId
184
+ || status.runId !== owner.runId
185
+ || status.mode !== "workflow"
186
+ || !terminalState(status.state)
187
+ || liveWorkflowRunIds.has(owner.runId)) return false;
188
+ for (const step of status.steps ?? []) {
189
+ if (step.status === "pending" || step.status === "running" || step.status === "paused") return false;
190
+ if (typeof step.async !== "boolean") return false;
191
+ if (!step.async) continue;
192
+ if (!step.runId) return false;
193
+ const childDir = path.join(path.dirname(owner.asyncDir), step.runId);
194
+ if (!fs.existsSync(childDir)) return false;
195
+ const childStatus = readStatus(childDir);
196
+ if (!childStatus || !terminalState(childStatus.state) || !childStatus.processTerminal?.runnerProcessInstanceId) return false;
197
+ const proof = readProcessTerminal(childDir, {
198
+ runId: step.runId,
199
+ runnerProcessInstanceId: childStatus.processTerminal.runnerProcessInstanceId,
200
+ });
201
+ if (proof?.state !== "observed" || proof.runId !== step.runId) return false;
202
+ }
203
+ return true;
204
+ }
205
+
206
+ function ownerCanRelease(owner: ActiveAsyncCapacityOwnerV1, liveWorkflowRunIds: ReadonlySet<string>): boolean {
207
+ const status = readStatus(owner.asyncDir);
208
+ if (!status) return false;
209
+ return owner.kind === "runner"
210
+ ? runnerCanRelease(owner, status)
211
+ : workflowCanRelease(owner, status, liveWorkflowRunIds);
212
+ }
213
+
214
+ export function reconcileActiveAsyncCapacity(
215
+ sessionId: string,
216
+ limit: number | undefined,
217
+ options: CapacityOptions & { liveWorkflowRunIds?: ReadonlySet<string> } = {},
218
+ ): ActiveAsyncCapacitySnapshot {
219
+ const rootDir = options.rootDir ?? ACTIVE_ASYNC_CAPACITY_DIR;
220
+ const poolDir = sessionDir(sessionId, rootDir);
221
+ const liveWorkflowRunIds = options.liveWorkflowRunIds ?? new Set<string>();
222
+ for (const dir of occupiedSlots(poolDir)) {
223
+ const owner = readOwner(dir);
224
+ if (!owner
225
+ || owner.ownerSessionId !== sessionId
226
+ || owner.ownerSessionKey !== activeAsyncCapacitySessionKey(sessionId)
227
+ || path.basename(dir) !== `slot-${owner.slot}`
228
+ || !ownerCanRelease(owner, liveWorkflowRunIds)) continue;
229
+ removeOwnedSlot(dir, owner, options);
230
+ }
231
+ return snapshotFor(sessionId, limit, rootDir);
232
+ }
233
+
234
+ export function getActiveAsyncCapacitySnapshot(
235
+ sessionId: string,
236
+ limit: number | undefined,
237
+ options: CapacityOptions & { liveWorkflowRunIds?: ReadonlySet<string> } = {},
238
+ ): ActiveAsyncCapacitySnapshot {
239
+ return reconcileActiveAsyncCapacity(sessionId, limit, options);
240
+ }
241
+
242
+ function createSlot(poolDir: string, owner: ActiveAsyncCapacityOwnerV1): boolean {
243
+ const destination = slotDir(poolDir, owner.slot);
244
+ fs.mkdirSync(poolDir, { recursive: true, mode: 0o700 });
245
+ try {
246
+ fs.mkdirSync(destination, { mode: 0o700 });
247
+ } catch (error) {
248
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
249
+ throw error;
250
+ }
251
+ // If owner persistence fails, the corrupt occupied directory remains and
252
+ // fails closed instead of becoming available to another admission.
253
+ fs.writeFileSync(path.join(destination, "owner.json"), `${JSON.stringify(owner, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
254
+ return true;
255
+ }
256
+
257
+ function handleFor(owner: ActiveAsyncCapacityOwnerV1, limit: number, options: CapacityOptions, rollbackOwner?: ActiveAsyncCapacityOwnerV1): ActiveAsyncCapacityHandle {
258
+ const rootDir = options.rootDir ?? ACTIVE_ASYNC_CAPACITY_DIR;
259
+ const dir = slotDir(sessionDir(owner.ownerSessionId, rootDir), owner.slot);
260
+ return {
261
+ owner,
262
+ markStarted(runnerProcessInstanceId) {
263
+ const claimed = withSlotClaim(dir, () => {
264
+ const current = matchingOwner(dir, owner);
265
+ if (!current) return false;
266
+ const next = { ...current, runnerProcessInstanceId, runnerStartedAt: options.now?.() ?? Date.now() };
267
+ // Mark memory first. If persistence fails after the process starts, caller
268
+ // cleanup must retain the occupied slot instead of rolling it back.
269
+ Object.assign(owner, next);
270
+ try {
271
+ writePrivateAtomicJson(path.join(dir, "owner.json"), next);
272
+ } catch (error) {
273
+ console.error(`Failed to bind active async capacity to runner '${runnerProcessInstanceId}'; capacity will remain occupied:`, error);
274
+ }
275
+ return true;
276
+ });
277
+ if (!claimed.acquired || !claimed.value) throw new Error(`Active async capacity ownership changed for run '${owner.runId}'.`);
278
+ },
279
+ markWorkflowStarted() {
280
+ const claimed = withSlotClaim(dir, () => {
281
+ const current = matchingOwner(dir, owner);
282
+ if (!current || current.kind !== "workflow") return false;
283
+ const next = { ...current, runnerStartedAt: options.now?.() ?? Date.now() };
284
+ Object.assign(owner, next);
285
+ try {
286
+ writePrivateAtomicJson(path.join(dir, "owner.json"), next);
287
+ } catch (error) {
288
+ console.error(`Failed to mark async workflow '${owner.runId}' started; capacity will remain occupied:`, error);
289
+ }
290
+ return true;
291
+ });
292
+ if (!claimed.acquired || !claimed.value) throw new Error(`Active async capacity ownership changed for workflow '${owner.runId}'.`);
293
+ },
294
+ rollback() {
295
+ if (!rollbackOwner) return removeOwnedSlot(dir, owner, options, true);
296
+ if (owner.runnerProcessInstanceId || owner.runnerStartedAt) return false;
297
+ const claimed = withSlotClaim(dir, () => {
298
+ const current = matchingOwner(dir, owner);
299
+ if (!current || current.runnerProcessInstanceId || current.runnerStartedAt) return false;
300
+ writePrivateAtomicJson(path.join(dir, "owner.json"), rollbackOwner);
301
+ Object.assign(owner, rollbackOwner);
302
+ return true;
303
+ });
304
+ return claimed.acquired && claimed.value;
305
+ },
306
+ reconcile(liveWorkflowRunIds) {
307
+ return reconcileActiveAsyncCapacity(owner.ownerSessionId, limit, { ...options, rootDir, liveWorkflowRunIds });
308
+ },
309
+ };
310
+ }
311
+
312
+ export function acquireActiveAsyncCapacity(
313
+ input: { sessionId: string; limit: number | undefined; runId: string; kind: "runner" | "workflow"; asyncDir: string },
314
+ options: CapacityOptions & { liveWorkflowRunIds?: ReadonlySet<string> } = {},
315
+ ): ActiveAsyncCapacityHandle | undefined {
316
+ if (input.limit === undefined) return undefined;
317
+ const rootDir = options.rootDir ?? ACTIVE_ASYNC_CAPACITY_DIR;
318
+ const reconciled = reconcileActiveAsyncCapacity(input.sessionId, input.limit, options);
319
+ if (reconciled.used >= input.limit) throw new ActiveAsyncCapacityError(reconciled);
320
+ const poolDir = sessionDir(input.sessionId, rootDir);
321
+ const token = options.token?.() ?? randomUUID();
322
+ for (let slot = 0; slot < input.limit; slot++) {
323
+ const owner: ActiveAsyncCapacityOwnerV1 = {
324
+ version: 1,
325
+ reservationToken: token,
326
+ ownerSessionId: input.sessionId,
327
+ ownerSessionKey: activeAsyncCapacitySessionKey(input.sessionId),
328
+ slot,
329
+ runId: input.runId,
330
+ generation: 0,
331
+ kind: input.kind,
332
+ asyncDir: input.asyncDir,
333
+ reservedAt: options.now?.() ?? Date.now(),
334
+ };
335
+ if (createSlot(poolDir, owner)) return handleFor(owner, input.limit, { ...options, rootDir });
336
+ }
337
+ throw new ActiveAsyncCapacityError(snapshotFor(input.sessionId, input.limit, rootDir));
338
+ }
339
+
340
+ export function transferActiveAsyncCapacity(
341
+ input: { sessionId: string; limit: number | undefined; sourceRunId: string; runId: string; asyncDir: string },
342
+ options: CapacityOptions = {},
343
+ ): ActiveAsyncCapacityHandle | undefined {
344
+ const limit = input.limit ?? 0;
345
+ const rootDir = options.rootDir ?? ACTIVE_ASYNC_CAPACITY_DIR;
346
+ const poolDir = sessionDir(input.sessionId, rootDir);
347
+ for (const dir of occupiedSlots(poolDir)) {
348
+ const source = readOwner(dir);
349
+ if (!source || source.ownerSessionId !== input.sessionId || source.runId !== input.sourceRunId) continue;
350
+ const claimed = withSlotClaim(dir, () => {
351
+ const current = matchingOwner(dir, source);
352
+ const status = readStatus(source.asyncDir);
353
+ if (!current || !status || status.runId !== input.sourceRunId || status.state === "queued" || status.state === "running") {
354
+ throw new Error(`Active async capacity source '${input.sourceRunId}' is not transferable.`);
355
+ }
356
+ const next: ActiveAsyncCapacityOwnerV1 = {
357
+ ...current,
358
+ runId: input.runId,
359
+ sourceRunId: input.sourceRunId,
360
+ generation: current.generation + 1,
361
+ kind: "runner",
362
+ asyncDir: input.asyncDir,
363
+ reservedAt: options.now?.() ?? Date.now(),
364
+ };
365
+ delete next.runnerProcessInstanceId;
366
+ delete next.runnerStartedAt;
367
+ writePrivateAtomicJson(path.join(dir, "owner.json"), next);
368
+ return handleFor(next, limit, { ...options, rootDir }, current);
369
+ });
370
+ if (!claimed.acquired) throw new Error(`Active async capacity transfer is already in progress for run '${input.sourceRunId}'.`);
371
+ return claimed.value;
372
+ }
373
+ return acquireActiveAsyncCapacity({ sessionId: input.sessionId, limit: input.limit, runId: input.runId, kind: "runner", asyncDir: input.asyncDir }, options);
374
+ }
@@ -16,6 +16,14 @@ export function isActiveAsyncState(state: AsyncStatus["state"]): boolean {
16
16
  return state === "queued" || state === "running";
17
17
  }
18
18
 
19
+ export function releaseActiveRunIndex(asyncDir: string): void {
20
+ try {
21
+ fs.rmSync(markerPath(asyncDir));
22
+ } catch (error) {
23
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
24
+ }
25
+ }
26
+
19
27
  export function updateActiveRunIndex(asyncDir: string, state: AsyncStatus["state"]): void {
20
28
  const marker = markerPath(asyncDir);
21
29
  if (isActiveAsyncState(state)) {
@@ -23,11 +31,7 @@ export function updateActiveRunIndex(asyncDir: string, state: AsyncStatus["state
23
31
  fs.writeFileSync(marker, "", { flag: "a" });
24
32
  return;
25
33
  }
26
- try {
27
- fs.rmSync(marker);
28
- } catch (error) {
29
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
30
- }
34
+ releaseActiveRunIndex(asyncDir);
31
35
  }
32
36
 
33
37
  export function readActiveRunIndex(asyncDirRoot: string): string[] | undefined {