@themoltnet/agent-daemon 0.32.0 → 0.32.1

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 (3) hide show
  1. package/README.md +23 -2
  2. package/dist/main.js +405 -28
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -156,8 +156,9 @@ registered task type; unknown task-type names remain invalid.
156
156
  provider key referenced by `.pi/models.json`, for example `OLLAMA_API_KEY`.
157
157
  - `ssh-keygen` on `PATH`.
158
158
  - A runtime profile in the target team. The profile supplies provider, model,
159
- sandbox policy, and runtime defaults. The daemon mounts the current working
160
- directory as the VM workspace root.
159
+ sandbox policy, and runtime defaults. The daemon resolves the configured
160
+ agent root and uses that checkout as the VM workspace root, regardless of
161
+ the shell directory from which the command was launched.
161
162
 
162
163
  For `themoltnet`, prefer a profile sandbox equivalent to this minimal policy:
163
164
 
@@ -203,6 +204,26 @@ producer context, the judge fails with `producer_context_missing`.
203
204
  Repo-specific `resumeCommands` that should not run in scratch mode must still
204
205
  be guarded with `when.workspaceMode`.
205
206
 
207
+ ### Runtime resource lifecycle
208
+
209
+ Each daemon process creates a unique runtime lane. Two polling processes using
210
+ the same agent, runtime profile, and task correlation therefore write to
211
+ different local Pi session directories and cannot race on the same slot.
212
+
213
+ `freeform` Pi context remains correlation-scoped, but its checkout is
214
+ attempt-scoped. Every attempt gets a fresh `daemon-task-<id>-attempt-<n>`
215
+ workspace; retries and explicit continuations fork the previous checkpointed
216
+ Pi session into that new workspace. The executor removes attempt workspaces on
217
+ normal completion. At startup, and once per minute while polling, the daemon
218
+ also reaps expired idle slots and terminal crash-orphans. Cleanup is restricted
219
+ to daemon-owned session, scratch, and `.worktrees` roots.
220
+
221
+ Provider failures are retried in the active Pi session before the daemon spends
222
+ a task attempt. The default is four same-session retries. If those fail,
223
+ deterministic retry classification runs before attempt-budget handling;
224
+ `executor_threw` is always treated as an implementation/setup failure and is
225
+ never promoted to another task attempt.
226
+
206
227
  ### 1. Start the local stack
207
228
 
