@made-by-moonlight/athene-core 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/agent-workspace-hooks.d.ts +3 -3
  2. package/dist/agent-workspace-hooks.d.ts.map +1 -1
  3. package/dist/agent-workspace-hooks.js +22 -21
  4. package/dist/agent-workspace-hooks.js.map +1 -1
  5. package/dist/config.d.ts +19 -1
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +116 -11
  8. package/dist/config.js.map +1 -1
  9. package/dist/env.d.ts +157 -0
  10. package/dist/env.d.ts.map +1 -0
  11. package/dist/env.js +187 -0
  12. package/dist/env.js.map +1 -0
  13. package/dist/events-db.d.ts.map +1 -1
  14. package/dist/events-db.js +2 -1
  15. package/dist/events-db.js.map +1 -1
  16. package/dist/feature-flags.d.ts.map +1 -1
  17. package/dist/feature-flags.js +3 -1
  18. package/dist/feature-flags.js.map +1 -1
  19. package/dist/gh-trace.d.ts.map +1 -1
  20. package/dist/gh-trace.js +4 -3
  21. package/dist/gh-trace.js.map +1 -1
  22. package/dist/global-config.d.ts +29 -8
  23. package/dist/global-config.d.ts.map +1 -1
  24. package/dist/global-config.js +13 -4
  25. package/dist/global-config.js.map +1 -1
  26. package/dist/index.d.ts +9 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +8 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/lifecycle-manager.d.ts.map +1 -1
  31. package/dist/lifecycle-manager.js +101 -1
  32. package/dist/lifecycle-manager.js.map +1 -1
  33. package/dist/meta-orchestrator-config-writer.d.ts +13 -0
  34. package/dist/meta-orchestrator-config-writer.d.ts.map +1 -0
  35. package/dist/meta-orchestrator-config-writer.js +22 -0
  36. package/dist/meta-orchestrator-config-writer.js.map +1 -0
  37. package/dist/meta-orchestrator-prompt.d.ts +16 -0
  38. package/dist/meta-orchestrator-prompt.d.ts.map +1 -0
  39. package/dist/meta-orchestrator-prompt.js +87 -0
  40. package/dist/meta-orchestrator-prompt.js.map +1 -0
  41. package/dist/meta-scope.d.ts +38 -0
  42. package/dist/meta-scope.d.ts.map +1 -0
  43. package/dist/meta-scope.js +47 -0
  44. package/dist/meta-scope.js.map +1 -0
  45. package/dist/observability.d.ts.map +1 -1
  46. package/dist/observability.js +3 -2
  47. package/dist/observability.js.map +1 -1
  48. package/dist/paths.d.ts +18 -0
  49. package/dist/paths.d.ts.map +1 -1
  50. package/dist/paths.js +37 -1
  51. package/dist/paths.js.map +1 -1
  52. package/dist/platform.d.ts.map +1 -1
  53. package/dist/platform.js +4 -3
  54. package/dist/platform.js.map +1 -1
  55. package/dist/prompt-builder.d.ts +2 -2
  56. package/dist/prompt-builder.d.ts.map +1 -1
  57. package/dist/prompt-builder.js +2 -2
  58. package/dist/prompts/meta-orchestrator.md.js +4 -0
  59. package/dist/prompts/meta-orchestrator.md.js.map +1 -0
  60. package/dist/prompts/orchestrator.md.js +1 -1
  61. package/dist/prompts/orchestrator.md.js.map +1 -1
  62. package/dist/runtime-orphans.d.ts +96 -0
  63. package/dist/runtime-orphans.d.ts.map +1 -0
  64. package/dist/runtime-orphans.js +158 -0
  65. package/dist/runtime-orphans.js.map +1 -0
  66. package/dist/session-manager.d.ts +16 -0
  67. package/dist/session-manager.d.ts.map +1 -1
  68. package/dist/session-manager.js +611 -46
  69. package/dist/session-manager.js.map +1 -1
  70. package/dist/spawn-collision.d.ts +23 -0
  71. package/dist/spawn-collision.d.ts.map +1 -0
  72. package/dist/spawn-collision.js +26 -0
  73. package/dist/spawn-collision.js.map +1 -0
  74. package/dist/types.d.ts +103 -1
  75. package/dist/types.d.ts.map +1 -1
  76. package/dist/types.js +22 -1
  77. package/dist/types.js.map +1 -1
  78. package/package.json +10 -9
  79. package/LICENSE +0 -22
@@ -1,4 +1,4 @@
1
- import { existsSync, statSync, mkdirSync, writeFileSync, unlinkSync, utimesSync } from 'node:fs';
1
+ import { existsSync, statSync, mkdirSync, writeFileSync, utimesSync, unlinkSync, openSync, rmSync, closeSync, readFileSync } from 'node:fs';
2
2
  import { recordActivityEvent } from './activity-events.js';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { join, resolve, basename } from 'node:path';
