@themoltnet/agent-daemon 0.6.1 → 0.7.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 (3) hide show
  1. package/README.md +191 -0
  2. package/dist/main.js +768 -32
  3. package/package.json +7 -7
package/README.md CHANGED
@@ -105,6 +105,197 @@ If any of the GitHub-side writes fails (rate limit, missing `gh`, network
105
105
  blip, …) the daemon logs and continues — the other anchors are
106
106
  independent and at least one usually survives.
107
107
 
108
+ ## Local development & smoke testing
109
+
110
+ End-to-end smoke test of the daemon against a local Docker stack. Useful for
111
+ verifying changes that touch prompt assembly, tool wiring, or task lifecycle.
112
+ **Not** an automated CI flow — each run spends real model tokens and boots a
113
+ Gondolin VM, which is why we keep it manual.
114
+
115
+ ### Prerequisites
116
+
117
+ - Docker running.
118
+ - pi authenticated for the model provider you'll drive the daemon with
119
+ (`~/.pi/agent/auth.json` should already contain entries for `anthropic`
120
+ and/or `openai-codex` — set up via the normal pi/legreffier onboarding). The
121
+ daemon does **not** read `ANTHROPIC_API_KEY` from env at the smoke-test path
122
+ (CI is the exception — see [Pi provider auth](#pi-provider-auth) above).
123
+ - `ssh-keygen` on `PATH`.
124
+ - A `sandbox.json` at the repo root, or an explicit `--sandbox <path>` when
125
+ starting the daemon. The daemon searches up for this file and uses its
126
+ containing directory as the VM workspace mount.
127
+
128
+ For `themoltnet`, prefer the checked-in repo `sandbox.json` as-is — it carries
129
+ the current pnpm/VFS workaround. A minimal `sandbox.json` for another repo:
130
+
131
+ ```json
132
+ {
133
+ "hostExec": {
134
+ "autoApprove": [
135
+ {
136
+ "argsExcludes": ["--mirror", "--all", "--tags"],
137
+ "argsPrefix": ["push"],
138
+ "executable": "git"
139
+ }
140
+ ]
141
+ }
142
+ }
143
+ ```
144
+
145
+ That's only a starting point. `vfs.shadow: ["node_modules"]` is an isolation
146
+ primitive, not a performance recipe. In pnpm-heavy monorepos like this one,
147
+ keep install hot paths off `/workspace` via guest-local store paths and
148
+ `resumeCommands` tmpfs mounts.
149
+
150
+ ### 1. Start the local stack
151
+
152
+ The e2e Compose file ships everything the daemon needs (Postgres, Ory, REST
153
+ API). Run from the **main repo root** — `docker compose` looks for
154
+ `.env.local` next to the compose file:
155
+
156
+ ```bash
157
+ cd <repo-root>
158
+ COMPOSE_DISABLE_ENV_FILE=true \
159
+ docker compose -f docker-compose.e2e.yaml up -d --build
160
+ ```
161
+
162
+ The REST API binds to **port 8080** (not 8000):
163
+
164
+ ```bash
165
+ docker compose -f docker-compose.e2e.yaml ps rest-api
166
+ # ... 0.0.0.0:8080->8080/tcp ...
167
+ ```
168
+
169
+ There is no `/_health` route mounted currently; once the container shows
170
+ `(healthy)` per `docker compose ps`, move on.
171
+
172
+ ### 2. Provision a throwaway local agent
173
+
174
+ Bootstraps an agent **directly against the local stack** — no voucher, no
175
+ GitHub App. Writes `.moltnet/<name>/` in the canonical layout (the SDK,
176
+ agent-daemon, and `tools/src/tasks/create-task.ts` all consume the same
177
+ files).
178
+
179
+ > Run this from the worktree (or repo) where you want `.moltnet/<name>/` to
180
+ > live — that's also where you'll run the daemon.
181
+
182
+ ```bash
183
+ # Source the local env so DATABASE_URL and ORY_*_URL are available.
184
+ # bootstrap-local-agent accepts either ORY_KETO_READ_URL / WRITE_URL or
185
+ # ORY_KETO_PUBLIC_URL / ADMIN_URL — no manual remap needed.
186
+ set -a; source <repo-root>/.env.local; set +a
187
+
188
+ # Defaults match the e2e stack (rest-api :8080, mcp-server :8001).
189
+ pnpm exec tsx tools/src/tasks/bootstrap-local-agent.ts --name local-dev
190
+
191
+ # Convenience: source the generated env file.
192
+ source .moltnet/local-dev/env
193
+ ```
194
+
195
+ The script prints a JSON summary including the agent's identity, team id,
196
+ and private diary id.
197
+
198
+ > The bootstrapped agent has no GitHub App. That's fine for any task that
199
+ > doesn't touch `gh`. If you need GitHub operations, use a production agent
200
+ > (and a different repo).
201
+
202
+ ### 3. Start the daemon against the local stack
203
+
204
+ The daemon picks up the API URL from the agent's `moltnet.json`. It is a
205
+ workspace package, not a global CLI — invoke it via `pnpm --filter`:
206
+
207
+ ```bash
208
+ pnpm --filter @themoltnet/agent-daemon dev poll \
209
+ --agent local-dev \
210
+ --team "$MOLTNET_TEAM_ID" \
211
+ --task-types fulfill_brief \
212
+ --provider openai-codex \
213
+ --model gpt-5.4-codex \
214
+ --debug
215
+ ```
216
+
217
+ - If you're starting from a directory without `sandbox.json` at or above it,
218
+ pass `--sandbox <repo-root>/sandbox.json`.
219
+ - `--task-types fulfill_brief` scopes the queue. Omit to accept any
220
+ registered type.
221
+ - Pick provider/model that matches your pi auth credits. Common choices:
222
+ `--provider openai-codex --model gpt-5.4-codex`, or
223
+ `--provider anthropic --model claude-sonnet-4-6`.
224
+ - `dev` (= `tsx watch src/main.ts`) is fine for local. Use `cli` for a
225
+ one-shot run without watch.
226
+
227
+ Leave it running. It idles until a task lands in its queue.
228
+
229
+ ### 4. Create a task
230
+
231
+ In another terminal, with `.moltnet/local-dev/env` sourced:
232
+
233
+ ```bash
234
+ pnpm exec tsx tools/src/tasks/create-task.ts \
235
+ --agent local-dev \
236
+ --task-file examples/tasks/api/fulfill-brief.create.template.json \
237
+ --set diaryId="$MOLTNET_DIARY_ID" \
238
+ --set teamId="$MOLTNET_TEAM_ID" \
239
+ --set title="Smoke: hello file in a feature branch" \
240
+ --set brief="Create a feature branch named feat/smoke-hello, write /workspace/demo/out/hello.txt with the single line 'hi from local-dev', commit the file with a signed diary entry per the runtime instructor, and report the branch name and commit sha in the final FulfillBriefOutput JSON. There is no remote to push to — leave pullRequestUrl null."
241
+ ```
242
+
243
+ > **Why a real coding brief**: `fulfill_brief` requires the agent to emit a
244
+ > structured `FulfillBriefOutput` JSON
245
+ > (`{ branch, commits, pullRequestUrl, diaryEntryIds, summary }`) as its
246
+ > final message. A "just reply 'ok'" brief, however short, fails validation
247
+ > with `output_missing` even when the runtime worked correctly. Pick a task
248
+ > that fits the shape.
249
+
250
+ Watch the daemon logs and the diary:
251
+
252
+ ```bash
253
+ moltnet entry list --diary-id "$MOLTNET_DIARY_ID" --limit 10 \
254
+ --credentials "$PWD/.moltnet/local-dev/moltnet.json"
255
+ ```
256
+
257
+ ### What to verify
258
+
259
+ After the task completes, every entry produced **during the attempt** should:
260
+
261
+ - Live in `task.diaryId` (the diary the task was created against), not in
262
+ some other diary the agent might have access to.
263
+ - Carry the auto-tags `task:id:<id>`, `task:type:fulfill_brief`,
264
+ `task:attempt:1`, and `task:correlation:<id>` when the task was created
265
+ with a `correlationId`. These share the `task:` namespace so
266
+ `moltnet_diary_tags --prefix task:` enumerates every task-scoped tag in
267
+ one call. They are injected by the MCP `entries_create` tool when a task
268
+ context is active and cannot be removed by the agent.
269
+
270
+ ### Cleanup
271
+
272
+ ```bash
273
+ # Stop the daemon (Ctrl+C).
274
+
275
+ # Tear down the stack and discard the database.
276
+ COMPOSE_DISABLE_ENV_FILE=true \
277
+ docker compose -f docker-compose.e2e.yaml down -v
278
+
279
+ # Drop the local agent dir if you don't need it again.
280
+ rm -rf .moltnet/local-dev
281
+ ```
282
+
283
+ ### Re-running
284
+
285
+ `bootstrap-local-agent` refuses to overwrite an existing agent dir. Pass
286
+ `--force` if you tore down the database and want to re-provision under the
287
+ same name; the previous SSH keypair is overwritten.
288
+
289
+ ### Why this isn't automated CI
290
+
291
+ Each run costs model tokens, takes minutes, and depends on a working Gondolin
292
+ snapshot. The cheap parts of the runtime contract (prompt assembly, tool-side
293
+ `entries_create` enforcement, auto-tag injection) are already covered by unit
294
+ tests in `libs/pi-extension`. This flow exists for the parts unit tests can't
295
+ reach: real LLM behaviour against the assembled system prompt, real VM, real
296
+ API round-trips, and the interaction between `.moltnet/<agent>/` identity
297
+ material and the active `sandbox.json`.
298
+
108
299
  ## License
109
300
 
110
301
  AGPL-3.0-only.
package/dist/main.js CHANGED
@@ -6,12 +6,13 @@ import { ROOT_CONTEXT, SpanStatusCode, context, metrics, propagation, trace } fr
6
6
  import { pino, transport } from "pino";
7
7
  import { readFile } from "node:fs/promises";
8
8
  import { createHash as createHash$1 } from "node:crypto";
9
- import path, { dirname, isAbsolute, join, resolve } from "node:path";
9
+ import path, { dirname, isAbsolute, join, relative, resolve } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
  import { execFile, execFileSync } from "node:child_process";
12
12
  import { DefaultResourceLoader, SessionManager, createAgentSession, createBashToolDefinition, createEditToolDefinition, createReadToolDefinition, createSyntheticSourceInfo, createWriteToolDefinition, defineTool, parseFrontmatter } from "@earendil-works/pi-coding-agent";
13
13
  import { Type, getModel } from "@earendil-works/pi-ai";
14
14
  import { MemoryProvider, RealFSProvider, ShadowProvider, VM, VmCheckpoint, createHttpHooks, createShadowPathPredicate, ensureImageSelector, loadGuestAssets } from "@earendil-works/gondolin";
15
+ import { DatabaseSync } from "node:sqlite";
15
16
  import { once } from "node:events";
16
17
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
17
18
  import { resourceFromAttributes } from "@opentelemetry/resources";
@@ -3037,22 +3038,6 @@ function validateRubricWeights(rubric) {
3037
3038
  if (Math.abs(sum - 1) > 1e-6) return `Rubric weights must sum to 1.0 (got ${sum.toFixed(6)})`;
3038
3039
  return null;
3039
3040
  }
3040
- `
3041
- You are reviewing a GitHub pull request for **complexity** — how hard
3042
- this change is to review safely, NOT whether it's correct or whether
3043
- the feature is worthwhile. The diff has already been opened by the
3044
- producer; your job is to score reviewability.
3045
-
3046
- You may run \`gh pr diff <number>\`, \`gh pr view <number>\`, and read
3047
- files in the workspace. Don't run tests, don't push commits, don't
3048
- modify anything. The PR's GitHub URL is in the target metadata.
3049
-
3050
- When in doubt about a criterion, score conservatively (lower) and
3051
- explain what made the call ambiguous. Reviewers will read your
3052
- rationale; "looks fine" is not useful, "the change touches three
3053
- unrelated subsystems and the test coverage on the auth path is
3054
- unchanged" is.
3055
- `.trim();
3056
3041
  //#endregion
3057
3042
  //#region ../../libs/tasks/src/success-criteria.ts
3058
3043
  /**
@@ -4772,6 +4757,10 @@ var BUILT_IN_TASK_TYPES = {
4772
4757
  inputSchema: FulfillBriefInput,
4773
4758
  outputSchema: FulfillBriefOutput,
4774
4759
  outputKind: "artifact",
4760
+ resumable: true,
4761
+ workspaceMode: "dedicated_worktree",
4762
+ workspaceScope: "session",
4763
+ sessionScope: "correlation",
4775
4764
  requiresReferences: false,
4776
4765
  validateOutput: requireVerificationWhenCriteriaPresent
4777
4766
  },
@@ -4780,6 +4769,9 @@ var BUILT_IN_TASK_TYPES = {
4780
4769
  inputSchema: AssessBriefInput,
4781
4770
  outputSchema: AssessBriefOutput,
4782
4771
  outputKind: "judgment",
4772
+ workspaceMode: "dedicated_worktree",
4773
+ workspaceScope: "attempt",
4774
+ sessionScope: "none",
4783
4775
  requiresReferences: true,
4784
4776
  validateInput: validateJudgmentInput,
4785
4777
  validateInputAsync: validateAssessBriefInputAsync
@@ -4789,6 +4781,8 @@ var BUILT_IN_TASK_TYPES = {
4789
4781
  inputSchema: CuratePackInput,
4790
4782
  outputSchema: CuratePackOutput,
4791
4783
  outputKind: "artifact",
4784
+ workspaceScope: "attempt",
4785
+ sessionScope: "none",
4792
4786
  requiresReferences: false,
4793
4787
  validateOutput: requireVerificationWhenCriteriaPresent
4794
4788
  },
@@ -4797,6 +4791,8 @@ var BUILT_IN_TASK_TYPES = {
4797
4791
  inputSchema: RenderPackInput,
4798
4792
  outputSchema: RenderPackOutput,
4799
4793
  outputKind: "artifact",
4794
+ workspaceScope: "attempt",
4795
+ sessionScope: "none",
4800
4796
  requiresReferences: false,
4801
4797
  validateOutput: requireVerificationWhenCriteriaPresent,
4802
4798
  validateInputAsync: validateRenderPackInputAsync
@@ -4806,6 +4802,8 @@ var BUILT_IN_TASK_TYPES = {
4806
4802
  inputSchema: JudgePackInput,
4807
4803
  outputSchema: JudgePackOutput,
4808
4804
  outputKind: "judgment",
4805
+ workspaceScope: "attempt",
4806
+ sessionScope: "none",
4809
4807
  requiresReferences: true,
4810
4808
  validateInput: validateJudgmentInput,
4811
4809
  validateOutput: validateJudgePackOutput,
@@ -4816,6 +4814,8 @@ var BUILT_IN_TASK_TYPES = {
4816
4814
  inputSchema: RunEvalInput,
4817
4815
  outputSchema: RunEvalOutput,
4818
4816
  outputKind: "artifact",
4817
+ workspaceScope: "attempt",
4818
+ sessionScope: "custom",
4819
4819
  requiresReferences: false,
4820
4820
  validateOutput: validateRunEvalOutput
4821
4821
  },
@@ -4824,6 +4824,8 @@ var BUILT_IN_TASK_TYPES = {
4824
4824
  inputSchema: JudgeEvalVariantInput,
4825
4825
  outputSchema: JudgeEvalVariantOutput,
4826
4826
  outputKind: "judgment",
4827
+ workspaceScope: "attempt",
4828
+ sessionScope: "custom",
4827
4829
  requiresReferences: false,
4828
4830
  validateInput: validateJudgeEvalVariantInput,
4829
4831
  validateOutput: validateJudgeEvalVariantOutput,
@@ -5737,6 +5739,58 @@ function getTaskOutputSchema(taskType) {
5737
5739
  function taskTypeUsesSubagents(taskType) {
5738
5740
  return getTaskTypeEntry(taskType)?.usesSubagents === true;
5739
5741
  }
5742
+ /**
5743
+ * Filesystem isolation policy requested by the task type.
5744
+ *
5745
+ * Unknown task types and task types without an explicit policy default to the
5746
+ * legacy/shared behaviour.
5747
+ */
5748
+ function taskTypeWorkspaceMode(taskType) {
5749
+ return getTaskTypeEntry(taskType)?.workspaceMode ?? "shared_mount";
5750
+ }
5751
+ /**
5752
+ * Whether the daemon may keep a warm executor session alive after one
5753
+ * attempt completes and reuse it for later related tasks.
5754
+ *
5755
+ * Unknown task types default to `false`.
5756
+ */
5757
+ function taskTypeResumable(taskType) {
5758
+ return getTaskTypeEntry(taskType)?.resumable === true;
5759
+ }
5760
+ /**
5761
+ * Lifetime of the task type's workspace.
5762
+ *
5763
+ * Unknown task types and task types without an explicit policy default to the
5764
+ * disposable per-attempt behavior.
5765
+ */
5766
+ function taskTypeWorkspaceScope(taskType) {
5767
+ return getTaskTypeEntry(taskType)?.workspaceScope ?? "attempt";
5768
+ }
5769
+ /**
5770
+ * Granularity of daemon-local warm-session reuse.
5771
+ *
5772
+ * Unknown task types and task types without an explicit policy default to no
5773
+ * reuse.
5774
+ */
5775
+ function taskTypeSessionScope(taskType) {
5776
+ return getTaskTypeEntry(taskType)?.sessionScope ?? "none";
5777
+ }
5778
+ /**
5779
+ * Resolve the daemon-relevant execution policy declared by a task type.
5780
+ *
5781
+ * This is the single read path for "may this task reuse a warm session?" and
5782
+ * "does its workspace belong to the attempt or the session?" so daemon code
5783
+ * doesn't need to hand-roll defaults or peek at the raw registry shape.
5784
+ */
5785
+ function getTaskExecutionPolicy(taskType) {
5786
+ return {
5787
+ resumable: taskTypeResumable(taskType),
5788
+ workspaceMode: taskTypeWorkspaceMode(taskType),
5789
+ workspaceScope: taskTypeWorkspaceScope(taskType),
5790
+ sessionScope: taskTypeSessionScope(taskType),
5791
+ usesSubagents: taskTypeUsesSubagents(taskType)
5792
+ };
5793
+ }
5740
5794
  //#endregion
5741
5795
  //#region ../../libs/tasks/src/wire.ts
5742
5796
  /**
@@ -5999,6 +6053,10 @@ var COMMON_OPTIONAL_FLAGS = `\
5999
6053
  this, the pi session aborts and the attempt
6000
6054
  fails with code 'max_bash_timeouts_exceeded'.
6001
6055
  0 = disabled. Default: 3.
6056
+ --warm-session-ttl-sec <n> Retain resumable daemon slots (Pi sessions +
6057
+ reusable worktrees) in local daemon state for
6058
+ this many seconds after use. 0 = disable reuse.
6059
+ Default: 1800.
6002
6060
  --debug Verbose logging: also log successful list/claim
6003
6061
  outcomes (candidate counts, claim attempts).`;
6004
6062
  var REGISTERED_TASK_TYPES = Object.keys(BUILT_IN_TASK_TYPES).sort();
@@ -6362,6 +6420,15 @@ function buildAssessBriefUserPrompt(input, ctx) {
6362
6420
  rubric.preamble,
6363
6421
  ""
6364
6422
  ].join("\n") : "";
6423
+ const workspaceSection = ctx.workspace?.mode === "dedicated_worktree" ? [
6424
+ "### Workspace",
6425
+ "",
6426
+ "This review attempt is running inside a dedicated disposable git",
6427
+ "worktree created for this task. If you need to check out the target",
6428
+ "branch or inspect refs locally, do it only inside this worktree.",
6429
+ ctx.workspace.branch ? `The current review branch is \`${ctx.workspace.branch}\`. You may replace it with the target branch locally if that helps your inspection.` : "The current checkout is disposable and will be cleaned up when the task ends.",
6430
+ ""
6431
+ ].join("\n") : "";
6365
6432
  return [
6366
6433
  "# Assess Brief Judge",
6367
6434
  "",
@@ -6402,6 +6469,7 @@ function buildAssessBriefUserPrompt(input, ctx) {
6402
6469
  " read it from the task you fetched in step 1 and pass",
6403
6470
  " `taskFilter: { correlationId: \"<id>\" }`.",
6404
6471
  "",
6472
+ workspaceSection,
6405
6473
  preambleSection,
6406
6474
  "## Criteria",
6407
6475
  "",
@@ -6661,6 +6729,14 @@ function buildFulfillBriefUserPrompt(input, ctx) {
6661
6729
  "from this branch naming scheme when correlationId is set.",
6662
6730
  ""
6663
6731
  ].join("\n") : "";
6732
+ const workspaceSection = ctx.workspace?.mode === "dedicated_worktree" ? [
6733
+ "### Workspace",
6734
+ "",
6735
+ "This attempt is running inside a dedicated git worktree created",
6736
+ "for this task. Do not repurpose or switch the primary checkout.",
6737
+ ctx.workspace.branch ? `The current branch is \`${ctx.workspace.branch}\`. Stay on this branch unless the runtime instructor explicitly tells you otherwise.` : "Stay on the branch that was pre-provisioned for this task.",
6738
+ ""
6739
+ ].join("\n") : "";
6664
6740
  return [
6665
6741
  "# Fulfill Brief Agent",
6666
6742
  "",
@@ -6681,9 +6757,10 @@ function buildFulfillBriefUserPrompt(input, ctx) {
6681
6757
  criteriaSection,
6682
6758
  seedSection,
6683
6759
  correlationSection,
6760
+ workspaceSection,
6684
6761
  "### Workflow",
6685
6762
  "",
6686
- `1. Create a feature branch (starting prefix suggestion: \`${branchSlug}<short-slug>\`).`,
6763
+ ctx.workspace?.mode === "dedicated_worktree" ? `1. Use the already-provisioned dedicated worktree branch${ctx.workspace.branch ? ` (\`${ctx.workspace.branch}\`)` : ""}; do not create or switch the primary checkout.` : `1. Create a feature branch (starting prefix suggestion: \`${branchSlug}<short-slug>\`).`,
6687
6764
  "2. Understand the problem — read relevant code; do not speculate.",
6688
6765
  "3. Implement the change. Keep commits small and coherent.",
6689
6766
  "4. Add tests if applicable.",
@@ -7081,7 +7158,8 @@ function buildTaskUserPrompt(task, ctx) {
7081
7158
  return buildFulfillBriefUserPrompt(task.input, {
7082
7159
  diaryId: ctx.diaryId,
7083
7160
  taskId: ctx.taskId,
7084
- correlationId: task.correlationId
7161
+ correlationId: task.correlationId,
7162
+ workspace: ctx.workspace
7085
7163
  });
7086
7164
  case ASSESS_BRIEF_TYPE:
7087
7165
  if (!Check(AssessBriefInput, task.input)) {
@@ -7090,7 +7168,8 @@ function buildTaskUserPrompt(task, ctx) {
7090
7168
  }
7091
7169
  return buildAssessBriefUserPrompt(task.input, {
7092
7170
  diaryId: ctx.diaryId,
7093
- taskId: ctx.taskId
7171
+ taskId: ctx.taskId,
7172
+ workspace: ctx.workspace
7094
7173
  });
7095
7174
  case CURATE_PACK_TYPE:
7096
7175
  if (!Check(CuratePackInput, task.input)) {
@@ -15313,9 +15392,11 @@ var NO_SKILLS = () => ({
15313
15392
  diagnostics: []
15314
15393
  });
15315
15394
  /**
15316
- * Construct an in-memory `AgentSession`. The caller is responsible for
15317
- * eventually invoking `session.prompt(...)` and for tearing down — the
15318
- * helper does no lifecycle management beyond construction.
15395
+ * Construct an `AgentSession`. By default it is in-memory; callers may opt
15396
+ * parent sessions into daemon-owned file persistence via `sessionPersistence`.
15397
+ * The caller is responsible for eventually invoking `session.prompt(...)` and
15398
+ * for tearing down — the helper does no lifecycle management beyond
15399
+ * construction.
15319
15400
  */
15320
15401
  async function buildAgentSession(args) {
15321
15402
  const piOtelExtension = createPiOtelExtension({
@@ -15330,15 +15411,23 @@ async function buildAgentSession(args) {
15330
15411
  skillsOverride: args.skillsOverride ?? NO_SKILLS
15331
15412
  });
15332
15413
  await resourceLoader.reload();
15414
+ const sessionManager = args.sessionPersistence ? await resolvePersistentSessionManager({
15415
+ cwd: args.mountPath,
15416
+ sessionDir: args.sessionPersistence.sessionDir
15417
+ }) : SessionManager.inMemory(args.mountPath);
15333
15418
  return (await createAgentSession({
15334
15419
  agentDir: args.piAuthDir,
15335
15420
  cwd: args.mountPath,
15336
15421
  model: args.modelHandle,
15337
15422
  customTools: args.customTools,
15338
- sessionManager: SessionManager.inMemory(),
15423
+ sessionManager,
15339
15424
  resourceLoader
15340
15425
  })).session;
15341
15426
  }
15427
+ async function resolvePersistentSessionManager(args) {
15428
+ await SessionManager.list(args.cwd, args.sessionDir);
15429
+ return SessionManager.continueRecent(args.cwd, args.sessionDir);
15430
+ }
15342
15431
  //#endregion
15343
15432
  //#region ../../libs/pi-extension/src/runtime/inject-task-context.ts
15344
15433
  /**
@@ -15949,6 +16038,113 @@ function resolveSubmitTools(taskType, opts = {}) {
15949
16038
  };
15950
16039
  }
15951
16040
  //#endregion
16041
+ //#region ../../libs/pi-extension/src/runtime/task-workspace.ts
16042
+ function prepareTaskWorkspace(task, requestedMountPath, executionPlan) {
16043
+ const branch = executionPlan?.worktreeBranch ?? null;
16044
+ if (!branch) return {
16045
+ mountPath: requestedMountPath,
16046
+ mode: "shared_mount",
16047
+ branch: null,
16048
+ cleanup: () => {}
16049
+ };
16050
+ const mainRepo = findMainWorktree();
16051
+ const worktreeDir = resolveTaskWorktreePath(mainRepo, executionPlan?.workspaceId ?? `task-${task.id}`);
16052
+ const relMount = relative(mainRepo, requestedMountPath);
16053
+ const mountPath = relMount === "" || relMount.startsWith("..") ? worktreeDir : join(worktreeDir, relMount);
16054
+ const keepWorkspace = executionPlan?.workspaceScope === "session" && executionPlan.sessionKey !== null;
16055
+ if (keepWorkspace) ensureReusableTaskWorktree(mainRepo, worktreeDir, branch);
16056
+ else {
16057
+ removeExistingTaskWorktree(mainRepo, worktreeDir);
16058
+ addTaskWorktree(mainRepo, worktreeDir, branch);
16059
+ }
16060
+ return {
16061
+ mountPath,
16062
+ mode: "dedicated_worktree",
16063
+ branch,
16064
+ cleanup: keepWorkspace ? () => {} : () => {
16065
+ execFileSync("git", [
16066
+ "-C",
16067
+ mainRepo,
16068
+ "worktree",
16069
+ "remove",
16070
+ "--force",
16071
+ worktreeDir
16072
+ ], { stdio: "pipe" });
16073
+ }
16074
+ };
16075
+ }
16076
+ function resolveTaskWorktreePath(mainRepo, workspaceId) {
16077
+ return join(mainRepo, ".worktrees", workspaceId);
16078
+ }
16079
+ function ensureReusableTaskWorktree(mainRepo, worktreeDir, branch) {
16080
+ if (isRegisteredWorktree$1(mainRepo, worktreeDir)) return;
16081
+ if (existsSync(worktreeDir)) throw new Error(`Expected reusable worktree ${worktreeDir} to be git-managed, but it exists outside git worktree metadata.`);
16082
+ addTaskWorktree(mainRepo, worktreeDir, branch);
16083
+ }
16084
+ function addTaskWorktree(mainRepo, worktreeDir, branch) {
16085
+ const baseRef = resolveWorktreeBaseRef(mainRepo);
16086
+ execFileSync("git", gitRefExists(mainRepo, `refs/heads/${branch}`) ? [
16087
+ "-C",
16088
+ mainRepo,
16089
+ "worktree",
16090
+ "add",
16091
+ worktreeDir,
16092
+ branch
16093
+ ] : [
16094
+ "-C",
16095
+ mainRepo,
16096
+ "worktree",
16097
+ "add",
16098
+ "-b",
16099
+ branch,
16100
+ worktreeDir,
16101
+ baseRef
16102
+ ], { stdio: "pipe" });
16103
+ }
16104
+ function removeExistingTaskWorktree(mainRepo, worktreeDir) {
16105
+ if (!existsSync(worktreeDir) || !isRegisteredWorktree$1(mainRepo, worktreeDir)) return;
16106
+ execFileSync("git", [
16107
+ "-C",
16108
+ mainRepo,
16109
+ "worktree",
16110
+ "remove",
16111
+ "--force",
16112
+ worktreeDir
16113
+ ], { stdio: "pipe" });
16114
+ }
16115
+ function isRegisteredWorktree$1(mainRepo, worktreeDir) {
16116
+ const list = execFileSync("git", [
16117
+ "-C",
16118
+ mainRepo,
16119
+ "worktree",
16120
+ "list",
16121
+ "--porcelain"
16122
+ ], {
16123
+ encoding: "utf8",
16124
+ stdio: "pipe"
16125
+ });
16126
+ const marker = `worktree ${worktreeDir}\n`;
16127
+ return list.includes(marker) || list.endsWith(`worktree ${worktreeDir}`);
16128
+ }
16129
+ function resolveWorktreeBaseRef(mainRepo) {
16130
+ return gitRefExists(mainRepo, "refs/heads/main") ? "main" : "HEAD";
16131
+ }
16132
+ function gitRefExists(mainRepo, ref) {
16133
+ try {
16134
+ execFileSync("git", [
16135
+ "-C",
16136
+ mainRepo,
16137
+ "show-ref",
16138
+ "--verify",
16139
+ "--quiet",
16140
+ ref
16141
+ ], { stdio: "pipe" });
16142
+ return true;
16143
+ } catch {
16144
+ return false;
16145
+ }
16146
+ }
16147
+ //#endregion
15952
16148
  //#region ../../libs/pi-extension/src/runtime/execute-pi-task.ts
15953
16149
  /**
15954
16150
  * executePiTask — run a single Task attempt using pi-coding-agent inside a
@@ -15997,7 +16193,10 @@ async function executePiTask(claimedTask, reporter, opts) {
15997
16193
  const task = claimedTask.task;
15998
16194
  const attemptN = claimedTask.attemptN;
15999
16195
  const startTime = Date.now();
16000
- const mountPath = opts.mountPath ?? process.cwd();
16196
+ const requestedMountPath = opts.mountPath ?? process.cwd();
16197
+ const executionPlan = opts.makeExecutionPlan?.(claimedTask) ?? null;
16198
+ const workspace = prepareTaskWorkspace(task, requestedMountPath, executionPlan);
16199
+ const mountPath = workspace.mountPath;
16001
16200
  if (reporter.cancelSignal.aborted) return {
16002
16201
  taskId: task.id,
16003
16202
  attemptN,
@@ -16028,7 +16227,8 @@ async function executePiTask(claimedTask, reporter, opts) {
16028
16227
  "--relative-paths"
16029
16228
  ], { stdio: "pipe" });
16030
16229
  } catch {}
16031
- const managed = await resumeVm({
16230
+ let managed = null;
16231
+ managed = await resumeVm({
16032
16232
  checkpointPath,
16033
16233
  agentName: opts.agentName,
16034
16234
  mountPath,
@@ -16088,13 +16288,19 @@ async function executePiTask(claimedTask, reporter, opts) {
16088
16288
  taskType: task.taskType,
16089
16289
  teamId: task.teamId,
16090
16290
  provider: opts.provider,
16091
- model: opts.model
16291
+ model: opts.model,
16292
+ workspaceMode: workspace.mode,
16293
+ workspaceBranch: workspace.branch
16092
16294
  });
16093
16295
  let taskPrompt;
16094
16296
  try {
16095
16297
  taskPrompt = buildTaskUserPrompt(task, {
16096
16298
  diaryId,
16097
16299
  taskId: task.id,
16300
+ workspace: {
16301
+ mode: workspace.mode,
16302
+ branch: workspace.branch
16303
+ },
16098
16304
  extras: opts.promptExtras
16099
16305
  });
16100
16306
  } catch (err) {
@@ -16205,7 +16411,8 @@ async function executePiTask(claimedTask, reporter, opts) {
16205
16411
  "moltnet.task.id": task.id,
16206
16412
  "moltnet.task.attempt": attemptN,
16207
16413
  "moltnet.task.type": task.taskType
16208
- }
16414
+ },
16415
+ sessionPersistence: executionPlan?.sessionPersistence ?? void 0
16209
16416
  });
16210
16417
  } catch (err) {
16211
16418
  const message = err instanceof Error ? err.message : String(err);
@@ -16424,7 +16631,13 @@ async function executePiTask(claimedTask, reporter, opts) {
16424
16631
  console.error(`executePiTask: reporter.close() failed for task ${task.id} attempt ${attemptN}: ${detail}`);
16425
16632
  }
16426
16633
  }
16427
- await managed.vm.close();
16634
+ if (managed) await managed.vm.close();
16635
+ try {
16636
+ workspace.cleanup();
16637
+ } catch (err) {
16638
+ const detail = err instanceof Error ? err.message : String(err);
16639
+ console.error(`executePiTask: workspace cleanup failed for task ${task.id} attempt ${attemptN}: ${detail}`);
16640
+ }
16428
16641
  }
16429
16642
  }
16430
16643
  function emptyUsage(provider, model) {
@@ -16626,6 +16839,419 @@ function createGhCliClient() {
16626
16839
  };
16627
16840
  }
16628
16841
  //#endregion
16842
+ //#region src/lib/daemon-slot-registry.ts
16843
+ var DaemonSlotRegistryError = class extends Error {
16844
+ constructor(operation, cause) {
16845
+ super(`Daemon slot registry failed during ${operation}`, { cause });
16846
+ this.operation = operation;
16847
+ this.name = "DaemonSlotRegistryError";
16848
+ }
16849
+ };
16850
+ var DaemonSlotRegistry = class {
16851
+ db;
16852
+ constructor(dbPath) {
16853
+ try {
16854
+ this.db = new DatabaseSync(dbPath);
16855
+ this.withDb("initialize schema", () => {
16856
+ this.db.exec(`
16857
+ PRAGMA journal_mode = WAL;
16858
+
16859
+ CREATE TABLE IF NOT EXISTS daemon_slots (
16860
+ agent_name TEXT NOT NULL,
16861
+ provider TEXT NOT NULL,
16862
+ model TEXT NOT NULL,
16863
+ slot_key TEXT NOT NULL,
16864
+ task_type TEXT NOT NULL,
16865
+ state TEXT NOT NULL CHECK (state IN ('active', 'idle')),
16866
+ last_task_id TEXT NOT NULL,
16867
+ last_attempt_n INTEGER NOT NULL,
16868
+ created_at_ms INTEGER NOT NULL,
16869
+ last_used_at_ms INTEGER NOT NULL,
16870
+ expires_at_ms INTEGER NOT NULL,
16871
+ PRIMARY KEY (agent_name, provider, model, slot_key)
16872
+ );
16873
+
16874
+ CREATE TABLE IF NOT EXISTS daemon_slot_sessions (
16875
+ agent_name TEXT NOT NULL,
16876
+ provider TEXT NOT NULL,
16877
+ model TEXT NOT NULL,
16878
+ slot_key TEXT NOT NULL,
16879
+ session_dir TEXT NOT NULL UNIQUE,
16880
+ session_path TEXT,
16881
+ PRIMARY KEY (agent_name, provider, model, slot_key),
16882
+ FOREIGN KEY (agent_name, provider, model, slot_key)
16883
+ REFERENCES daemon_slots(agent_name, provider, model, slot_key)
16884
+ ON DELETE CASCADE
16885
+ );
16886
+
16887
+ CREATE TABLE IF NOT EXISTS daemon_slot_workspaces (
16888
+ agent_name TEXT NOT NULL,
16889
+ provider TEXT NOT NULL,
16890
+ model TEXT NOT NULL,
16891
+ slot_key TEXT NOT NULL,
16892
+ workspace_id TEXT NOT NULL UNIQUE,
16893
+ worktree_path TEXT NOT NULL,
16894
+ worktree_branch TEXT,
16895
+ PRIMARY KEY (agent_name, provider, model, slot_key),
16896
+ FOREIGN KEY (agent_name, provider, model, slot_key)
16897
+ REFERENCES daemon_slots(agent_name, provider, model, slot_key)
16898
+ ON DELETE CASCADE
16899
+ );
16900
+
16901
+ CREATE INDEX IF NOT EXISTS daemon_slots_expires_idx
16902
+ ON daemon_slots (expires_at_ms);
16903
+ `);
16904
+ });
16905
+ } catch (error) {
16906
+ throw new DaemonSlotRegistryError("open database", error);
16907
+ }
16908
+ }
16909
+ close() {
16910
+ try {
16911
+ this.db.close();
16912
+ } catch (error) {
16913
+ throw new DaemonSlotRegistryError("close database", error);
16914
+ }
16915
+ }
16916
+ beginSlot(input) {
16917
+ const now = Date.now();
16918
+ const expiresAtMs = now + input.ttlSec * 1e3;
16919
+ this.withDb("upsert slot", () => this.db.prepare(`INSERT INTO daemon_slots (
16920
+ agent_name, provider, model, slot_key, task_type, state,
16921
+ last_task_id, last_attempt_n, created_at_ms, last_used_at_ms,
16922
+ expires_at_ms
16923
+ ) VALUES (
16924
+ :agentName, :provider, :model, :slotKey, :taskType, 'active',
16925
+ :lastTaskId, :lastAttemptN, :createdAtMs, :lastUsedAtMs,
16926
+ :expiresAtMs
16927
+ )
16928
+ ON CONFLICT(agent_name, provider, model, slot_key) DO UPDATE SET
16929
+ task_type = excluded.task_type,
16930
+ state = 'active',
16931
+ last_task_id = excluded.last_task_id,
16932
+ last_attempt_n = excluded.last_attempt_n,
16933
+ last_used_at_ms = excluded.last_used_at_ms,
16934
+ expires_at_ms = excluded.expires_at_ms`).run({
16935
+ agentName: input.agentName,
16936
+ provider: input.provider,
16937
+ model: input.model,
16938
+ slotKey: input.slotKey,
16939
+ taskType: input.taskType,
16940
+ lastTaskId: input.lastTaskId,
16941
+ lastAttemptN: input.lastAttemptN,
16942
+ createdAtMs: now,
16943
+ lastUsedAtMs: now,
16944
+ expiresAtMs
16945
+ }));
16946
+ if (input.sessionDir) this.withDb("upsert slot session", () => this.db.prepare(`INSERT INTO daemon_slot_sessions (
16947
+ agent_name, provider, model, slot_key, session_dir, session_path
16948
+ ) VALUES (
16949
+ :agentName, :provider, :model, :slotKey, :sessionDir, :sessionPath
16950
+ )
16951
+ ON CONFLICT(agent_name, provider, model, slot_key) DO UPDATE SET
16952
+ session_dir = excluded.session_dir,
16953
+ session_path = excluded.session_path`).run({
16954
+ agentName: input.agentName,
16955
+ provider: input.provider,
16956
+ model: input.model,
16957
+ slotKey: input.slotKey,
16958
+ sessionDir: input.sessionDir,
16959
+ sessionPath: input.sessionPath
16960
+ }));
16961
+ if (input.workspaceId && input.worktreePath) this.withDb("upsert slot workspace", () => this.db.prepare(`INSERT INTO daemon_slot_workspaces (
16962
+ agent_name, provider, model, slot_key, workspace_id, worktree_path,
16963
+ worktree_branch
16964
+ ) VALUES (
16965
+ :agentName, :provider, :model, :slotKey, :workspaceId, :worktreePath,
16966
+ :worktreeBranch
16967
+ )
16968
+ ON CONFLICT(agent_name, provider, model, slot_key) DO UPDATE SET
16969
+ workspace_id = excluded.workspace_id,
16970
+ worktree_path = excluded.worktree_path,
16971
+ worktree_branch = excluded.worktree_branch`).run({
16972
+ agentName: input.agentName,
16973
+ provider: input.provider,
16974
+ model: input.model,
16975
+ slotKey: input.slotKey,
16976
+ workspaceId: input.workspaceId,
16977
+ worktreePath: input.worktreePath,
16978
+ worktreeBranch: input.worktreeBranch
16979
+ }));
16980
+ }
16981
+ finishSlot(identity, slotKey, ttlSec, sessionPath) {
16982
+ const now = Date.now();
16983
+ this.withDb("finish slot", () => this.db.prepare(`UPDATE daemon_slots
16984
+ SET state = 'idle',
16985
+ last_used_at_ms = ?,
16986
+ expires_at_ms = ?
16987
+ WHERE agent_name = ? AND provider = ? AND model = ? AND slot_key = ?`).run(now, now + ttlSec * 1e3, identity.agentName, identity.provider, identity.model, slotKey));
16988
+ if (sessionPath !== null) this.withDb("update slot session path", () => this.db.prepare(`UPDATE daemon_slot_sessions
16989
+ SET session_path = ?
16990
+ WHERE agent_name = ? AND provider = ? AND model = ? AND slot_key = ?`).run(sessionPath, identity.agentName, identity.provider, identity.model, slotKey));
16991
+ }
16992
+ reapExpiredSlots(now = Date.now()) {
16993
+ this.withDb("begin reap transaction", () => this.db.exec("BEGIN IMMEDIATE"));
16994
+ try {
16995
+ const slots = this.withDb("select expired slots", () => this.db.prepare(`SELECT
16996
+ agent_name as agentName,
16997
+ provider,
16998
+ model,
16999
+ slot_key as slotKey,
17000
+ task_type as taskType,
17001
+ state,
17002
+ last_task_id as lastTaskId,
17003
+ last_attempt_n as lastAttemptN,
17004
+ created_at_ms as createdAtMs,
17005
+ last_used_at_ms as lastUsedAtMs,
17006
+ expires_at_ms as expiresAtMs
17007
+ FROM daemon_slots
17008
+ WHERE expires_at_ms <= ?`).all(now));
17009
+ if (slots.length < 1) {
17010
+ this.withDb("commit empty reap transaction", () => this.db.exec("COMMIT"));
17011
+ return [];
17012
+ }
17013
+ const selectSession = this.withDb("prepare slot session lookup", () => this.db.prepare(`SELECT
17014
+ agent_name as agentName,
17015
+ provider,
17016
+ model,
17017
+ slot_key as slotKey,
17018
+ session_dir as sessionDir,
17019
+ session_path as sessionPath
17020
+ FROM daemon_slot_sessions
17021
+ WHERE agent_name = ? AND provider = ? AND model = ? AND slot_key = ?`));
17022
+ const selectWorkspace = this.withDb("prepare slot workspace lookup", () => this.db.prepare(`SELECT
17023
+ agent_name as agentName,
17024
+ provider,
17025
+ model,
17026
+ slot_key as slotKey,
17027
+ workspace_id as workspaceId,
17028
+ worktree_path as worktreePath,
17029
+ worktree_branch as worktreeBranch
17030
+ FROM daemon_slot_workspaces
17031
+ WHERE agent_name = ? AND provider = ? AND model = ? AND slot_key = ?`));
17032
+ const deleteStmt = this.withDb("prepare expired slot delete", () => this.db.prepare("DELETE FROM daemon_slots WHERE agent_name = ? AND provider = ? AND model = ? AND slot_key = ?"));
17033
+ const out = [];
17034
+ for (const slot of slots) {
17035
+ const session = this.withDb("select slot session", () => selectSession.get(slot.agentName, slot.provider, slot.model, slot.slotKey) ?? null);
17036
+ const workspace = this.withDb("select slot workspace", () => selectWorkspace.get(slot.agentName, slot.provider, slot.model, slot.slotKey) ?? null);
17037
+ out.push({
17038
+ slot,
17039
+ session,
17040
+ workspace
17041
+ });
17042
+ this.withDb("delete expired slot", () => deleteStmt.run(slot.agentName, slot.provider, slot.model, slot.slotKey));
17043
+ }
17044
+ this.withDb("commit reap transaction", () => this.db.exec("COMMIT"));
17045
+ for (const item of out) {
17046
+ if (item.session) cleanupPiSessionDir(item.session.sessionDir);
17047
+ if (item.workspace) cleanupReusableWorktree(item.workspace.worktreePath);
17048
+ }
17049
+ return out;
17050
+ } catch (error) {
17051
+ try {
17052
+ this.db.exec("ROLLBACK");
17053
+ } catch {}
17054
+ throw error instanceof DaemonSlotRegistryError ? error : new DaemonSlotRegistryError("reap expired slots", error);
17055
+ }
17056
+ }
17057
+ withDb(operation, fn) {
17058
+ try {
17059
+ return fn();
17060
+ } catch (error) {
17061
+ throw new DaemonSlotRegistryError(operation, error);
17062
+ }
17063
+ }
17064
+ };
17065
+ function resolveLatestPiSessionPath(sessionDir) {
17066
+ try {
17067
+ const latestEntry = readdirSync(sessionDir, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")).map((entry) => entry.name).sort().at(-1);
17068
+ return latestEntry ? join(sessionDir, latestEntry) : null;
17069
+ } catch {
17070
+ return null;
17071
+ }
17072
+ }
17073
+ function cleanupPiSessionDir(sessionDir) {
17074
+ rmSync(sessionDir, {
17075
+ recursive: true,
17076
+ force: true
17077
+ });
17078
+ }
17079
+ function cleanupReusableWorktree(worktreePath) {
17080
+ const mainRepo = findMainWorktree();
17081
+ if (isRegisteredWorktree(mainRepo, worktreePath)) {
17082
+ execFileSync("git", [
17083
+ "-C",
17084
+ mainRepo,
17085
+ "worktree",
17086
+ "remove",
17087
+ "--force",
17088
+ worktreePath
17089
+ ], { stdio: "pipe" });
17090
+ return;
17091
+ }
17092
+ rmSync(worktreePath, {
17093
+ recursive: true,
17094
+ force: true
17095
+ });
17096
+ }
17097
+ function isRegisteredWorktree(mainRepo, worktreeDir) {
17098
+ const list = execFileSync("git", [
17099
+ "-C",
17100
+ mainRepo,
17101
+ "worktree",
17102
+ "list",
17103
+ "--porcelain"
17104
+ ], {
17105
+ encoding: "utf8",
17106
+ stdio: "pipe"
17107
+ });
17108
+ const marker = `worktree ${worktreeDir}\n`;
17109
+ return list.includes(marker) || list.endsWith(`worktree ${worktreeDir}`);
17110
+ }
17111
+ //#endregion
17112
+ //#region src/lib/slugify.ts
17113
+ function slugifyAsciiLower(input, maxLen, preserveChars = []) {
17114
+ const preserved = new Set(preserveChars.map((char) => char.toLowerCase()));
17115
+ let out = "";
17116
+ let pendingDash = false;
17117
+ for (const rawChar of input) {
17118
+ const char = rawChar.toLowerCase();
17119
+ const isAlphaNum = char >= "a" && char <= "z" || char >= "0" && char <= "9";
17120
+ const isPreserved = preserved.has(char);
17121
+ if (isAlphaNum || isPreserved) {
17122
+ if (pendingDash && out.length > 0 && out.length < maxLen) out += "-";
17123
+ pendingDash = false;
17124
+ if (out.length < maxLen) out += char;
17125
+ else break;
17126
+ continue;
17127
+ }
17128
+ pendingDash = out.length > 0;
17129
+ }
17130
+ return out;
17131
+ }
17132
+ //#endregion
17133
+ //#region src/lib/session-policy.ts
17134
+ /**
17135
+ * Resolve the daemon-facing execution policy for a task and, when the task
17136
+ * type permits warm reuse, compute the daemon-local `sessionKey`.
17137
+ *
17138
+ * `correlationId` remains the audit/query key; `sessionKey` is narrower and
17139
+ * may fold in task-type-specific discriminators so eval/review flows stay
17140
+ * isolated even inside one correlation group.
17141
+ */
17142
+ function deriveTaskSessionDescriptor(task) {
17143
+ const policy = getTaskExecutionPolicy(task.taskType);
17144
+ return {
17145
+ policy,
17146
+ sessionKey: buildTaskSessionKey(task, policy)
17147
+ };
17148
+ }
17149
+ function buildTaskSessionKey(task, policy) {
17150
+ if (!policy.resumable) return null;
17151
+ switch (policy.sessionScope) {
17152
+ case "none": return null;
17153
+ case "correlation": return task.correlationId ? `${task.taskType}:correlation:${task.correlationId}` : null;
17154
+ case "custom": return buildCustomSessionKey(task);
17155
+ }
17156
+ }
17157
+ function buildCustomSessionKey(task) {
17158
+ switch (task.taskType) {
17159
+ case "run_eval": {
17160
+ const variantLabel = typeof task.input.variantLabel === "string" ? task.input.variantLabel : null;
17161
+ if (!task.correlationId || !variantLabel) return null;
17162
+ return `run_eval:correlation:${task.correlationId}:variant:${slugifySessionComponent(variantLabel)}`;
17163
+ }
17164
+ case "judge_eval_variant": {
17165
+ const runTaskIds = Array.isArray(task.input.runTaskIds) ? task.input.runTaskIds.filter((value) => typeof value === "string") : [];
17166
+ if (runTaskIds.length < 1) return null;
17167
+ return `judge_eval_variant:run_tasks:${[...runTaskIds].sort().join(",")}`;
17168
+ }
17169
+ default: return null;
17170
+ }
17171
+ }
17172
+ function slugifySessionComponent(input) {
17173
+ return slugifyAsciiLower(input.trim(), 64);
17174
+ }
17175
+ //#endregion
17176
+ //#region src/lib/task-execution-plan.ts
17177
+ function buildDaemonTaskExecutionPlan(task, stateDirs, identity, warmSessionTtlSec) {
17178
+ const descriptor = deriveTaskSessionDescriptor(task);
17179
+ const slotKey = warmSessionTtlSec > 0 ? descriptor.sessionKey : null;
17180
+ const workspaceScope = slotKey !== null ? descriptor.policy.workspaceScope : "attempt";
17181
+ const slotId = slotKey ? buildDaemonSlotId(identity, slotKey) : null;
17182
+ const sessionDir = slotId ? `${stateDirs.piSessionsDir}/${encodeURIComponent(slotId)}` : null;
17183
+ const worktreeBranch = resolveTaskWorktreeBranch(task, descriptor.policy);
17184
+ const workspaceId = worktreeBranch !== null ? resolveTaskWorkspaceId(task, {
17185
+ sessionKey: slotId,
17186
+ workspaceScope,
17187
+ sessionPersistence: sessionDir ? { sessionDir } : null
17188
+ }) : null;
17189
+ return {
17190
+ descriptor,
17191
+ sessionKey: slotId,
17192
+ slotKey,
17193
+ slotId,
17194
+ workspaceScope,
17195
+ sessionPersistence: sessionDir ? { sessionDir } : null,
17196
+ workspaceId,
17197
+ worktreeBranch
17198
+ };
17199
+ }
17200
+ function buildDaemonSlotId(identity, slotKey) {
17201
+ return [
17202
+ "agent",
17203
+ slugSlotIdentityComponent(identity.agentName),
17204
+ "provider",
17205
+ slugSlotIdentityComponent(identity.provider),
17206
+ "model",
17207
+ slugSlotIdentityComponent(identity.model),
17208
+ "key",
17209
+ slotKey
17210
+ ].join(":");
17211
+ }
17212
+ function slugSlotIdentityComponent(input) {
17213
+ return slugifyAsciiLower(input.trim(), 64, [
17214
+ ".",
17215
+ "_",
17216
+ "-"
17217
+ ]);
17218
+ }
17219
+ function resolveTaskWorktreeBranch(task, policy) {
17220
+ if (policy.workspaceMode !== "dedicated_worktree") return null;
17221
+ if (task.taskType === "fulfill_brief") {
17222
+ const input = task.input;
17223
+ const slug = slugifyAsciiLower(typeof input.title === "string" && input.title.trim().length > 0 ? input.title : typeof input.brief === "string" && input.brief.trim().length > 0 ? input.brief : task.taskType, 60) || "task";
17224
+ if (task.correlationId) return `moltnet/${task.correlationId}/${slug}`;
17225
+ return `feat/${(typeof input.scopeHint === "string" && input.scopeHint.trim().length > 0 ? slugifyAsciiLower(input.scopeHint, 60) : "task") || "task"}-${slug}`;
17226
+ }
17227
+ return `task/${slugifyAsciiLower(task.taskType, 60) || "task"}-${task.id.slice(0, 8)}`;
17228
+ }
17229
+ function resolveTaskWorkspaceId(task, executionPlan) {
17230
+ if (executionPlan.workspaceScope === "session" && executionPlan.sessionKey !== null) return `session-${encodeURIComponent(executionPlan.sessionKey)}`;
17231
+ return `task-${task.id}`;
17232
+ }
17233
+ //#endregion
17234
+ //#region src/lib/execution-plan-cache.ts
17235
+ function createExecutionPlanCache(args) {
17236
+ const cache = /* @__PURE__ */ new Map();
17237
+ return {
17238
+ getOrCreate(claimedTask) {
17239
+ const key = buildClaimedTaskKey(claimedTask);
17240
+ const existing = cache.get(key);
17241
+ if (existing) return existing;
17242
+ const plan = buildDaemonTaskExecutionPlan(claimedTask.task, args.stateDirs, args.slotIdentity, args.warmSessionTtlSec);
17243
+ cache.set(key, plan);
17244
+ return plan;
17245
+ },
17246
+ delete(claimedTask) {
17247
+ cache.delete(buildClaimedTaskKey(claimedTask));
17248
+ }
17249
+ };
17250
+ }
17251
+ function buildClaimedTaskKey(task) {
17252
+ return `${task.task.id}:${task.attemptN}`;
17253
+ }
17254
+ //#endregion
16629
17255
  //#region src/lib/finalize.ts
16630
17256
  async function finalizeTask(agent, output, ctx = {}) {
16631
17257
  if (output.status === "cancelled") return;
@@ -16710,7 +17336,8 @@ var DEFAULTS = {
16710
17336
  maxBatchSize: 50,
16711
17337
  flushIntervalMs: 200,
16712
17338
  maxTurns: 0,
16713
- maxBashTimeouts: 3
17339
+ maxBashTimeouts: 3,
17340
+ warmSessionTtlSec: 1800
16714
17341
  };
16715
17342
  var MissingRequiredOptionError = class extends Error {
16716
17343
  constructor(flag) {
@@ -16734,6 +17361,7 @@ function parseCommonOptions(args) {
16734
17361
  flushIntervalMs: parseNonNegativeInt(args["flush-interval-ms"], "flush-interval-ms", DEFAULTS.flushIntervalMs),
16735
17362
  maxTurns: parseNonNegativeInt(args["max-turns"], "max-turns", DEFAULTS.maxTurns),
16736
17363
  maxBashTimeouts: parseNonNegativeInt(args["max-bash-timeouts"], "max-bash-timeouts", DEFAULTS.maxBashTimeouts),
17364
+ warmSessionTtlSec: parseNonNegativeInt(args["warm-session-ttl-sec"], "warm-session-ttl-sec", DEFAULTS.warmSessionTtlSec),
16737
17365
  debug: args.debug === true
16738
17366
  };
16739
17367
  }
@@ -16769,6 +17397,7 @@ function commonOptionDefs() {
16769
17397
  "flush-interval-ms": { type: "string" },
16770
17398
  "max-turns": { type: "string" },
16771
17399
  "max-bash-timeouts": { type: "string" },
17400
+ "warm-session-ttl-sec": { type: "string" },
16772
17401
  debug: { type: "boolean" }
16773
17402
  };
16774
17403
  }
@@ -16851,6 +17480,18 @@ function findUp(startDir, filename) {
16851
17480
  }
16852
17481
  }
16853
17482
  //#endregion
17483
+ //#region src/lib/state-dir.ts
17484
+ function ensureDaemonStateDirs(mountPath) {
17485
+ const rootDir = join(mountPath, ".moltnet", "d");
17486
+ const piSessionsDir = join(rootDir, "pi-sessions");
17487
+ mkdirSync(piSessionsDir, { recursive: true });
17488
+ return {
17489
+ rootDir,
17490
+ piSessionsDir,
17491
+ registryDbPath: join(rootDir, "daemon-state.sqlite")
17492
+ };
17493
+ }
17494
+ //#endregion
16854
17495
  //#region src/lib/turn-event-logger.ts
16855
17496
  function makeTurnEventHandler(base, context = {}) {
16856
17497
  const log = base.child({
@@ -16922,6 +17563,19 @@ async function runPolling(opts) {
16922
17563
  const listLimit = optionalPositiveInt(values["list-limit"], "list-limit", 10);
16923
17564
  if (taskTypes.length === 0) console.error(`[${opts.modeLabel}] --task-types is empty — daemon will accept any registered type. Pass an explicit list to limit scope (e.g. --task-types fulfill_brief).`);
16924
17565
  const sandbox = resolveSandbox(process.cwd(), values.sandbox);
17566
+ const stateDirs = ensureDaemonStateDirs(sandbox.rootDir);
17567
+ const slotRegistry = new DaemonSlotRegistry(stateDirs.registryDbPath);
17568
+ const slotIdentity = {
17569
+ agentName: common.agent,
17570
+ provider: common.provider,
17571
+ model: common.model
17572
+ };
17573
+ const mainRepo = findMainWorktree();
17574
+ const executionPlans = createExecutionPlanCache({
17575
+ stateDirs,
17576
+ slotIdentity,
17577
+ warmSessionTtlSec: common.warmSessionTtlSec
17578
+ });
16925
17579
  const ctx = await resolveAgentContext(common.agent);
16926
17580
  const cfg = loadConfig();
16927
17581
  const otelShutdown = await initWorkerOtel({
@@ -16972,6 +17626,7 @@ async function runPolling(opts) {
16972
17626
  provider: common.provider,
16973
17627
  model: common.model,
16974
17628
  sandboxConfig: sandbox.config,
17629
+ makeExecutionPlan: (claimedTask) => executionPlans.getOrCreate(claimedTask),
16975
17630
  makeOnTurnEvent: makeTurnEventHandlerFactory(rootLogger),
16976
17631
  maxTurns: common.maxTurns,
16977
17632
  maxBashTimeouts: common.maxBashTimeouts
@@ -17010,6 +17665,26 @@ async function runPolling(opts) {
17010
17665
  log: (msg, err) => rootLogger.warn({ err }, msg)
17011
17666
  }),
17012
17667
  executeTask: async (claimedTask, reporter) => {
17668
+ const executionPlan = executionPlans.getOrCreate(claimedTask);
17669
+ const sessionDescriptor = executionPlan.descriptor;
17670
+ const expired = slotRegistry.reapExpiredSlots();
17671
+ if (expired.length > 0) rootLogger.info({
17672
+ expiredCount: expired.length,
17673
+ slotKeys: expired.map((item) => item.slot.slotKey)
17674
+ }, "agent-daemon.daemon_slots_reaped");
17675
+ rootLogger.debug({
17676
+ taskId: claimedTask.task.id,
17677
+ taskType: claimedTask.task.taskType,
17678
+ resumable: sessionDescriptor.policy.resumable,
17679
+ workspaceMode: sessionDescriptor.policy.workspaceMode,
17680
+ workspaceScope: sessionDescriptor.policy.workspaceScope,
17681
+ sessionScope: sessionDescriptor.policy.sessionScope,
17682
+ slotKey: executionPlan.slotKey,
17683
+ slotId: executionPlan.slotId,
17684
+ sessionKey: executionPlan.sessionKey,
17685
+ piSessionDir: executionPlan.sessionPersistence?.sessionDir ?? null,
17686
+ workspaceId: executionPlan.workspaceId
17687
+ }, "agent-daemon.task_execution_policy");
17013
17688
  if (taskTypes.length > 0 && !taskTypes.includes(claimedTask.task.taskType)) return {
17014
17689
  taskId: claimedTask.task.id,
17015
17690
  attemptN: claimedTask.attemptN,
@@ -17044,7 +17719,25 @@ async function runPolling(opts) {
17044
17719
  retryable: false
17045
17720
  }
17046
17721
  };
17047
- return executeTask(claimedTask, reporter);
17722
+ if (executionPlan.slotKey && executionPlan.sessionPersistence) slotRegistry.beginSlot({
17723
+ ...slotIdentity,
17724
+ slotKey: executionPlan.slotKey,
17725
+ taskType: claimedTask.task.taskType,
17726
+ sessionDir: executionPlan.sessionPersistence.sessionDir,
17727
+ sessionPath: resolveLatestPiSessionPath(executionPlan.sessionPersistence.sessionDir),
17728
+ workspaceId: executionPlan.workspaceId,
17729
+ worktreePath: executionPlan.workspaceId ? resolveTaskWorktreePath(mainRepo, executionPlan.workspaceId) : null,
17730
+ worktreeBranch: executionPlan.worktreeBranch,
17731
+ lastTaskId: claimedTask.task.id,
17732
+ lastAttemptN: claimedTask.attemptN,
17733
+ ttlSec: common.warmSessionTtlSec
17734
+ });
17735
+ try {
17736
+ return await executeTask(claimedTask, reporter);
17737
+ } finally {
17738
+ executionPlans.delete(claimedTask);
17739
+ if (executionPlan.slotKey) slotRegistry.finishSlot(slotIdentity, executionPlan.slotKey, common.warmSessionTtlSec, executionPlan.sessionPersistence ? resolveLatestPiSessionPath(executionPlan.sessionPersistence.sessionDir) : null);
17740
+ }
17048
17741
  }
17049
17742
  });
17050
17743
  const drained = await runtime.start();
@@ -17052,6 +17745,7 @@ async function runPolling(opts) {
17052
17745
  rootLogger.info({ processed: drained.length }, "agent-daemon.drained");
17053
17746
  return drained.some((o) => o.status !== "completed") ? 1 : 0;
17054
17747
  } finally {
17748
+ slotRegistry.close();
17055
17749
  await otelShutdown();
17056
17750
  await shutdownLogger();
17057
17751
  }
@@ -17112,6 +17806,19 @@ async function runOnce(argv) {
17112
17806
  throw err;
17113
17807
  }
17114
17808
  const sandbox = resolveSandbox(process.cwd(), values.sandbox);
17809
+ const stateDirs = ensureDaemonStateDirs(sandbox.rootDir);
17810
+ const slotRegistry = new DaemonSlotRegistry(stateDirs.registryDbPath);
17811
+ const slotIdentity = {
17812
+ agentName: opts.agent,
17813
+ provider: opts.provider,
17814
+ model: opts.model
17815
+ };
17816
+ const mainRepo = findMainWorktree();
17817
+ const executionPlans = createExecutionPlanCache({
17818
+ stateDirs,
17819
+ slotIdentity,
17820
+ warmSessionTtlSec: opts.warmSessionTtlSec
17821
+ });
17115
17822
  const ctx = await resolveAgentContext(opts.agent);
17116
17823
  const cfg = loadConfig();
17117
17824
  const otelShutdown = await initWorkerOtel({
@@ -17160,16 +17867,44 @@ async function runOnce(argv) {
17160
17867
  onSignal("SIGTERM");
17161
17868
  });
17162
17869
  try {
17163
- const executeTask = createPiTaskExecutor({
17870
+ const rawExecuteTask = createPiTaskExecutor({
17164
17871
  agentName: opts.agent,
17165
17872
  mountPath: sandbox.rootDir,
17166
17873
  provider: opts.provider,
17167
17874
  model: opts.model,
17168
17875
  sandboxConfig: sandbox.config,
17876
+ makeExecutionPlan: (claimedTask) => executionPlans.getOrCreate(claimedTask),
17169
17877
  onTurnEvent: makeTurnEventHandler(rootLogger, { taskId }),
17170
17878
  maxTurns: opts.maxTurns,
17171
17879
  maxBashTimeouts: opts.maxBashTimeouts
17172
17880
  });
17881
+ const executeTask = async (claimedTask, reporter) => {
17882
+ const expired = slotRegistry.reapExpiredSlots();
17883
+ if (expired.length > 0) rootLogger.info({
17884
+ expiredCount: expired.length,
17885
+ slotKeys: expired.map((item) => item.slot.slotKey)
17886
+ }, "agent-daemon.daemon_slots_reaped");
17887
+ const executionPlan = executionPlans.getOrCreate(claimedTask);
17888
+ if (executionPlan.slotKey && executionPlan.sessionPersistence) slotRegistry.beginSlot({
17889
+ ...slotIdentity,
17890
+ slotKey: executionPlan.slotKey,
17891
+ taskType: claimedTask.task.taskType,
17892
+ sessionDir: executionPlan.sessionPersistence.sessionDir,
17893
+ sessionPath: resolveLatestPiSessionPath(executionPlan.sessionPersistence.sessionDir),
17894
+ workspaceId: executionPlan.workspaceId,
17895
+ worktreePath: executionPlan.workspaceId ? resolveTaskWorktreePath(mainRepo, executionPlan.workspaceId) : null,
17896
+ worktreeBranch: executionPlan.worktreeBranch,
17897
+ lastTaskId: claimedTask.task.id,
17898
+ lastAttemptN: claimedTask.attemptN,
17899
+ ttlSec: opts.warmSessionTtlSec
17900
+ });
17901
+ try {
17902
+ return await rawExecuteTask(claimedTask, reporter);
17903
+ } finally {
17904
+ executionPlans.delete(claimedTask);
17905
+ if (executionPlan.slotKey) slotRegistry.finishSlot(slotIdentity, executionPlan.slotKey, opts.warmSessionTtlSec, executionPlan.sessionPersistence ? resolveLatestPiSessionPath(executionPlan.sessionPersistence.sessionDir) : null);
17906
+ }
17907
+ };
17173
17908
  const writeCorrelationAnchors = makePrBodyAnchorWriter({
17174
17909
  gh: createGhCliClient(),
17175
17910
  logger: rootLogger
@@ -17204,6 +17939,7 @@ async function runOnce(argv) {
17204
17939
  console.log(JSON.stringify(output, null, 2));
17205
17940
  return output.status === "completed" ? 0 : 1;
17206
17941
  } finally {
17942
+ slotRegistry.close();
17207
17943
  await otelShutdown();
17208
17944
  await shutdownLogger();
17209
17945
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@themoltnet/agent-daemon",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
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.",
@@ -33,19 +33,19 @@
33
33
  "@opentelemetry/semantic-conventions": "^1.39.0",
34
34
  "pino": "^10.3.1",
35
35
  "pino-pretty": "^13.1.3",
36
- "@themoltnet/sdk": "0.102.0",
37
- "@themoltnet/pi-extension": "0.16.1",
38
- "@themoltnet/agent-runtime": "0.15.0"
36
+ "@themoltnet/pi-extension": "0.17.0",
37
+ "@themoltnet/agent-runtime": "0.15.1",
38
+ "@themoltnet/sdk": "0.102.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "tsx": "^4.7.0",
42
42
  "typescript": "^5.3.3",
43
43
  "vite": "^8.0.0",
44
44
  "vitest": "^3.0.0",
45
- "@moltnet/crypto-service": "0.1.0",
46
- "@moltnet/tasks": "0.1.0",
45
+ "@moltnet/bootstrap": "0.1.0",
47
46
  "@moltnet/database": "0.1.0",
48
- "@moltnet/bootstrap": "0.1.0"
47
+ "@moltnet/tasks": "0.1.0",
48
+ "@moltnet/crypto-service": "0.1.0"
49
49
  },
50
50
  "nx": {
51
51
  "tags": [