208
229
  The e2e Compose file ships everything the daemon needs (Postgres, Ory, REST
package/dist/main.js CHANGED
@@ -12,8 +12,9 @@ import { parseArgs, promisify } from "node:util";
12
12
  import { AgentRuntime, ApiTaskReporter, ApiTaskSource, PollingApiTaskSource } from "@themoltnet/agent-runtime";
13
13
  import { createPiRetryTriage, createPiTaskExecutor, findMainWorktree, normalizeRetryTriageResult, redactRetryTriageSecrets } from "@themoltnet/pi-extension";
14
14
  import { execFile, execFileSync } from "node:child_process";
15
- import { accessSync, constants, createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync } from "node:fs";
15
+ import { accessSync, constants, createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from "node:fs";
16
16
  import { AuthenticationError, MoltNetError, connect } from "@themoltnet/sdk";
17
+ import { createHash, randomUUID } from "node:crypto";
17
18
  import { once } from "node:events";
18
19
  import { pino, transport } from "pino";
19
20
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
@@ -25,7 +26,6 @@ import { AsyncLocalStorage } from "node:async_hooks";
25
26
  import { getModel } from "@earendil-works/pi-ai";
26
27
  import { mkdir, realpath, stat } from "node:fs/promises";
27
28
  import { pipeline } from "node:stream/promises";
28
- import { createHash } from "node:crypto";
29
29
  import { Writable } from "node:stream";
30
30
  import { createGzip } from "node:zlib";
31
31
  //#region ../../libs/observability/src/instrumentation.ts
@@ -6537,7 +6537,7 @@ var BUILT_IN_TASK_TYPES = {
6537
6537
  outputKind: "artifact",
6538
6538
  resumable: true,
6539
6539
  workspaceMode: "shared_mount",
6540
- workspaceScope: "session",
6540
+ workspaceScope: "attempt",
6541
6541
  sessionScope: "correlation",
6542
6542
  acceptsInputWorkspaceOverride: true,
6543
6543
  requiresReferences: false,
@@ -9880,6 +9880,11 @@ function createGhCliClient() {
9880
9880
  };
9881
9881
  }
9882
9882
  //#endregion
9883
+ //#region src/lib/daemon-slot-identity.ts
9884
+ function createRuntimeInstanceId() {
9885
+ return randomUUID();
9886
+ }
9887
+ //#endregion
9883
9888
  //#region src/lib/session-files.ts
9884
9889
  function resolveLatestPiSessionPath(sessionDir) {
9885
9890
  try {
@@ -9950,10 +9955,10 @@ function slugifySessionComponent(input) {
9950
9955
  }
9951
9956
  //#endregion
9952
9957
  //#region src/lib/task-execution-plan.ts
9953
- function buildDaemonTaskExecutionPlan(task, stateDirs, identity, warmSessionTtlSec, runtimeProfileWorkspacePolicy = {}) {
9958
+ function buildDaemonTaskExecutionPlan(task, stateDirs, identity, warmSessionTtlSec, runtimeProfileWorkspacePolicy = {}, attemptN) {
9954
9959
  const descriptor = deriveTaskSessionDescriptor(task);
9955
9960
  const workspaceMode = resolveTaskWorkspaceMode(task, descriptor.policy, runtimeProfileWorkspacePolicy);
9956
- const slotKey = warmSessionTtlSec > 0 ? descriptor.sessionKey : null;
9961
+ const slotKey = warmSessionTtlSec > 0 && descriptor.sessionKey ? buildRuntimeSlotKey(descriptor.sessionKey, identity.runtimeInstanceId) : null;
9957
9962
  const workspaceScope = slotKey !== null ? descriptor.policy.workspaceScope : "attempt";
9958
9963
  const slotId = slotKey ? buildDaemonSlotId(identity, slotKey) : null;
9959
9964
  const sessionDir = slotId ? `${stateDirs.piSessionsDir}/${encodeURIComponent(slotId)}` : null;
@@ -9961,7 +9966,8 @@ function buildDaemonTaskExecutionPlan(task, stateDirs, identity, warmSessionTtlS
9961
9966
  const workspaceId = workspaceMode !== "shared_mount" ? resolveTaskWorkspaceId(task, {
9962
9967
  sessionKey: slotId,
9963
9968
  workspaceScope,
9964
- sessionPersistence: sessionDir ? { sessionDir } : null
9969
+ sessionPersistence: sessionDir ? { sessionDir } : null,
9970
+ attemptN
9965
9971
  }) : null;
9966
9972
  return {
9967
9973
  descriptor,
@@ -9985,6 +9991,12 @@ function buildDaemonSlotId(identity, slotKey) {
9985
9991
  slotKey
9986
9992
  ].join(":");
9987
9993
  }
9994
+ function buildRuntimeSlotKey(logicalSessionKey, runtimeInstanceId) {
9995
+ return runtimeInstanceId ? `${logicalSessionKey}:worker:${slugSlotIdentityComponent(runtimeInstanceId)}` : logicalSessionKey;
9996
+ }
9997
+ function runtimeSlotKeyBelongsToInstance(slotKey, runtimeInstanceId) {
9998
+ return slotKey.endsWith(`:worker:${slugSlotIdentityComponent(runtimeInstanceId)}`);
9999
+ }
9988
10000
  function slugSlotIdentityComponent(input) {
9989
10001
  return slugifyAsciiLower(input.trim(), 64, [
9990
10002
  ".",
@@ -10039,7 +10051,7 @@ function toDaemonWorkspaceMode(mode) {
10039
10051
  }
10040
10052
  function resolveTaskWorkspaceId(task, executionPlan) {
10041
10053
  if (executionPlan.workspaceScope === "session" && executionPlan.sessionKey !== null) return `session-${encodeURIComponent(executionPlan.sessionKey)}`;
10042
- return `task-${task.id}`;
10054
+ return executionPlan.attemptN ? `daemon-task-${task.id}-attempt-${executionPlan.attemptN}` : `task-${task.id}`;
10043
10055
  }
10044
10056
  //#endregion
10045
10057
  //#region src/lib/execution-plan-cache.ts
@@ -10058,7 +10070,7 @@ function createExecutionPlanCache(args) {
10058
10070
  const key = buildClaimedTaskKey(claimedTask);
10059
10071
  const existing = cache.get(key);
10060
10072
  if (existing) return existing;
10061
- const plan = await maybeAttachWarmSlotContext(claimedTask, buildDaemonTaskExecutionPlan(claimedTask.task, args.stateDirs, args.slotIdentity, args.warmSessionTtlSec, args.workspacePolicy), args.stateDirs, args.slotRegistry, runtimeSessionStore, sourceAttemptResolver);
10073
+ const plan = await maybeAttachWarmSlotContext(claimedTask, buildDaemonTaskExecutionPlan(claimedTask.task, args.stateDirs, args.slotIdentity, args.warmSessionTtlSec, args.workspacePolicy, claimedTask.attemptN), args.stateDirs, args.slotRegistry, runtimeSessionStore, sourceAttemptResolver);
10062
10074
  assertPlanAllowedByWorkspacePolicy(plan, args.workspacePolicy);
10063
10075
  cache.set(key, plan);
10064
10076
  return plan;
@@ -10125,14 +10137,13 @@ async function resolveWarmSlot(slotRegistry, runtimeSessionStore, teamId, source
10125
10137
  return {
10126
10138
  kind: "found",
10127
10139
  producerSlot: producerContext,
10128
- sessionPath: sourceSessionPath,
10129
- workspacePath: resolveProducerWorkspaceCopySource(producerContext, stateDirs)
10140
+ sessionPath: sourceSessionPath
10130
10141
  };
10131
10142
  }
10132
10143
  async function maybeAttachWarmSlotContext(claimedTask, basePlan, stateDirs, slotRegistry, runtimeSessionStore, sourceAttemptResolver) {
10133
10144
  if (claimedTask.task.taskType === "freeform") {
10134
10145
  const continueFrom = claimedTask.task.input.continueFrom;
10135
- if (!continueFrom) return basePlan;
10146
+ if (!continueFrom) return maybeAttachRetrySession(claimedTask, basePlan, stateDirs, slotRegistry, runtimeSessionStore);
10136
10147
  const resolution = await resolveWarmSlot(slotRegistry, runtimeSessionStore, claimedTask.task.teamId, continueFrom.taskId, continueFrom.attemptN, stateDirs);
10137
10148
  if (resolution.kind === "missing") throw new ProducerContextResolutionError(`Continuation source task ${continueFrom.taskId} attempt ${continueFrom.attemptN} has no local runtime slot or durable runtime session — claim affinity filter should have prevented this claim`);
10138
10149
  if (resolution.kind === "no-session-path") throw new ProducerContextResolutionError(`Continuation source attempt ${continueFrom.taskId}/${continueFrom.attemptN} has no persisted Pi session path`);
@@ -10164,7 +10175,7 @@ async function maybeAttachWarmSlotContext(claimedTask, basePlan, stateDirs, slot
10164
10175
  return {
10165
10176
  ...basePlan,
10166
10177
  workspaceMode: "dedicated_worktree",
10167
- workspaceId: recoveredBranch ? `extend-${continueFrom.taskId}-attempt-${continueFrom.attemptN}` : null,
10178
+ workspaceId: recoveredBranch ? buildAttemptWorkspaceId(claimedTask) : null,
10168
10179
  worktreeBranch: recoveredBranch,
10169
10180
  sessionPersistence: {
10170
10181
  sessionDir,
@@ -10193,7 +10204,7 @@ async function maybeAttachWarmSlotContext(claimedTask, basePlan, stateDirs, slot
10193
10204
  return {
10194
10205
  ...basePlan,
10195
10206
  workspaceMode: "dedicated_worktree",
10196
- workspaceId: resolution.producerSlot.workspace?.workspaceId ?? null,
10207
+ workspaceId: parentBranch ? buildAttemptWorkspaceId(claimedTask) : null,
10197
10208
  worktreeBranch: parentBranch,
10198
10209
  sessionPersistence: {
10199
10210
  sessionDir,
@@ -10215,7 +10226,7 @@ async function maybeAttachWarmSlotContext(claimedTask, basePlan, stateDirs, slot
10215
10226
  worktreeBranch: null,
10216
10227
  workspaceKind: "scratch",
10217
10228
  workspaceSeed: {
10218
- copyFromPath: resolution.workspacePath,
10229
+ copyFromPath: resolveProducerWorkspaceCopySource(resolution.producerSlot, stateDirs),
10219
10230
  source: "producer"
10220
10231
  },
10221
10232
  sessionPersistence: {
@@ -10224,6 +10235,25 @@ async function maybeAttachWarmSlotContext(claimedTask, basePlan, stateDirs, slot
10224
10235
  }
10225
10236
  };
10226
10237
  }
10238
+ function buildAttemptWorkspaceId(claimedTask) {
10239
+ return `daemon-task-${claimedTask.task.id}-attempt-${claimedTask.attemptN}`;
10240
+ }
10241
+ async function maybeAttachRetrySession(claimedTask, basePlan, stateDirs, slotRegistry, runtimeSessionStore) {
10242
+ if (claimedTask.attemptN <= 1 || !basePlan.sessionPersistence?.sessionDir) return basePlan;
10243
+ const resolution = await resolveWarmSlot(slotRegistry, runtimeSessionStore, claimedTask.task.teamId, claimedTask.task.id, claimedTask.attemptN - 1, stateDirs);
10244
+ if (resolution.kind === "missing" || resolution.kind === "no-session-path") return basePlan;
10245
+ const sourceSessionDir = dirname(resolution.sessionPath);
10246
+ const targetSessionDir = basePlan.sessionPersistence.sessionDir;
10247
+ if (sourceSessionDir === targetSessionDir) return basePlan;
10248
+ return {
10249
+ ...basePlan,
10250
+ worktreeBranch: basePlan.workspaceMode === "dedicated_worktree" && resolution.kind === "found" ? resolution.producerSlot.workspace?.worktreeBranch ?? basePlan.worktreeBranch : basePlan.worktreeBranch,
10251
+ sessionPersistence: {
10252
+ sessionDir: targetSessionDir,
10253
+ forkFromSessionPath: resolution.sessionPath
10254
+ }
10255
+ };
10256
+ }
10227
10257
  function buildForkBranch(parentBranch, childTaskId, childAttemptN) {
10228
10258
  return `${parentBranch}-fork-${childTaskId.slice(0, 8)}-${childAttemptN}`;
10229
10259
  }
@@ -10267,6 +10297,7 @@ var RETRYABLE_CODES = new Set([
10267
10297
  ]);
10268
10298
  var NON_RETRYABLE_CODES = new Set([
10269
10299
  "bad_api_key",
10300
+ "executor_threw",
10270
10301
  "invalid_api_key",
10271
10302
  "invalid_model",
10272
10303
  "max_turns_exceeded",
@@ -10309,17 +10340,18 @@ var NON_RETRYABLE_MESSAGE_PATTERNS = [
10309
10340
  /\bmax (?:turn|bash)/i
10310
10341
  ];
10311
10342
  async function classifyAttemptFailure(input) {
10312
- if (input.remainingAttempts !== null && input.remainingAttempts !== void 0) {
10313
- if (input.remainingAttempts <= 0) return {
10343
+ const deterministic = classifyDeterministically(input.error);
10344
+ if (input.remainingAttempts !== null && input.remainingAttempts !== void 0 && input.remainingAttempts <= 0) {
10345
+ const deterministicReason = deterministic === "ambiguous" ? "" : ` Deterministic policy classified the failure as ${deterministic}.`;
10346
+ return {
10314
10347
  error: withRetryInfo(input.error, {
10315
10348
  retryable: false,
10316
10349
  source: "attempts_exhausted",
10317
- reason: `Attempt budget exhausted at attempt ${input.attemptN}${input.maxAttempts ? ` of ${input.maxAttempts}` : ""}.`
10350
+ reason: `Attempt budget exhausted at attempt ${input.attemptN}${input.maxAttempts ? ` of ${input.maxAttempts}` : ""}.${deterministicReason}`
10318
10351
  }),
10319
10352
  source: "attempts_exhausted"
10320
10353
  };
10321
10354
  }
10322
- const deterministic = classifyDeterministically(input.error);
10323
10355
  if (deterministic !== "ambiguous") {
10324
10356
  const retryable = deterministic === "retryable";
10325
10357
  const source = input.error.retryable === retryable ? "explicit" : "deterministic";
@@ -10895,6 +10927,269 @@ function createRuntimeProfileRetryTriage(options) {
10895
10927
  });
10896
10928
  }
10897
10929
  //#endregion
10930
+ //#region src/lib/runtime-resource-reaper.ts
10931
+ var RUNTIME_SLOT_PAGE_LIMIT = 200;
10932
+ var ATTEMPT_LOOKUP_CONCURRENCY = 8;
10933
+ var TERMINAL_ATTEMPT_STATUSES = new Set([
10934
+ "completed",
10935
+ "failed",
10936
+ "cancelled",
10937
+ "aborted",
10938
+ "timed_out"
10939
+ ]);
10940
+ /**
10941
+ * Reap daemon-owned local resources whose runtime slot is no longer usable.
10942
+ * The destructive path is deliberately conservative: uncertainty about slot
10943
+ * ownership, attempt state, listing completeness, or real filesystem
10944
+ * containment retains resources rather than risking another daemon's data.
10945
+ */
10946
+ async function reapRuntimeSlotResources(deps, input) {
10947
+ const now = input.now ?? Date.now();
10948
+ const [activePage, idlePage] = await Promise.all([listOwnedSlots(deps.runtimeSlotStore, input, "active"), listOwnedSlots(deps.runtimeSlotStore, input, "idle")]);
10949
+ const active = activePage.items;
10950
+ const idle = idlePage.items;
10951
+ const slots = [...active, ...idle];
10952
+ const result = {
10953
+ failed: 0,
10954
+ failures: [],
10955
+ removedSessions: 0,
10956
+ removedWorkspaces: 0,
10957
+ scanned: slots.length,
10958
+ truncated: activePage.truncated || idlePage.truncated,
10959
+ unsafePathDetails: [],
10960
+ unsafePaths: 0
10961
+ };
10962
+ const reaped = new Set(idle.filter((item) => item.slot.expiresAtMs <= now).map((item) => item.slot.id));
10963
+ for (const slotId of await findTerminalActiveSlotIds(deps, active, result)) reaped.add(slotId);
10964
+ const retainedSessionDirs = new Set(slots.filter((item) => !reaped.has(item.slot.id)).flatMap((item) => item.session?.sessionDir ? [resolve(item.session.sessionDir)] : []));
10965
+ const retainedWorkspacePaths = new Set(slots.filter((item) => !reaped.has(item.slot.id)).flatMap((item) => item.workspace?.worktreePath ? [resolve(item.workspace.worktreePath)] : []));
10966
+ const worktrees = input.mainWorktree ? await listRegisteredWorktrees(input.mainWorktree) : {
10967
+ kind: "ready",
10968
+ paths: /* @__PURE__ */ new Set()
10969
+ };
10970
+ for (const item of slots) {
10971
+ if (!reaped.has(item.slot.id)) continue;
10972
+ if (!await slotStillQualifiesForReaping(deps, input, item, now, result)) continue;
10973
+ const sessionDir = item.session?.sessionDir;
10974
+ if (sessionDir && !retainedSessionDirs.has(resolve(sessionDir)) && existsSync(sessionDir)) if (!isRealPathInsideRoot(sessionDir, input.sessionRootDir)) recordUnsafePath(deps, result, item.slot.id, sessionDir, input.sessionRootDir);
10975
+ else try {
10976
+ rmSync(sessionDir, {
10977
+ force: true,
10978
+ recursive: true
10979
+ });
10980
+ result.removedSessions++;
10981
+ } catch (err) {
10982
+ recordFailure(deps, result, item.slot.id, sessionDir, input.sessionRootDir, err);
10983
+ }
10984
+ const workspacePath = item.workspace?.worktreePath;
10985
+ if (!workspacePath || retainedWorkspacePaths.has(resolve(workspacePath))) continue;
10986
+ const workspaceRoot = item.workspace?.kind === "scratch" ? input.scratchRootDir : input.mainWorktree ? resolve(input.mainWorktree, ".worktrees") : null;
10987
+ if (!workspaceRoot) {
10988
+ recordUnsafePath(deps, result, item.slot.id, workspacePath, null);
10989
+ continue;
10990
+ }
10991
+ await removeWorkspace(deps, result, worktrees, input.mainWorktree, item.slot.id, workspacePath, workspaceRoot, item.workspace?.kind === "scratch");
10992
+ }
10993
+ if (!result.truncated) await reapLegacyOrphanWorktrees(deps, input, worktrees, retainedSessionDirs, retainedWorkspacePaths, result);
10994
+ return result;
10995
+ }
10996
+ async function listOwnedSlots(store, input, state) {
10997
+ const listed = await store.listSlots({
10998
+ agentName: input.agentName,
10999
+ limit: RUNTIME_SLOT_PAGE_LIMIT,
11000
+ runtimeProfileId: input.runtimeProfileId,
11001
+ state,
11002
+ teamId: input.teamId
11003
+ });
11004
+ return {
11005
+ items: listed.filter((item) => runtimeSlotKeyBelongsToInstance(item.slot.slotKey, input.runtimeInstanceId)),
11006
+ truncated: listed.length === RUNTIME_SLOT_PAGE_LIMIT
11007
+ };
11008
+ }
11009
+ async function findTerminalActiveSlotIds(deps, active, result) {
11010
+ const byTask = /* @__PURE__ */ new Map();
11011
+ for (const item of active) {
11012
+ const taskSlots = byTask.get(item.slot.lastTaskId) ?? [];
11013
+ taskSlots.push(item);
11014
+ byTask.set(item.slot.lastTaskId, taskSlots);
11015
+ }
11016
+ const terminal = /* @__PURE__ */ new Set();
11017
+ await mapWithConcurrency([...byTask.entries()], ATTEMPT_LOOKUP_CONCURRENCY, async ([taskId, taskSlots]) => {
11018
+ let attempts;
11019
+ try {
11020
+ attempts = await deps.taskReader.listAttempts(taskId);
11021
+ } catch (err) {
11022
+ recordFailure(deps, result, null, `task:${taskId}`, null, err);
11023
+ return;
11024
+ }
11025
+ for (const item of taskSlots) {
11026
+ const attempt = attempts.find((candidate) => candidate.attemptN === item.slot.lastAttemptN);
11027
+ if (attempt && TERMINAL_ATTEMPT_STATUSES.has(attempt.status)) terminal.add(item.slot.id);
11028
+ }
11029
+ });
11030
+ return terminal;
11031
+ }
11032
+ async function slotStillQualifiesForReaping(deps, input, original, now, result) {
11033
+ let page;
11034
+ try {
11035
+ page = await listOwnedSlots(deps.runtimeSlotStore, input, original.slot.state);
11036
+ } catch (err) {
11037
+ recordFailure(deps, result, original.slot.id, `slot:${original.slot.id}`, null, err);
11038
+ return false;
11039
+ }
11040
+ const current = page.items.find((candidate) => candidate.slot.id === original.slot.id);
11041
+ if (!current) return false;
11042
+ if (current.slot.lastTaskId !== original.slot.lastTaskId || current.slot.lastAttemptN !== original.slot.lastAttemptN) return false;
11043
+ return current.slot.state === "active" || current.slot.state === "idle" && current.slot.expiresAtMs <= now;
11044
+ }
11045
+ async function reapLegacyOrphanWorktrees(deps, input, worktrees, retainedSessionDirs, retainedWorkspacePaths, result) {
11046
+ if (!input.mainWorktree) return;
11047
+ const workspaceRoot = resolve(input.mainWorktree, ".worktrees");
11048
+ if (!existsSync(workspaceRoot)) return;
11049
+ let entries;
11050
+ try {
11051
+ entries = readdirSync(workspaceRoot, { withFileTypes: true });
11052
+ } catch (err) {
11053
+ recordFailure(deps, result, null, workspaceRoot, workspaceRoot, err);
11054
+ return;
11055
+ }
11056
+ for (const entry of entries) {
11057
+ if (!entry.isDirectory() || !entry.name.startsWith("session-agent%3A")) continue;
11058
+ const workspacePath = resolve(workspaceRoot, entry.name);
11059
+ if (retainedWorkspacePaths.has(workspacePath)) continue;
11060
+ const sessionDir = resolve(input.sessionRootDir, entry.name.slice(8));
11061
+ if (retainedSessionDirs.has(sessionDir) || existsSync(sessionDir)) continue;
11062
+ await removeWorkspace(deps, result, worktrees, input.mainWorktree, null, workspacePath, workspaceRoot, false);
11063
+ }
11064
+ }
11065
+ async function removeWorkspace(deps, result, worktrees, mainWorktree, slotId, workspacePath, workspaceRoot, scratch) {
11066
+ if (!existsSync(workspacePath)) return;
11067
+ if (!isRealPathInsideRoot(workspacePath, workspaceRoot)) {
11068
+ recordUnsafePath(deps, result, slotId, workspacePath, workspaceRoot);
11069
+ return;
11070
+ }
11071
+ if (scratch || !mainWorktree) {
11072
+ try {
11073
+ rmSync(workspacePath, {
11074
+ force: true,
11075
+ recursive: true
11076
+ });
11077
+ result.removedWorkspaces++;
11078
+ } catch (err) {
11079
+ recordFailure(deps, result, slotId, workspacePath, workspaceRoot, err);
11080
+ }
11081
+ return;
11082
+ }
11083
+ if (worktrees.kind === "unavailable") {
11084
+ recordFailure(deps, result, slotId, workspacePath, workspaceRoot, new Error(worktrees.reason));
11085
+ return;
11086
+ }
11087
+ try {
11088
+ const canonical = realpathSync(workspacePath);
11089
+ if (worktrees.paths.has(canonical)) {
11090
+ await execFileText("git", [
11091
+ "-C",
11092
+ mainWorktree,
11093
+ "worktree",
11094
+ "remove",
11095
+ "--force",
11096
+ workspacePath
11097
+ ]);
11098
+ worktrees.paths.delete(canonical);
11099
+ } else rmSync(workspacePath, {
11100
+ force: true,
11101
+ recursive: true
11102
+ });
11103
+ result.removedWorkspaces++;
11104
+ } catch (err) {
11105
+ recordFailure(deps, result, slotId, workspacePath, workspaceRoot, err);
11106
+ }
11107
+ }
11108
+ async function listRegisteredWorktrees(mainWorktree) {
11109
+ try {
11110
+ const list = await execFileText("git", [
11111
+ "-C",
11112
+ mainWorktree,
11113
+ "worktree",
11114
+ "list",
11115
+ "--porcelain"
11116
+ ]);
11117
+ return {
11118
+ kind: "ready",
11119
+ paths: new Set(list.split("\n").filter((line) => line.startsWith("worktree ")).map((line) => canonicalPath(line.slice(9))))
11120
+ };
11121
+ } catch (err) {
11122
+ return {
11123
+ kind: "unavailable",
11124
+ reason: errorMessage(err)
11125
+ };
11126
+ }
11127
+ }
11128
+ function isRealPathInsideRoot(path, root) {
11129
+ try {
11130
+ return isResolvedPathInsideRoot$1(realpathSync(path), realpathSync(root));
11131
+ } catch {
11132
+ return false;
11133
+ }
11134
+ }
11135
+ function isResolvedPathInsideRoot$1(path, root) {
11136
+ const rel = relative(root, path);
11137
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
11138
+ }
11139
+ function canonicalPath(path) {
11140
+ try {
11141
+ return realpathSync(path);
11142
+ } catch {
11143
+ return resolve(path);
11144
+ }
11145
+ }
11146
+ function recordUnsafePath(deps, result, slotId, path, root) {
11147
+ const issue = {
11148
+ kind: "unsafe_path",
11149
+ path,
11150
+ reason: root === null ? "No daemon-owned root was available for this workspace." : "Candidate or root could not be resolved inside the daemon-owned root.",
11151
+ root,
11152
+ slotId
11153
+ };
11154
+ result.unsafePaths++;
11155
+ result.unsafePathDetails.push(issue);
11156
+ emitIssue(deps, issue);
11157
+ }
11158
+ function recordFailure(deps, result, slotId, path, root, err) {
11159
+ const issue = {
11160
+ kind: "failure",
11161
+ path,
11162
+ reason: errorMessage(err),
11163
+ root,
11164
+ slotId
11165
+ };
11166
+ result.failed++;
11167
+ result.failures.push(issue);
11168
+ emitIssue(deps, issue);
11169
+ }
11170
+ function emitIssue(deps, issue) {
11171
+ try {
11172
+ deps.onIssue?.(issue);
11173
+ } catch {}
11174
+ }
11175
+ function errorMessage(err) {
11176
+ return err instanceof Error ? err.message : String(err);
11177
+ }
11178
+ async function execFileText(file, args) {
11179
+ return new Promise((resolvePromise, reject) => {
11180
+ execFile(file, args, {
11181
+ encoding: "utf8",
11182
+ maxBuffer: 10 * 1024 * 1024
11183
+ }, (err, stdout) => {
11184
+ if (err) reject(err instanceof Error ? err : /* @__PURE__ */ new Error("Child process failed without an Error instance."));
11185
+ else resolvePromise(stdout);
11186
+ });
11187
+ });
11188
+ }
11189
+ async function mapWithConcurrency(items, concurrency, worker) {
11190
+ for (let index = 0; index < items.length; index += concurrency) await Promise.all(items.slice(index, index + concurrency).map(worker));
11191
+ }
11192
+ //#endregion
10898
11193
  //#region src/lib/runtime-sessions.ts
10899
11194
  function resolveRuntimeSessionKind(claimedTask) {
10900
11195
  const continueFrom = resolveContinueFrom(claimedTask);
@@ -11031,6 +11326,8 @@ function createApiRuntimeSlotStore(args) {
11031
11326
  lastAttemptN: resolved.slot.lastAttemptN,
11032
11327
  lastTaskId: resolved.slot.lastTaskId,
11033
11328
  runtimeProfileId: resolved.slot.runtimeProfileId,
11329
+ slotKey: resolved.slot.slotKey,
11330
+ state: resolved.slot.state,
11034
11331
  taskType: resolved.slot.taskType
11035
11332
  },
11036
11333
  session: resolved.slot.sessionDir ? {
@@ -11202,12 +11499,13 @@ async function runPolling(opts) {
11202
11499
  agent: ctx.agent,
11203
11500
  profiles: profileValues,
11204
11501
  teamId,
11205
- cwd: process.cwd()
11502
+ cwd: ctx.agentRootDir
11206
11503
  });
11207
11504
  for (const profile of profiles) validateRuntimeProfilePrerequisites(profile, cfg.profilePrerequisiteEnv, cfg.profilePrerequisitePath);
11208
11505
  const slotRegistry = createApiRuntimeSlotStore({ agent: ctx.agent });
11209
11506
  const runtimeSessionStore = createApiRuntimeSessionStore({ agent: ctx.agent });
11210
11507
  const sourceAttemptResolver = createApiSourceAttemptResolver({ agent: ctx.agent });
11508
+ const runtimeInstanceId = createRuntimeInstanceId();
11211
11509
  const runtimes = /* @__PURE__ */ new Map();
11212
11510
  for (const profile of profiles) {
11213
11511
  const common = parseCommonOptions(values, { runtimeDefaults: {
@@ -11227,7 +11525,8 @@ async function runPolling(opts) {
11227
11525
  const stateDirs = ensureDaemonStateDirs(sandbox.rootDir);
11228
11526
  const slotIdentity = {
11229
11527
  agentName: common.agent,
11230
- runtimeProfileId: profile.id
11528
+ runtimeProfileId: profile.id,
11529
+ runtimeInstanceId
11231
11530
  };
11232
11531
  const executionPlans = createExecutionPlanCache({
11233
11532
  stateDirs,
@@ -11335,6 +11634,47 @@ async function runPolling(opts) {
11335
11634
  piAgentDir: piAgentDir.path,
11336
11635
  piAgentDirSource: piAgentDir.source
11337
11636
  }, "agent-daemon.starting");
11637
+ let reaperRunning = false;
11638
+ const reapRuntimeResources = async () => {
11639
+ if (reaperRunning) return;
11640
+ reaperRunning = true;
11641
+ try {
11642
+ for (const profile of profiles) {
11643
+ const selected = requireRuntime(runtimes, profile.id);
11644
+ const result = await reapRuntimeSlotResources({
11645
+ onIssue: (issue) => {
11646
+ rootLogger.warn({
11647
+ ...issue,
11648
+ runtimeProfileId: profile.id
11649
+ }, "agent-daemon.runtime_resource_reap_issue");
11650
+ },
11651
+ runtimeSlotStore: slotRegistry,
11652
+ taskReader: ctx.agent.tasks
11653
+ }, {
11654
+ agentName: selected.common.agent,
11655
+ mainWorktree: resolveMainWorktree$1(selected.sandbox.rootDir),
11656
+ runtimeInstanceId,
11657
+ runtimeProfileId: profile.id,
11658
+ sessionRootDir: selected.stateDirs.piSessionsDir,
11659
+ scratchRootDir: join(selected.stateDirs.rootDir, "task-workspaces"),
11660
+ teamId
11661
+ });
11662
+ if (result.removedSessions > 0 || result.removedWorkspaces > 0 || result.failed > 0 || result.unsafePaths > 0 || result.truncated) rootLogger.info({
11663
+ ...result,
11664
+ runtimeProfileId: profile.id
11665
+ }, "agent-daemon.runtime_resources_reaped");
11666
+ }
11667
+ } catch (err) {
11668
+ rootLogger.warn({ err }, "agent-daemon.runtime_resource_reap_failed");
11669
+ } finally {
11670
+ reaperRunning = false;
11671
+ }
11672
+ };
11673
+ await reapRuntimeResources();
11674
+ const reaperTimer = setInterval(() => {
11675
+ reapRuntimeResources();
11676
+ }, 6e4);
11677
+ reaperTimer.unref();
11338
11678
  const outputs = [];
11339
11679
  try {
11340
11680
  runtime = new AgentRuntime({
@@ -11512,7 +11852,7 @@ async function runPolling(opts) {
11512
11852
  sessionDir: executionPlan.sessionPersistence.sessionDir,
11513
11853
  sessionPath: resolveLatestPiSessionPath(executionPlan.sessionPersistence.sessionDir),
11514
11854
  workspaceId: executionPlan.workspaceId,
11515
- worktreePath: resolveRecordedWorkspacePath$1(stateDirs.rootDir, executionPlan),
11855
+ worktreePath: resolveRecordedWorkspacePath$1(stateDirs.rootDir, sandbox.rootDir, executionPlan),
11516
11856
  worktreeBranch: executionPlan.worktreeBranch,
11517
11857
  workspaceKind: executionPlan.workspaceKind,
11518
11858
  lastTaskId: claimedTask.task.id,
@@ -11568,15 +11908,23 @@ async function runPolling(opts) {
11568
11908
  rootLogger.info({ processed: drained.length }, "agent-daemon.drained");
11569
11909
  return drained.some((o) => o.status !== "completed") ? 1 : 0;
11570
11910
  } finally {
11911
+ clearInterval(reaperTimer);
11571
11912
  signalHandlers.dispose();
11572
11913
  await slotRegistry.close();
11573
11914
  await otelShutdown();
11574
11915
  await shutdownLogger();
11575
11916
  }
11576
11917
  }
11577
- function resolveRecordedWorkspacePath$1(stateRootDir, executionPlan) {
11918
+ function resolveRecordedWorkspacePath$1(stateRootDir, mountPath, executionPlan) {
11578
11919
  if (!executionPlan.workspaceId) return null;
11579
- return executionPlan.workspaceMode === "scratch_mount" ? join(stateRootDir, "task-workspaces", executionPlan.workspaceId) : join(findMainWorktree(), ".worktrees", executionPlan.workspaceId);
11920
+ return executionPlan.workspaceMode === "scratch_mount" ? join(stateRootDir, "task-workspaces", executionPlan.workspaceId) : join(findMainWorktree(mountPath), ".worktrees", executionPlan.workspaceId);
11921
+ }
11922
+ function resolveMainWorktree$1(mountPath) {
11923
+ try {
11924
+ return findMainWorktree(mountPath);
11925
+ } catch {
11926
+ return null;
11927
+ }
11580
11928
  }
11581
11929
  function runtimeForClaimedTask(runtimes, claimedTask) {
11582
11930
  if (!claimedTask.profileId) throw new Error(`Claimed task ${claimedTask.task.id} did not include a selected runtime profile`);
@@ -11668,7 +12016,7 @@ async function runOnce(argv) {
11668
12016
  agent: ctx.agent,
11669
12017
  profile: values.profile,
11670
12018
  teamId: values.team,
11671
- cwd: process.cwd()
12019
+ cwd: ctx.agentRootDir
11672
12020
  });
11673
12021
  validateRuntimeProfilePrerequisites(profile, cfg.profilePrerequisiteEnv, cfg.profilePrerequisitePath);
11674
12022
  opts = parseCommonOptions(values, { runtimeDefaults: {
@@ -11690,9 +12038,11 @@ async function runOnce(argv) {
11690
12038
  const slotRegistry = createApiRuntimeSlotStore({ agent: ctx.agent });
11691
12039
  const runtimeSessionStore = createApiRuntimeSessionStore({ agent: ctx.agent });
11692
12040
  const sourceAttemptResolver = createApiSourceAttemptResolver({ agent: ctx.agent });
12041
+ const runtimeInstanceId = createRuntimeInstanceId();
11693
12042
  const slotIdentity = {
11694
12043
  agentName: opts.agent,
11695
- runtimeProfileId: profile.id
12044
+ runtimeProfileId: profile.id,
12045
+ runtimeInstanceId
11696
12046
  };
11697
12047
  const executionPlans = createExecutionPlanCache({
11698
12048
  stateDirs,
@@ -11754,6 +12104,26 @@ async function runOnce(argv) {
11754
12104
  piAgentDir: piAgentDir.path,
11755
12105
  piAgentDirSource: piAgentDir.source
11756
12106
  }, "agent-daemon.starting");
12107
+ try {
12108
+ const reaped = await reapRuntimeSlotResources({
12109
+ onIssue: (issue) => {
12110
+ rootLogger.warn(issue, "agent-daemon.runtime_resource_reap_issue");
12111
+ },
12112
+ runtimeSlotStore: slotRegistry,
12113
+ taskReader: ctx.agent.tasks
12114
+ }, {
12115
+ agentName: opts.agent,
12116
+ mainWorktree: resolveMainWorktree(sandbox.rootDir),
12117
+ runtimeInstanceId,
12118
+ runtimeProfileId: profile.id,
12119
+ sessionRootDir: stateDirs.piSessionsDir,
12120
+ scratchRootDir: join(stateDirs.rootDir, "task-workspaces"),
12121
+ teamId: profile.teamId
12122
+ });
12123
+ if (reaped.removedSessions > 0 || reaped.removedWorkspaces > 0 || reaped.failed > 0 || reaped.unsafePaths > 0 || reaped.truncated) rootLogger.info(reaped, "agent-daemon.runtime_resources_reaped");
12124
+ } catch (err) {
12125
+ rootLogger.warn({ err }, "agent-daemon.runtime_resource_reap_failed");
12126
+ }
11757
12127
  let runtime = null;
11758
12128
  let activeAttemptN = null;
11759
12129
  const signalHandlers = installShutdownSignalHandlers({
@@ -11843,7 +12213,7 @@ async function runOnce(argv) {
11843
12213
  sessionDir: executionPlan.sessionPersistence.sessionDir,
11844
12214
  sessionPath: resolveLatestPiSessionPath(executionPlan.sessionPersistence.sessionDir),
11845
12215
  workspaceId: executionPlan.workspaceId,
11846
- worktreePath: resolveRecordedWorkspacePath(stateDirs.rootDir, executionPlan),
12216
+ worktreePath: resolveRecordedWorkspacePath(stateDirs.rootDir, sandbox.rootDir, executionPlan),
11847
12217
  worktreeBranch: executionPlan.worktreeBranch,
11848
12218
  workspaceKind: executionPlan.workspaceKind,
11849
12219
  lastTaskId: claimedTask.task.id,
@@ -11939,9 +12309,16 @@ async function runOnce(argv) {
11939
12309
  await shutdownLogger();
11940
12310
  }
11941
12311
  }
11942
- function resolveRecordedWorkspacePath(stateRootDir, executionPlan) {
12312
+ function resolveRecordedWorkspacePath(stateRootDir, mountPath, executionPlan) {
11943
12313
  if (!executionPlan.workspaceId) return null;
11944
- return executionPlan.workspaceMode === "scratch_mount" ? join(stateRootDir, "task-workspaces", executionPlan.workspaceId) : join(findMainWorktree(), ".worktrees", executionPlan.workspaceId);
12314
+ return executionPlan.workspaceMode === "scratch_mount" ? join(stateRootDir, "task-workspaces", executionPlan.workspaceId) : join(findMainWorktree(mountPath), ".worktrees", executionPlan.workspaceId);
12315
+ }
12316
+ function resolveMainWorktree(mountPath) {
12317
+ try {
12318
+ return findMainWorktree(mountPath);
12319
+ } catch {
12320
+ return null;
12321
+ }
11945
12322
  }
11946
12323
  //#endregion
11947
12324
  //#region src/cli/poll.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@themoltnet/agent-daemon",
3
- "version": "0.32.0",
3
+ "version": "0.32.1",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "description": "MoltNet agent daemon — claims and executes tasks (fulfill_brief, assess_brief) from the MoltNet task-service via Pi-headless. CLI: moltnet-agent.",
@@ -52,8 +52,8 @@
52
52
  "pino": "^10.3.1",
53
53
  "pino-pretty": "^13.1.3",
54
54
  "@themoltnet/agent-runtime": "0.36.6",
55
- "@themoltnet/pi-extension": "0.36.0",
56
- "@themoltnet/sdk": "0.127.0"
55
+ "@themoltnet/sdk": "0.127.0",
56
+ "@themoltnet/pi-extension": "0.36.1"
57
57
  },
58
58
  "devDependencies": {
59
59
  "tsx": "^4.7.0",
@@ -62,8 +62,8 @@
62
62
  "vitest": "^3.0.0",
63
63
  "@moltnet/bootstrap": "0.1.0",
64
64
  "@moltnet/crypto-service": "0.1.0",
65
- "@moltnet/observability": "0.1.0",
66
- "@moltnet/tasks": "0.1.0"
65
+ "@moltnet/tasks": "0.1.0",
66
+ "@moltnet/observability": "0.1.0"
67
67
  },
68
68
  "nx": {
69
69
  "projectType": "application",