@@ -9,7 +9,7 @@ import { listMetadata, readMetadataRaw, updateMetadata, mutateMetadata, deleteMe
9
9
  import { deriveLegacyStatus, parseCanonicalLifecycle, cloneLifecycle, buildLifecycleMetadataPatch, clearTerminalMarkersForNonTerminalState, createInitialCanonicalLifecycle } from './lifecycle-state.js';
10
10
  import { buildPrompt } from './prompt-builder.js';
11
11
  import { createActivitySignal, classifyActivitySignal } from './activity-signal.js';
12
- import { getProjectSessionsDir, getProjectWorktreesDir, getProjectDir, generateSessionName } from './paths.js';
12
+ import { getProjectSessionsDir, getProjectWorktreesDir, getProjectDir, getMetaSessionsDir, getMetaWorkspaceDir, generateSessionName } from './paths.js';
13
13
  import { asValidOpenCodeSessionId } from './opencode-session-id.js';
14
14
  import { getOpenCodeChildEnv, invalidateOpenCodeSessionListCache, getCachedOpenCodeSessionList } from './opencode-shared.js';
15
15
  import { writeWorkspaceOpenCodeAgentsMd } from './opencode-agents-md.js';
@@ -20,8 +20,10 @@ import { sessionFromMetadata } from './utils/session-from-metadata.js';
20
20
  import { dedupePrUrls } from './utils/pr.js';
21
21
  import { safeJsonParse, validateStatus } from './utils/validation.js';
22
22
  import { isGitBranchNameSafe } from './utils.js';
23
+ import { checkSpawnCollision, formatHardRefusal } from './spawn-collision.js';
23
24
  import { resolveAgentSelectionForSession, resolveAgentSelection } from './agent-selection.js';
24
25
  import { PREFERRED_GH_PATH, buildAgentPath, setupPathWrapperWorkspace } from './agent-workspace-hooks.js';
26
+ import { withLegacyEnvAliases, ENV, getEnvString } from './env.js';
25
27
 
26
28
  /**
27
29
  * Session Manager — CRUD for agent sessions.
@@ -199,6 +201,133 @@ const ENSURE_ORCHESTRATOR_CONFLICT_POLL_MS = 250;
199
201
  function sleep(ms) {
200
202
  return new Promise((resolve) => setTimeout(resolve, ms));
201
203
  }
204
+ /**
205
+ * Read a project's sessions directly from metadata files — NO enrichment (no
206
+ * runtime liveness probes, no agent getSessionInfo execFile calls). Used by the
207
+ * spawn collision guard so the per-project spawn lock is held only for a brief,
208
+ * disk-only window, and so the read reflects in-flight issue claims immediately.
209
+ */
210
+ function listProjectSessionsFromDisk(projectId) {
211
+ const dir = getProjectSessionsDir(projectId);
212
+ const sessions = [];
213
+ for (const id of listMetadata(dir)) {
214
+ const raw = readMetadataRaw(dir, id);
215
+ if (!raw)
216
+ continue;
217
+ sessions.push(sessionFromMetadata(id, raw, { projectId }));
218
+ }
219
+ return sessions;
220
+ }
221
+ /**
222
+ * Whether a session is TRULY terminal for spawn-dedup purposes: only
223
+ * done/terminated (covers manually_killed) or a merged PR. A runtime-lost /
224
+ * `detecting` session is a PENDING decision (#1735), NOT terminal — its in-memory
225
+ * enriched view may show runtime.state==='missing', but on disk it is `detecting`
226
+ * and it still OWNS its issue, so it must keep blocking a duplicate same-issue
227
+ * spawn for BOTH coordinators. Deliberately does not use `isTerminalSession`,
228
+ * which treats runtime.state==='missing'/'exited' as terminal.
229
+ */
230
+ function isSpawnTerminal(session) {
231
+ const state = session.lifecycle.session.state;
232
+ return state === "done" || state === "terminated" || session.lifecycle.pr.state === "merged";
233
+ }
234
+ /**
235
+ * Cross-platform process-liveness probe via signal 0. `process.kill(pid, 0)` is
236
+ * portable (Windows included). Per docs/CROSS_PLATFORM.md: a throw with code
237
+ * `ESRCH` means the process is gone (dead); `EPERM` means it EXISTS but we can't
238
+ * signal it (alive). Any other throw is treated as not-alive.
239
+ */
240
+ function isPidAlive(pid) {
241
+ if (!Number.isInteger(pid) || pid <= 0)
242
+ return false;
243
+ try {
244
+ process.kill(pid, 0);
245
+ return true;
246
+ }
247
+ catch (err) {
248
+ return err.code === "EPERM";
249
+ }
250
+ }
251
+ /**
252
+ * Backstop for a spawn.lock whose holder PID can't be read/parsed (legacy format,
253
+ * mid-write, or corruption) — only such a lock is reaped by age, and only after a
254
+ * long window no real disk-only critical section could ever reach. A lock with a
255
+ * readable PID is reaped ONLY when that PID is dead, never by age, so an
256
+ * alive-but-slow holder is never reaped.
257
+ */
258
+ /**
259
+ * Absolute age ceiling for a spawn.lock. The in-lock critical section is
260
+ * disk-only (no network/runtime work — the git ls-remote is hoisted out), so it
261
+ * never legitimately runs anywhere near this long. Any lock older than this is
262
+ * reapable regardless of holder PID — this clears both a corrupt/no-PID lock and
263
+ * a PID-bearing lock whose SIGKILLed holder's PID was recycled to an unrelated
264
+ * live process (which would otherwise look "alive" forever and block spawns).
265
+ */
266
+ const SPAWN_LOCK_MAX_AGE_MS = 300_000;
267
+ /**
268
+ * Per-acquisition owner token written into spawn.lock: `${pid}:${nonce}`. The pid
269
+ * prefix drives liveness reaping; the nonce makes the token UNIQUE per
270
+ * acquisition (pid alone is not — PIDs are reused), so release can verify the
271
+ * lock is still ours and never delete one re-acquired by another process.
272
+ */
273
+ let spawnLockNonceCounter = 0;
274
+ function nextSpawnLockToken() {
275
+ return `${process.pid}:${process.hrtime.bigint().toString(36)}-${(spawnLockNonceCounter++).toString(36)}`;
276
+ }
277
+ /** Extract the holder PID from a lock token (`${pid}:${nonce}` or a bare pid). */
278
+ function spawnLockPid(content) {
279
+ const pidStr = content.split(":", 1)[0] ?? "";
280
+ const pid = Number.parseInt(pidStr, 10);
281
+ return Number.isInteger(pid) && pid > 0 && String(pid) === pidStr ? pid : null;
282
+ }
283
+ /**
284
+ * Whether a waiter may reap an existing spawn.lock:
285
+ * - over the absolute age ceiling → always reapable (any lock); or
286
+ * - holder PID is parseable and provably dead → reapable immediately.
287
+ * A fresh lock held by a live PID (or in its brief no-token mid-write window) is
288
+ * NOT reaped. Exported for tests.
289
+ */
290
+ function isSpawnLockReapable(lockPath, now = Date.now()) {
291
+ let content;
292
+ let mtimeMs;
293
+ try {
294
+ content = readFileSync(lockPath, "utf8").trim();
295
+ mtimeMs = statSync(lockPath).mtimeMs;
296
+ }
297
+ catch {
298
+ // Vanished/unreadable between the open attempt and here — safe to retry.
299
+ return true;
300
+ }
301
+ // Absolute ceiling applies to ALL locks (PID-bearing included), so a recycled
302
+ // dead-holder PID can never block spawns indefinitely.
303
+ if (now - mtimeMs > SPAWN_LOCK_MAX_AGE_MS) {
304
+ return true;
305
+ }
306
+ const pid = spawnLockPid(content);
307
+ if (pid !== null) {
308
+ // Within the ceiling — reap only if the holder process is provably gone.
309
+ return !isPidAlive(pid);
310
+ }
311
+ // No parseable PID and still fresh (legacy/mid-write/corrupt) — keep waiting.
312
+ return false;
313
+ }
314
+ /**
315
+ * Release a spawn.lock ONLY if it still records our owner token. If the file is
316
+ * missing or holds a different token — meaning we were reaped (age ceiling) and
317
+ * another process now owns it — leave it alone. This closes the lock-steal race
318
+ * where a holder that overran the ceiling would otherwise delete the new owner's
319
+ * lock. Exported for tests.
320
+ */
321
+ function releaseSpawnLockIfOwned(lockPath, ownerToken) {
322
+ try {
323
+ if (readFileSync(lockPath, "utf8").trim() === ownerToken) {
324
+ rmSync(lockPath, { force: true });
325
+ }
326
+ }
327
+ catch {
328
+ // Missing/unreadable — nothing of ours to release.
329
+ }
330
+ }
202
331
  async function isAgentProcessNotDefinitelyMissing(agent, handle) {
203
332
  try {
204
333
  return (await agent.isProcessRunning(handle)) !== false;
@@ -354,6 +483,7 @@ function createSessionManager(deps) {
354
483
  let sessionCache = null;
355
484
  const ensureOrchestratorPromises = new Map();
356
485
  const relaunchOrchestratorPromises = new Map();
486
+ const ensureMetaOrchestratorPromises = new Map();
357
487
  function invalidateCache() {
358
488
  sessionCache = null;
359
489
  }
@@ -639,14 +769,90 @@ function createSessionManager(deps) {
639
769
  return [];
640
770
  }
641
771
  }
642
- async function reserveNextSessionIdentity(project, sessionsDir) {
772
+ /**
773
+ * Serialize the spawn collision-check + id-reservation window per project,
774
+ * ACROSS processes (the project orchestrator and a meta orchestrator each run
775
+ * their own `athene spawn` process). Without this, two concurrent spawns could
776
+ * both pass the issue-collision guard before either persists its claim, and
777
+ * both create live sessions owning the same issue. The lock is held only for
778
+ * the brief guard+reserve+claim window, never across worktree/runtime creation.
779
+ */
780
+ async function withProjectSpawnLock(projectId, fn) {
781
+ const projectDir = getProjectDir(projectId);
782
+ mkdirSync(projectDir, { recursive: true });
783
+ const lockPath = join(projectDir, "spawn.lock");
784
+ const timeoutMs = 30_000;
785
+ const deadline = Date.now() + timeoutMs;
786
+ let fd = null;
787
+ let waitMs = 10;
788
+ while (fd === null) {
789
+ try {
790
+ fd = openSync(lockPath, "wx");
791
+ }
792
+ catch (err) {
793
+ if (err.code !== "EEXIST") {
794
+ throw new Error(`Failed to acquire spawn lock: ${lockPath}`, { cause: err });
795
+ }
796
+ // Checked every iteration (before any `continue`) so a vanished-lock
797
+ // race can't spin past the timeout.
798
+ if (Date.now() > deadline) {
799
+ throw new Error(`Timed out acquiring spawn lock for project '${projectId}'`, {
800
+ cause: err,
801
+ });
802
+ }
803
+ // Liveness-based reaping: reap ONLY when the recorded holder PID is
804
+ // provably dead (or the lock is corrupt and past the backstop). A live
805
+ // but slow holder is never reaped, so the cross-process collision
806
+ // guarantee holds regardless of critical-section duration.
807
+ if (isSpawnLockReapable(lockPath)) {
808
+ rmSync(lockPath, { force: true });
809
+ continue;
810
+ }
811
+ await sleep(waitMs);
812
+ waitMs = Math.min(waitMs * 2, 250);
813
+ }
814
+ }
815
+ // Record our unique owner token (pid:nonce). The pid lets other waiters probe
816
+ // our liveness; the nonce lets release verify the lock is still ours. The
817
+ // brief empty-file window before this write reads as "no parseable token" and
818
+ // is protected by the age ceiling (never reaped while fresh).
819
+ const ownerToken = nextSpawnLockToken();
820
+ try {
821
+ writeFileSync(lockPath, ownerToken);
822
+ }
823
+ catch {
824
+ /* best effort — a missing token just falls back to the age ceiling */
825
+ }
826
+ try {
827
+ return await fn();
828
+ }
829
+ finally {
830
+ try {
831
+ closeSync(fd);
832
+ }
833
+ catch {
834
+ /* best effort */
835
+ }
836
+ // Only delete the lock if it is STILL ours — if we overran the age ceiling
837
+ // and were reaped, another process may now own it; deleting it then would
838
+ // break mutual exclusion.
839
+ releaseSpawnLockIfOwned(lockPath, ownerToken);
840
+ }
841
+ }
842
+ /**
843
+ * Reserve the next free session id. DISK-ONLY and synchronous: the remote
844
+ * branch lookup (git ls-remote) is performed by the caller BEFORE acquiring
845
+ * the spawn lock and passed in via `remoteSessionNumbers`, so the per-project
846
+ * spawn lock never spans a network round-trip.
847
+ */
848
+ function reserveNextSessionIdentity(project, sessionsDir, remoteSessionNumbers) {
643
849
  const usedNumbers = new Set();
644
850
  for (const sessionName of listMetadata(sessionsDir)) {
645
851
  const num = getSessionNumber(sessionName, project.sessionPrefix);
646
852
  if (num !== undefined)
647
853
  usedNumbers.add(num);
648
854
  }
649
- for (const num of await listRemoteSessionNumbers(project)) {
855
+ for (const num of remoteSessionNumbers) {
650
856
  usedNumbers.add(num);
651
857
  }
652
858
  let num = getNextSessionNumber([...usedNumbers].map((value) => `${project.sessionPrefix}-${value}`), project.sessionPrefix);
@@ -972,12 +1178,75 @@ function createSessionManager(deps) {
972
1178
  // ones.
973
1179
  const cleanupStack = new CleanupStack();
974
1180
  let sessionId;
1181
+ let tmuxName;
1182
+ // Fetch remote session numbers (git ls-remote, up to ~5s) BEFORE acquiring
1183
+ // the spawn lock so the network round-trip is never serialized behind the
1184
+ // per-project mutex — the in-lock window stays disk-only.
1185
+ const remoteSessionNumbers = await listRemoteSessionNumbers(project);
1186
+ // Anti-collision guard + id reservation run together under a per-project
1187
+ // spawn lock so the check-then-reserve window is atomic ACROSS processes
1188
+ // (the project orchestrator and a meta orchestrator each spawn from their own
1189
+ // process). The guard runs BEFORE any worktree/runtime creation so a refusal
1190
+ // orphans nothing, and is the single authority protecting BOTH coordinators
1191
+ // symmetrically. Issue-keyed work is a HARD refusal; freeform work is
1192
+ // advisory (surfaced by the CLI, not blocked here). The lock window is
1193
+ // DISK-ONLY (no network/enrichment) — the remote lookup above was hoisted out.
1194
+ await withProjectSpawnLock(spawnConfig.projectId, async () => {
1195
+ // Minimal disk-only read (no enrichment/probes) so the lock window stays
1196
+ // brief and reflects in-flight issue claims. Detecting/runtime-lost peers
1197
+ // are intentionally NOT excluded (isSpawnTerminal, not isTerminalSession):
1198
+ // they still own their issue and must block a duplicate (#1735).
1199
+ const liveInProject = listProjectSessionsFromDisk(spawnConfig.projectId).filter((s) => !isSpawnTerminal(s));
1200
+ const collision = checkSpawnCollision(liveInProject, {
1201
+ projectId: spawnConfig.projectId,
1202
+ issueId: spawnConfig.issueId,
1203
+ });
1204
+ if (collision.hard) {
1205
+ throw new Error(formatHardRefusal(collision.hard));
1206
+ }
1207
+ // Atomically reserve the session id, then immediately persist the issue
1208
+ // claim so a concurrent spawn (next to acquire the lock) sees it via
1209
+ // list() and hard-refuses — the empty reservation file carries no issueId.
1210
+ //
1211
+ // Accepted tradeoff of cross-process dedup: this claim writes
1212
+ // issue + status:"spawning" with no lifecycle/runtime handle yet. If the
1213
+ // spawn process crashes between here and the full writeMetadata below, the
1214
+ // session is left on disk as a "spawning" phantom that still owns the issue
1215
+ // (isSpawnTerminal treats only done/terminated/merged as terminal), so it
1216
+ // keeps hard-refusing re-spawns of that issue until it is cleaned up. It is
1217
+ // self-healing — lifecycle reconciliation probes the absent runtime and
1218
+ // terminates it — and killable in the meantime via `athene session kill`.
1219
+ ({ sessionId, tmuxName } = reserveNextSessionIdentity(project, sessionsDir, remoteSessionNumbers));
1220
+ if (spawnConfig.issueId) {
1221
+ try {
1222
+ updateMetadata(sessionsDir, sessionId, {
1223
+ issue: spawnConfig.issueId,
1224
+ project: spawnConfig.projectId,
1225
+ status: "spawning",
1226
+ });
1227
+ }
1228
+ catch (err) {
1229
+ // Roll back the reservation so a failed claim write inside the lock
1230
+ // can't leave an orphaned phantom session (the cleanupStack undo is
1231
+ // only registered after this block).
1232
+ try {
1233
+ deleteMetadata(sessionsDir, sessionId);
1234
+ }
1235
+ catch {
1236
+ /* best effort */
1237
+ }
1238
+ throw err;
1239
+ }
1240
+ }
1241
+ });
1242
+ if (!sessionId) {
1243
+ // reserveNextSessionIdentity throws on exhaustion, so this is unreachable;
1244
+ // the guard narrows `sessionId` to string for the rest of the function.
1245
+ throw new Error("Failed to reserve a session id");
1246
+ }
1247
+ const reservedSessionId = sessionId;
1248
+ cleanupStack.push(() => deleteMetadata(sessionsDir, reservedSessionId));
975
1249
  try {
976
- // Determine session ID — atomically reserve to prevent concurrent collisions
977
- let tmuxName;
978
- ({ sessionId, tmuxName } = await reserveNextSessionIdentity(project, sessionsDir));
979
- const reservedSessionId = sessionId;
980
- cleanupStack.push(() => deleteMetadata(sessionsDir, reservedSessionId));
981
1250
  // Determine branch name — explicit branch always takes priority
982
1251
  let branch;
983
1252
  if (spawnConfig.branch) {
@@ -1124,25 +1393,25 @@ function createSessionManager(deps) {
1124
1393
  sessionId: tmuxName ?? sessionId, // Use tmux name for runtime if available
1125
1394
  workspacePath,
1126
1395
  launchCommand,
1127
- environment: {
1396
+ environment: withLegacyEnvAliases({
1128
1397
  ...environment,
1129
1398
  ...(opencodeConfigFile ? { OPENCODE_CONFIG: opencodeConfigFile } : {}),
1130
1399
  ...(project.env ?? {}),
1131
1400
  PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
1132
1401
  GH_PATH: PREFERRED_GH_PATH,
1133
- ...(process.env["AO_AGENT_GH_TRACE"] && {
1134
- AO_AGENT_GH_TRACE: process.env["AO_AGENT_GH_TRACE"],
1402
+ ...(getEnvString(ENV.AGENT_GH_TRACE) && {
1403
+ [ENV.AGENT_GH_TRACE]: getEnvString(ENV.AGENT_GH_TRACE),
1135
1404
  }),
1136
- AO_SESSION: sessionId,
1137
- AO_DATA_DIR: sessionsDir, // Pass sessions directory (not root dataDir)
1138
- AO_SESSION_NAME: sessionId, // User-facing session name
1139
- ...(tmuxName && { AO_TMUX_NAME: tmuxName }), // Tmux session name if using new arch
1140
- AO_CALLER_TYPE: "agent",
1141
- AO_PROJECT_ID: spawnConfig.projectId,
1142
- AO_CONFIG_PATH: config.configPath,
1405
+ [ENV.SESSION]: sessionId,
1406
+ [ENV.DATA_DIR]: sessionsDir, // Pass sessions directory (not root dataDir)
1407
+ [ENV.SESSION_NAME]: sessionId, // User-facing session name
1408
+ ...(tmuxName && { [ENV.TMUX_NAME]: tmuxName }), // Tmux session name if using new arch
1409
+ [ENV.CALLER_TYPE]: "agent",
1410
+ [ENV.PROJECT_ID]: spawnConfig.projectId,
1411
+ [ENV.CONFIG_PATH]: config.configPath,
1143
1412
  ...(config.port !== undefined &&
1144
- config.port !== null && { AO_PORT: String(config.port) }),
1145
- },
1413
+ config.port !== null && { [ENV.PORT]: String(config.port) }),
1414
+ }),
1146
1415
  });
1147
1416
  const rt = plugins.runtime;
1148
1417
  cleanupStack.push(() => rt.destroy(handle));
@@ -1183,6 +1452,15 @@ function createSessionManager(deps) {
1183
1452
  ...(reusedOpenCodeSessionId ? { opencodeSessionId: reusedOpenCodeSessionId } : {}),
1184
1453
  ...(spawnConfig.prompt ? { userPrompt: spawnConfig.prompt } : {}),
1185
1454
  ...(displayName ? { displayName } : {}),
1455
+ // Owner stamping: meta-dispatched workers carry ownerKind/metaOwner so
1456
+ // they are visible to BOTH the meta orchestrator and the per-project
1457
+ // orchestrator. Absent => project-owned (the default).
1458
+ ...(spawnConfig.ownerKind === "meta"
1459
+ ? {
1460
+ ownerKind: "meta",
1461
+ ...(spawnConfig.metaOwner ? { metaOwner: spawnConfig.metaOwner } : {}),
1462
+ }
1463
+ : {}),
1186
1464
  },
1187
1465
  };
1188
1466
  writeMetadata(sessionsDir, sessionId, {
@@ -1558,24 +1836,24 @@ function createSessionManager(deps) {
1558
1836
  sessionId: tmuxName ?? sessionId,
1559
1837
  workspacePath,
1560
1838
  launchCommand,
1561
- environment: {
1839
+ environment: withLegacyEnvAliases({
1562
1840
  ...environment,
1563
1841
  ...(project.env ?? {}),
1564
1842
  PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
1565
1843
  GH_PATH: PREFERRED_GH_PATH,
1566
- ...(process.env["AO_AGENT_GH_TRACE"] && {
1567
- AO_AGENT_GH_TRACE: process.env["AO_AGENT_GH_TRACE"],
1844
+ ...(getEnvString(ENV.AGENT_GH_TRACE) && {
1845
+ [ENV.AGENT_GH_TRACE]: getEnvString(ENV.AGENT_GH_TRACE),
1568
1846
  }),
1569
- AO_SESSION: sessionId,
1570
- AO_DATA_DIR: sessionsDir,
1571
- AO_SESSION_NAME: sessionId,
1572
- ...(tmuxName && { AO_TMUX_NAME: tmuxName }),
1573
- AO_CALLER_TYPE: "orchestrator",
1574
- AO_PROJECT_ID: orchestratorConfig.projectId,
1575
- AO_CONFIG_PATH: config.configPath,
1847
+ [ENV.SESSION]: sessionId,
1848
+ [ENV.DATA_DIR]: sessionsDir,
1849
+ [ENV.SESSION_NAME]: sessionId,
1850
+ ...(tmuxName && { [ENV.TMUX_NAME]: tmuxName }),
1851
+ [ENV.CALLER_TYPE]: "orchestrator",
1852
+ [ENV.PROJECT_ID]: orchestratorConfig.projectId,
1853
+ [ENV.CONFIG_PATH]: config.configPath,
1576
1854
  ...(config.port !== undefined &&
1577
- config.port !== null && { AO_PORT: String(config.port) }),
1578
- },
1855
+ config.port !== null && { [ENV.PORT]: String(config.port) }),
1856
+ }),
1579
1857
  });
1580
1858
  }
1581
1859
  catch (err) {
@@ -1801,6 +2079,292 @@ function createSessionManager(deps) {
1801
2079
  ensureOrchestratorPromises.set(sessionId, promise);
1802
2080
  return promise;
1803
2081
  }
2082
+ /**
2083
+ * Ensure a meta orchestrator session exists for `name`. Reuses a live one if
2084
+ * present (read from the reserved `_meta` scope), else spawns fresh.
2085
+ *
2086
+ * Meta orchestrators are NOT scoped to a single project, so they live under
2087
+ * `projects/_meta/<name>/sessions/<name>.json` and are reconstructed by
2088
+ * reading that file directly — `get()`/`list()` only walk configured projects.
2089
+ */
2090
+ function readExistingMetaSession(name) {
2091
+ const existing = readMetadataRaw(getMetaSessionsDir(name), name);
2092
+ if (!existing)
2093
+ return null;
2094
+ // projectId "_meta" for parity with the freshly-spawned path (meta["project"]
2095
+ // is also persisted as "_meta", but pass it explicitly to be unambiguous).
2096
+ return sessionFromMetadata(name, existing, {
2097
+ projectId: "_meta",
2098
+ sessionKind: "meta-orchestrator",
2099
+ });
2100
+ }
2101
+ /**
2102
+ * Synthetic project context for the reserved `_meta` scope — a meta orchestrator
2103
+ * has no repo/worktree. `workspacePath` is a plain scratch dir (agent cwd +
2104
+ * PATH-wrapper home), never a git worktree, so the workspace plugin's create()
2105
+ * is intentionally never called. Shared by spawn and the liveness probe.
2106
+ */
2107
+ function metaProjectContext(name, agentOverride) {
2108
+ const metaProject = {
2109
+ name: `${name} (meta)`,
2110
+ path: getMetaWorkspaceDir(name),
2111
+ defaultBranch: "main",
2112
+ sessionPrefix: name,
2113
+ };
2114
+ const selection = resolveAgentSelection({
2115
+ role: "orchestrator",
2116
+ project: metaProject,
2117
+ defaults: config.defaults,
2118
+ spawnAgentOverride: agentOverride,
2119
+ });
2120
+ const plugins = resolvePlugins(metaProject, selection.agentName);
2121
+ return { metaProject, selection, plugins };
2122
+ }
2123
+ async function ensureMetaOrchestratorInternal(metaConfig) {
2124
+ const name = metaConfig.name;
2125
+ const existing = readExistingMetaSession(name);
2126
+ if (existing && !isTerminalSession(existing)) {
2127
+ // _meta sessions are not runtime-enriched or supervised by the lifecycle
2128
+ // manager, so their persisted state stays `working` even after the runtime
2129
+ // dies. Probe the runtime handle: reuse only if it is NOT definitely
2130
+ // missing; otherwise clear the stale metadata and relaunch (mirrors
2131
+ // ensureOrchestratorInternal's enriched get() + restore behavior).
2132
+ const { plugins } = metaProjectContext(name, metaConfig.agent);
2133
+ const aliveOrUncertain = Boolean(existing.runtimeHandle &&
2134
+ plugins.agent &&
2135
+ (await isAgentProcessNotDefinitelyMissing(plugins.agent, existing.runtimeHandle)));
2136
+ if (aliveOrUncertain) {
2137
+ return existing;
2138
+ }
2139
+ deleteMetadata(getMetaSessionsDir(name), name);
2140
+ }
2141
+ return _spawnMetaOrchestratorInner(metaConfig);
2142
+ }
2143
+ async function ensureMetaOrchestrator(metaConfig) {
2144
+ // In-flight dedup keyed by meta name — mirrors ensureOrchestrator, so two
2145
+ // concurrent calls in the same process share one spawn rather than racing.
2146
+ const name = metaConfig.name;
2147
+ const existingPromise = ensureMetaOrchestratorPromises.get(name);
2148
+ if (existingPromise)
2149
+ return existingPromise;
2150
+ const promise = ensureMetaOrchestratorInternal(metaConfig).finally(() => {
2151
+ ensureMetaOrchestratorPromises.delete(name);
2152
+ });
2153
+ ensureMetaOrchestratorPromises.set(name, promise);
2154
+ return promise;
2155
+ }
2156
+ async function _spawnMetaOrchestratorInner(metaConfig) {
2157
+ const name = metaConfig.name;
2158
+ const sessionId = name;
2159
+ const sessionsDir = getMetaSessionsDir(name);
2160
+ const workspacePath = getMetaWorkspaceDir(name);
2161
+ recordActivityEvent({
2162
+ projectId: "_meta",
2163
+ source: "session-manager",
2164
+ kind: "session.spawn_started",
2165
+ summary: "meta orchestrator spawn started",
2166
+ data: { agent: metaConfig.agent ?? undefined, role: "meta-orchestrator" },
2167
+ });
2168
+ const { metaProject, selection, plugins } = metaProjectContext(name, metaConfig.agent);
2169
+ if (!plugins.runtime) {
2170
+ throw new Error(`Runtime plugin '${config.defaults.runtime}' not found`);
2171
+ }
2172
+ if (!plugins.agent) {
2173
+ throw new Error(`Agent plugin '${selection.agentName}' not found`);
2174
+ }
2175
+ mkdirSync(workspacePath, { recursive: true });
2176
+ mkdirSync(sessionsDir, { recursive: true });
2177
+ // Atomic cross-process reservation — mirrors reserveFixedOrchestratorIdentity.
2178
+ // O_EXCL creates the metadata file iff it does not exist, so two concurrent
2179
+ // `meta-start` processes for the same name cannot both proceed to runtime
2180
+ // creation (the loser would otherwise overwrite metadata and orphan the
2181
+ // winner's runtime). On conflict, reuse a live session or reclaim a stale one.
2182
+ if (!reserveSessionId(sessionsDir, sessionId)) {
2183
+ const existing = readExistingMetaSession(name);
2184
+ // Probe the runtime before reusing — mirrors ensureMetaOrchestratorInternal.
2185
+ // _meta sessions are not lifecycle-supervised, so a non-terminal persisted
2186
+ // state can still front a dead runtime; under a truly-concurrent meta-start
2187
+ // race the normal probe/delete in the caller may not have run yet. Reuse only
2188
+ // if NOT definitely missing; otherwise reclaim and relaunch.
2189
+ const aliveOrUncertain = existing &&
2190
+ !isTerminalSession(existing) &&
2191
+ Boolean(existing.runtimeHandle &&
2192
+ plugins.agent &&
2193
+ (await isAgentProcessNotDefinitelyMissing(plugins.agent, existing.runtimeHandle)));
2194
+ if (aliveOrUncertain) {
2195
+ return existing;
2196
+ }
2197
+ // Stale, dead, or terminal record left behind — reclaim the id.
2198
+ deleteMetadata(sessionsDir, sessionId);
2199
+ if (!reserveSessionId(sessionsDir, sessionId)) {
2200
+ throw new Error(`Meta orchestrator '${name}' is already being created`);
2201
+ }
2202
+ }
2203
+ const cleanup = async (handle) => {
2204
+ if (handle) {
2205
+ try {
2206
+ await plugins.runtime.destroy(handle);
2207
+ }
2208
+ catch {
2209
+ /* best effort */
2210
+ }
2211
+ }
2212
+ try {
2213
+ deleteMetadata(sessionsDir, sessionId);
2214
+ }
2215
+ catch {
2216
+ /* best effort */
2217
+ }
2218
+ };
2219
+ // Install metadata hooks / PATH wrappers so the meta orchestrator can run
2220
+ // `athene` commands autonomously. Mirrors the orchestrator/worker paths.
2221
+ try {
2222
+ if (plugins.agent.setupWorkspaceHooks) {
2223
+ await plugins.agent.setupWorkspaceHooks(workspacePath, { dataDir: sessionsDir });
2224
+ }
2225
+ if (plugins.agent.name !== "claude-code") {
2226
+ await setupPathWrapperWorkspace(workspacePath);
2227
+ }
2228
+ }
2229
+ catch (err) {
2230
+ await cleanup();
2231
+ throw err;
2232
+ }
2233
+ let systemPromptFile;
2234
+ if (metaConfig.systemPrompt) {
2235
+ systemPromptFile = join(workspacePath, `meta-orchestrator-prompt-${sessionId}.md`);
2236
+ writeFileSync(systemPromptFile, metaConfig.systemPrompt, "utf-8");
2237
+ // OpenCode does not consume systemPromptFile — it reads its prompt from a
2238
+ // workspace AGENTS.md. Mirror the per-project orchestrator path so a meta
2239
+ // orchestrator on the opencode agent actually receives its routing prompt.
2240
+ if (plugins.agent.name === "opencode") {
2241
+ writeWorkspaceOpenCodeAgentsMd(workspacePath, systemPromptFile);
2242
+ }
2243
+ }
2244
+ // Meta orchestrator ALWAYS runs permissionless — it must run ao CLI commands.
2245
+ const agentLaunchConfig = {
2246
+ sessionId,
2247
+ projectConfig: {
2248
+ ...metaProject,
2249
+ agentConfig: {
2250
+ ...selection.agentConfig,
2251
+ permissions: "permissionless",
2252
+ },
2253
+ },
2254
+ workspacePath,
2255
+ permissions: "permissionless",
2256
+ model: selection.model,
2257
+ systemPromptFile,
2258
+ subagent: selection.subagent,
2259
+ };
2260
+ const launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig);
2261
+ const environment = plugins.agent.getEnvironment(agentLaunchConfig);
2262
+ if (plugins.agent.preLaunchSetup) {
2263
+ await plugins.agent.preLaunchSetup(workspacePath);
2264
+ }
2265
+ let handle;
2266
+ try {
2267
+ handle = await plugins.runtime.create({
2268
+ sessionId,
2269
+ workspacePath,
2270
+ launchCommand,
2271
+ environment: withLegacyEnvAliases({
2272
+ ...environment,
2273
+ PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
2274
+ GH_PATH: PREFERRED_GH_PATH,
2275
+ [ENV.SESSION]: sessionId,
2276
+ [ENV.DATA_DIR]: sessionsDir,
2277
+ [ENV.SESSION_NAME]: sessionId,
2278
+ [ENV.CALLER_TYPE]: "meta-orchestrator",
2279
+ [ENV.PROJECT_ID]: "_meta",
2280
+ [ENV.META_NAME]: name,
2281
+ [ENV.CONFIG_PATH]: config.configPath,
2282
+ ...(config.port !== undefined &&
2283
+ config.port !== null && { [ENV.PORT]: String(config.port) }),
2284
+ }),
2285
+ });
2286
+ }
2287
+ catch (err) {
2288
+ await cleanup();
2289
+ throw err;
2290
+ }
2291
+ const createdAt = new Date();
2292
+ const lifecycle = createInitialCanonicalLifecycle("meta-orchestrator", createdAt);
2293
+ lifecycle.session.state = "working";
2294
+ lifecycle.session.reason = "task_in_progress";
2295
+ lifecycle.session.startedAt = createdAt.toISOString();
2296
+ lifecycle.session.lastTransitionAt = createdAt.toISOString();
2297
+ lifecycle.runtime.handle = handle;
2298
+ const displayName = `${name} (meta)`;
2299
+ const session = {
2300
+ id: sessionId,
2301
+ projectId: "_meta",
2302
+ status: deriveLegacyStatus(lifecycle),
2303
+ activity: "active",
2304
+ activitySignal: createActivitySignal("valid", {
2305
+ activity: "active",
2306
+ timestamp: createdAt,
2307
+ source: "runtime",
2308
+ }),
2309
+ lifecycle,
2310
+ branch: null,
2311
+ issueId: null,
2312
+ pr: null,
2313
+ prs: [],
2314
+ workspacePath,
2315
+ runtimeHandle: handle,
2316
+ agentInfo: null,
2317
+ createdAt,
2318
+ lastActivityAt: createdAt,
2319
+ metadata: {
2320
+ role: "meta-orchestrator",
2321
+ metaName: name,
2322
+ ...(displayName ? { displayName } : {}),
2323
+ },
2324
+ };
2325
+ try {
2326
+ writeMetadata(sessionsDir, sessionId, {
2327
+ worktree: workspacePath,
2328
+ // Meta orchestrators have no git branch (no worktree). Persist an empty
2329
+ // string to satisfy the metadata contract; session.branch stays null.
2330
+ branch: "",
2331
+ status: deriveLegacyStatus(lifecycle),
2332
+ ...buildLifecycleMetadataPatch(lifecycle),
2333
+ // Object overrides for the typed writeMetadata path — see the worker
2334
+ // spawn site for the rationale.
2335
+ lifecycle,
2336
+ role: "meta-orchestrator",
2337
+ project: "_meta",
2338
+ agent: selection.agentName,
2339
+ createdAt: createdAt.toISOString(),
2340
+ runtimeHandle: handle,
2341
+ displayName,
2342
+ });
2343
+ if (plugins.agent.postLaunchSetup) {
2344
+ await plugins.agent.postLaunchSetup(session);
2345
+ }
2346
+ if (plugins.agent.promptDelivery === "post-launch" && metaConfig.systemPrompt) {
2347
+ await plugins.runtime.sendMessage(handle, "Begin.");
2348
+ }
2349
+ if (Object.keys(session.metadata || {}).length > 0) {
2350
+ updateMetadata(sessionsDir, sessionId, session.metadata);
2351
+ }
2352
+ invalidateCache();
2353
+ }
2354
+ catch (err) {
2355
+ await cleanup(handle);
2356
+ throw err;
2357
+ }
2358
+ recordActivityEvent({
2359
+ projectId: "_meta",
2360
+ sessionId,
2361
+ source: "session-manager",
2362
+ kind: "session.spawned",
2363
+ summary: `spawned: ${sessionId}`,
2364
+ data: { agent: plugins.agent.name, role: "meta-orchestrator" },
2365
+ });
2366
+ return session;
2367
+ }
1804
2368
  async function relaunchOrchestratorInternal(orchestratorConfig) {
1805
2369
  const project = config.projects[orchestratorConfig.projectId];
1806
2370
  if (!project) {
@@ -2980,24 +3544,24 @@ function createSessionManager(deps) {
2980
3544
  sessionId: tmuxName ?? sessionId,
2981
3545
  workspacePath,
2982
3546
  launchCommand,
2983
- environment: {
3547
+ environment: withLegacyEnvAliases({
2984
3548
  ...environment,
2985
3549
  ...(opencodeConfigPath ? { OPENCODE_CONFIG: opencodeConfigPath } : {}),
2986
3550
  ...(project.env ?? {}),
2987
3551
  PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
2988
3552
  GH_PATH: PREFERRED_GH_PATH,
2989
- ...(process.env["AO_AGENT_GH_TRACE"] && {
2990
- AO_AGENT_GH_TRACE: process.env["AO_AGENT_GH_TRACE"],
3553
+ ...(getEnvString(ENV.AGENT_GH_TRACE) && {
3554
+ [ENV.AGENT_GH_TRACE]: getEnvString(ENV.AGENT_GH_TRACE),
2991
3555
  }),
2992
- AO_SESSION: sessionId,
2993
- AO_DATA_DIR: sessionsDir,
2994
- AO_SESSION_NAME: sessionId,
2995
- ...(tmuxName && { AO_TMUX_NAME: tmuxName }),
2996
- AO_CALLER_TYPE: "agent",
2997
- ...(projectId && { AO_PROJECT_ID: projectId }),
2998
- AO_CONFIG_PATH: config.configPath,
2999
- ...(config.port !== undefined && config.port !== null && { AO_PORT: String(config.port) }),
3000
- },
3556
+ [ENV.SESSION]: sessionId,
3557
+ [ENV.DATA_DIR]: sessionsDir,
3558
+ [ENV.SESSION_NAME]: sessionId,
3559
+ ...(tmuxName && { [ENV.TMUX_NAME]: tmuxName }),
3560
+ [ENV.CALLER_TYPE]: "agent",
3561
+ ...(projectId && { [ENV.PROJECT_ID]: projectId }),
3562
+ [ENV.CONFIG_PATH]: config.configPath,
3563
+ ...(config.port !== undefined && config.port !== null && { [ENV.PORT]: String(config.port) }),
3564
+ }),
3001
3565
  });
3002
3566
  // 9. Update metadata — reset lifecycle to working state
3003
3567
  const now = new Date().toISOString();
@@ -3059,6 +3623,7 @@ function createSessionManager(deps) {
3059
3623
  spawn,
3060
3624
  spawnOrchestrator,
3061
3625
  ensureOrchestrator,
3626
+ ensureMetaOrchestrator,
3062
3627
  relaunchOrchestrator,
3063
3628
  restore,
3064
3629
  list,
@@ -3073,5 +3638,5 @@ function createSessionManager(deps) {
3073
3638
  };
3074
3639
  }
3075
3640
 
3076
- export { createSessionManager };
3641
+ export { createSessionManager, isSpawnLockReapable, releaseSpawnLockIfOwned };
3077
3642
  //# sourceMappingURL=session-manager.js.map