@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.
- package/dist/agent-workspace-hooks.d.ts +3 -3
- package/dist/agent-workspace-hooks.d.ts.map +1 -1
- package/dist/agent-workspace-hooks.js +22 -21
- package/dist/agent-workspace-hooks.js.map +1 -1
- package/dist/config.d.ts +19 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +116 -11
- package/dist/config.js.map +1 -1
- package/dist/env.d.ts +157 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +187 -0
- package/dist/env.js.map +1 -0
- package/dist/events-db.d.ts.map +1 -1
- package/dist/events-db.js +2 -1
- package/dist/events-db.js.map +1 -1
- package/dist/feature-flags.d.ts.map +1 -1
- package/dist/feature-flags.js +3 -1
- package/dist/feature-flags.js.map +1 -1
- package/dist/gh-trace.d.ts.map +1 -1
- package/dist/gh-trace.js +4 -3
- package/dist/gh-trace.js.map +1 -1
- package/dist/global-config.d.ts +29 -8
- package/dist/global-config.d.ts.map +1 -1
- package/dist/global-config.js +13 -4
- package/dist/global-config.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/lifecycle-manager.d.ts.map +1 -1
- package/dist/lifecycle-manager.js +101 -1
- package/dist/lifecycle-manager.js.map +1 -1
- package/dist/meta-orchestrator-config-writer.d.ts +13 -0
- package/dist/meta-orchestrator-config-writer.d.ts.map +1 -0
- package/dist/meta-orchestrator-config-writer.js +22 -0
- package/dist/meta-orchestrator-config-writer.js.map +1 -0
- package/dist/meta-orchestrator-prompt.d.ts +16 -0
- package/dist/meta-orchestrator-prompt.d.ts.map +1 -0
- package/dist/meta-orchestrator-prompt.js +87 -0
- package/dist/meta-orchestrator-prompt.js.map +1 -0
- package/dist/meta-scope.d.ts +38 -0
- package/dist/meta-scope.d.ts.map +1 -0
- package/dist/meta-scope.js +47 -0
- package/dist/meta-scope.js.map +1 -0
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +3 -2
- package/dist/observability.js.map +1 -1
- package/dist/paths.d.ts +18 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +37 -1
- package/dist/paths.js.map +1 -1
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +4 -3
- package/dist/platform.js.map +1 -1
- package/dist/prompt-builder.d.ts +2 -2
- package/dist/prompt-builder.d.ts.map +1 -1
- package/dist/prompt-builder.js +2 -2
- package/dist/prompts/meta-orchestrator.md.js +4 -0
- package/dist/prompts/meta-orchestrator.md.js.map +1 -0
- package/dist/prompts/orchestrator.md.js +1 -1
- package/dist/prompts/orchestrator.md.js.map +1 -1
- package/dist/runtime-orphans.d.ts +96 -0
- package/dist/runtime-orphans.d.ts.map +1 -0
- package/dist/runtime-orphans.js +158 -0
- package/dist/runtime-orphans.js.map +1 -0
- package/dist/session-manager.d.ts +16 -0
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +611 -46
- package/dist/session-manager.js.map +1 -1
- package/dist/spawn-collision.d.ts +23 -0
- package/dist/spawn-collision.d.ts.map +1 -0
- package/dist/spawn-collision.js +26 -0
- package/dist/spawn-collision.js.map +1 -0
- package/dist/types.d.ts +103 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +22 -1
- package/dist/types.js.map +1 -1
- package/package.json +10 -9
- package/LICENSE +0 -22
package/dist/session-manager.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, statSync, mkdirSync, writeFileSync, unlinkSync,
|
|
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
|
-
|
|
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
|
|
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
|
-
...(
|
|
1134
|
-
|
|
1402
|
+
...(getEnvString(ENV.AGENT_GH_TRACE) && {
|
|
1403
|
+
[ENV.AGENT_GH_TRACE]: getEnvString(ENV.AGENT_GH_TRACE),
|
|
1135
1404
|
}),
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
...(tmuxName && {
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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 && {
|
|
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
|
-
...(
|
|
1567
|
-
|
|
1844
|
+
...(getEnvString(ENV.AGENT_GH_TRACE) && {
|
|
1845
|
+
[ENV.AGENT_GH_TRACE]: getEnvString(ENV.AGENT_GH_TRACE),
|
|
1568
1846
|
}),
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
...(tmuxName && {
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
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 && {
|
|
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
|
-
...(
|
|
2990
|
-
|
|
3553
|
+
...(getEnvString(ENV.AGENT_GH_TRACE) && {
|
|
3554
|
+
[ENV.AGENT_GH_TRACE]: getEnvString(ENV.AGENT_GH_TRACE),
|
|
2991
3555
|
}),
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
...(tmuxName && {
|
|
2996
|
-
|
|
2997
|
-
...(projectId && {
|
|
2998
|
-
|
|
2999
|
-
...(config.port !== undefined && config.port !== null && {
|
|
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
|