@themoltnet/agent-daemon 0.24.0 → 0.26.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 +7 -8
  2. package/dist/main.js +305 -53
  3. package/package.json +6 -6
package/README.md CHANGED
@@ -179,14 +179,13 @@ This matters for evals in particular. `run_eval` tasks declare their intended
179
179
  workspace shape in `input.execution.workspace`: `none` becomes a
180
180
  `scratch_mount`, `shared_mount` uses the daemon mount, and
181
181
  `dedicated_worktree` uses an isolated checkout. Downstream
182
- `judge_eval_attempt` tasks only resolve against a still-live producer
183
- session/workspace slot. If the producer slot or its local session/workspace
184
- files cannot be resolved, the judge fails with `producer_context_missing`.
185
- When the judge does claim in time, it immediately forks the producer session and copies the
186
- producer workspace into judge-owned scratch state so the running judge no
187
- longer depends on the producer slot after claim time. Repo-specific
188
- `resumeCommands` that should not run in scratch mode must still be guarded
189
- with `when.workspaceMode`.
182
+ `judge_eval_attempt` tasks can hydrate the producer Pi session from durable
183
+ runtime-session storage when producer slot/workspace metadata is available but
184
+ the local session file is unavailable. Workspace copying still depends on
185
+ producer slot/workspace metadata; if the daemon cannot resolve the required
186
+ producer context, the judge fails with `producer_context_missing`.
187
+ Repo-specific `resumeCommands` that should not run in scratch mode must still
188
+ be guarded with `when.workspaceMode`.
190
189
 
191
190
  ### 1. Start the local stack
192
191
 
package/dist/main.js CHANGED
@@ -12,7 +12,7 @@ import { parseArgs, promisify } from "node:util";
12
12
  import { AgentRuntime, ApiTaskReporter, ApiTaskSource, PollingApiTaskSource } from "@themoltnet/agent-runtime";
13
13
  import { createPiTaskExecutor, findMainWorktree } from "@themoltnet/pi-extension";
14
14
  import { execFile, execFileSync } from "node:child_process";
15
- import { accessSync, constants, existsSync, mkdirSync, readdirSync } from "node:fs";
15
+ import { accessSync, constants, createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync } from "node:fs";
16
16
  import { MoltNetError, connect } from "@themoltnet/sdk";
17
17
  import { once } from "node:events";
18
18
  import { pino, transport } from "pino";
@@ -22,6 +22,8 @@ import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
22
22
  import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
23
23
  import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
24
24
  import { AsyncLocalStorage } from "node:async_hooks";
25
+ import { mkdir } from "node:fs/promises";
26
+ import { pipeline } from "node:stream/promises";
25
27
  //#region ../../libs/observability/src/instrumentation.ts
26
28
  /**
27
29
  * Register OTel auto-instrumentation for common Node.js modules.
@@ -4569,6 +4571,64 @@ _Object_({
4569
4571
  additionalProperties: false
4570
4572
  });
4571
4573
  //#endregion
4574
+ //#region ../../libs/tasks/src/runtime-sessions.ts
4575
+ var RuntimeSessionKind = Union([
4576
+ Literal("root"),
4577
+ Literal("extend"),
4578
+ Literal("fork")
4579
+ ]);
4580
+ var RuntimeSessionCheckpointKind = Union([Literal("attempt_final")]);
4581
+ _Object_({
4582
+ id: String$1({ format: "uuid" }),
4583
+ teamId: String$1({ format: "uuid" }),
4584
+ taskId: String$1({ format: "uuid" }),
4585
+ attemptN: Integer({ minimum: 1 }),
4586
+ sourceSlotId: Union([String$1({ format: "uuid" }), Null()]),
4587
+ sourceRuntimeProfileId: Union([String$1({ format: "uuid" }), Null()]),
4588
+ sessionKind: RuntimeSessionKind,
4589
+ parentSessionId: Union([String$1({ format: "uuid" }), Null()]),
4590
+ contentType: String$1({
4591
+ minLength: 1,
4592
+ maxLength: 200
4593
+ }),
4594
+ contentEncoding: Union([String$1({
4595
+ minLength: 1,
4596
+ maxLength: 100
4597
+ }), Null()]),
4598
+ sizeBytes: Integer({ minimum: 0 }),
4599
+ sha256: String$1({
4600
+ minLength: 64,
4601
+ maxLength: 64
4602
+ }),
4603
+ storageClass: String$1({
4604
+ minLength: 1,
4605
+ maxLength: 100
4606
+ }),
4607
+ checkpointKind: RuntimeSessionCheckpointKind,
4608
+ uploadedAt: String$1({ format: "date-time" })
4609
+ }, { $id: "RuntimeSession" });
4610
+ _Object_({
4611
+ sourceSlotId: Optional(String$1({ format: "uuid" })),
4612
+ sourceRuntimeProfileId: Optional(String$1({ format: "uuid" })),
4613
+ sessionKind: RuntimeSessionKind,
4614
+ parentSessionId: Optional(String$1({ format: "uuid" }))
4615
+ }, {
4616
+ $id: "UploadRuntimeSessionQuery",
4617
+ additionalProperties: false
4618
+ });
4619
+ String$1({
4620
+ $id: "RuntimeSessionContent",
4621
+ description: "Runtime session content stream.",
4622
+ format: "binary"
4623
+ });
4624
+ _Object_({
4625
+ taskId: String$1({ format: "uuid" }),
4626
+ attemptN: Integer({ minimum: 1 })
4627
+ }, {
4628
+ $id: "RuntimeSessionAttemptParams",
4629
+ additionalProperties: false
4630
+ });
4631
+ //#endregion
4572
4632
  //#region ../../libs/tasks/src/runtime-slots.ts
4573
4633
  var RuntimeWorkspaceKind = Union([
4574
4634
  Literal("origin"),
@@ -5553,6 +5613,7 @@ var FreeformArtifact = _Object_({
5553
5613
  });
5554
5614
  var FreeformOutput = _Object_({
5555
5615
  summary: String$1({ minLength: 1 }),
5616
+ branch: Optional(String$1({ minLength: 1 })),
5556
5617
  artifacts: Optional(_Array_(FreeformArtifact, { maxItems: 20 })),
5557
5618
  proposedTaskType: Optional(FreeformTaskTypeProposal),
5558
5619
  diaryEntryIds: Optional(_Array_(String$1({ format: "uuid" }))),
@@ -5565,7 +5626,7 @@ var FreeformOutput = _Object_({
5565
5626
  * Server-side preflight for `freeform` task-create. Runs after the
5566
5627
  * sync TypeBox check passes and only kicks in when
5567
5628
  * `input.continueFrom` is set — i.e. the proposer is asking to
5568
- * resume a prior freeform attempt's warm slot (#1287).
5629
+ * continue from a prior freeform attempt (#1287).
5569
5630
  *
5570
5631
  * Failure modes, in evaluation order:
5571
5632
  * 1. `freeform.sourceTaskNotFound` — source task id does not resolve
@@ -5573,23 +5634,17 @@ var FreeformOutput = _Object_({
5573
5634
  * 2. `freeform.sourceTaskTypeNotSupported` — source isn't `freeform`.
5574
5635
  * v1 only supports freeform → freeform continuation.
5575
5636
  * 3. `freeform.sourceAttemptNotCompleted` — named attempt is missing
5576
- * or not in `completed` state; warm continuation only makes sense
5637
+ * or not in `completed` state; continuation only makes sense
5577
5638
  * once the parent has produced a terminal output.
5578
5639
  * 4. `freeform.executionWorkspaceNotInheritable` — caller set
5579
5640
  * `execution.workspace` together with `continueFrom`. Workspace
5580
- * mode for a continuation is inherited from the parent slot
5581
- * (`maybeAttachWarmSlotContext` forces `dedicated_worktree` +
5582
- * the parent's worktreeBranch), so any caller-supplied override
5583
- * is silently dropped at the daemon plan stage. Reject explicitly
5584
- * so misconfiguration surfaces at create time.
5585
- * 5. `freeform.sourceNotResumeEligible` — `daemonState` is null or
5586
- * `slotResumableUntil` is null. Older completions (pre-#1287) and
5587
- * daemons that opt out fall here.
5588
- * 6. `freeform.sourceResumeExpired` — `slotResumableUntil` is in the
5589
- * past; the warm slot's TTL has elapsed and no daemon is
5590
- * guaranteed to still hold it.
5641
+ * mode for a continuation is derived by the daemon from parent runtime
5642
+ * context (local slot first, durable session + source attempt branch
5643
+ * second), so any caller-supplied override is silently dropped at the
5644
+ * daemon plan stage. Reject explicitly so misconfiguration surfaces at
5645
+ * create time.
5591
5646
  *
5592
- * Returns on the first failure (no "report all six") — the checks
5647
+ * Returns on the first failure — the checks
5593
5648
  * are sequential preconditions, later ones presume earlier ones hold.
5594
5649
  */
5595
5650
  async function validateFreeformInputAsync(input, ctx) {
@@ -5608,7 +5663,7 @@ async function validateFreeformInputAsync(input, ctx) {
5608
5663
  }];
5609
5664
  if (input.execution?.workspace) return [{
5610
5665
  field: "input/execution/workspace",
5611
- message: "execution.workspace is inherited from the parent slot when continueFrom is set; omit it",
5666
+ message: "execution.workspace is derived from parent runtime context when continueFrom is set; omit it",
5612
5667
  code: "freeform.executionWorkspaceNotInheritable"
5613
5668
  }];
5614
5669
  if (ctx.deferReadinessChecks) return [];
@@ -5618,17 +5673,6 @@ async function validateFreeformInputAsync(input, ctx) {
5618
5673
  message: `Source attempt ${cf.attemptN} on task ${cf.taskId} is not in 'completed' state`,
5619
5674
  code: "freeform.sourceAttemptNotCompleted"
5620
5675
  }];
5621
- if (!attempt.daemonState || attempt.daemonState.slotResumableUntil === null) return [{
5622
- field: "input/continueFrom",
5623
- message: "Source attempt did not report continuation eligibility (older completion or daemon opted out)",
5624
- code: "freeform.sourceNotResumeEligible"
5625
- }];
5626
- const expiresAt = new Date(attempt.daemonState.slotResumableUntil).getTime();
5627
- if (Number.isNaN(expiresAt) || expiresAt <= Date.now()) return [{
5628
- field: "input/continueFrom",
5629
- message: `Source attempt's warm slot expired at ${attempt.daemonState.slotResumableUntil} (reported at ${attempt.daemonState.reportedAt})`,
5630
- code: "freeform.sourceResumeExpired"
5631
- }];
5632
5676
  return [];
5633
5677
  }
5634
5678
  //#endregion
@@ -8922,11 +8966,12 @@ var MAX_CLAIM_CONDITION_STATUSES = 8;
8922
8966
  /**
8923
8967
  * Daemon-asserted runtime state stamped onto a `TaskAttemptSummary` at
8924
8968
  * attempt-completion time. The server persists this block verbatim and
8925
- * reads `slotResumableUntil` for `tasks_continue` create-time
8926
- * eligibility; the daemon-side claim-affinity filter is the runtime
8927
- * truth. The block carries its own `reportedAt` so consumers can reason
8928
- * about staleness without reading documentation. All daemon-asserted
8929
- * state lives here — top-level attempt fields stay server-authoritative.
8969
+ * exposes `slotResumableUntil` as a legacy/local warm-slot hint; task
8970
+ * continuation eligibility is based on the completed source attempt and
8971
+ * daemon-side claim-affinity/runtime-session recovery. The block carries
8972
+ * its own `reportedAt` so consumers can reason about staleness without
8973
+ * reading documentation. All daemon-asserted state lives here —
8974
+ * top-level attempt fields stay server-authoritative.
8930
8975
  *
8931
8976
  * Adding new fields requires explicit design review (intentional
8932
8977
  * boundary; see docs/superpowers/specs/2026-06-04-tasks-continue-design.md).
@@ -9572,12 +9617,14 @@ var ProducerContextResolutionError = class extends Error {
9572
9617
  };
9573
9618
  function createExecutionPlanCache(args) {
9574
9619
  const cache = /* @__PURE__ */ new Map();
9620
+ const runtimeSessionStore = args.runtimeSessionStore ?? createNullRuntimeSessionStore();
9621
+ const sourceAttemptResolver = args.sourceAttemptResolver ?? createNullSourceAttemptResolver();
9575
9622
  return {
9576
9623
  async getOrCreate(claimedTask) {
9577
9624
  const key = buildClaimedTaskKey(claimedTask);
9578
9625
  const existing = cache.get(key);
9579
9626
  if (existing) return existing;
9580
- const plan = await maybeAttachWarmSlotContext(claimedTask, buildDaemonTaskExecutionPlan(claimedTask.task, args.stateDirs, args.slotIdentity, args.warmSessionTtlSec, args.workspacePolicy), args.stateDirs, args.slotRegistry);
9627
+ const plan = await maybeAttachWarmSlotContext(claimedTask, buildDaemonTaskExecutionPlan(claimedTask.task, args.stateDirs, args.slotIdentity, args.warmSessionTtlSec, args.workspacePolicy), args.stateDirs, args.slotRegistry, runtimeSessionStore, sourceAttemptResolver);
9581
9628
  assertPlanAllowedByWorkspacePolicy(plan, args.workspacePolicy);
9582
9629
  cache.set(key, plan);
9583
9630
  return plan;
@@ -9587,6 +9634,22 @@ function createExecutionPlanCache(args) {
9587
9634
  }
9588
9635
  };
9589
9636
  }
9637
+ function createNullSourceAttemptResolver() {
9638
+ return { findOutputBranch() {
9639
+ return Promise.resolve(null);
9640
+ } };
9641
+ }
9642
+ function createNullRuntimeSessionStore() {
9643
+ return {
9644
+ findRuntimeSessionByTaskAttempt() {
9645
+ return Promise.resolve(null);
9646
+ },
9647
+ hydrateSession() {
9648
+ return Promise.reject(new ProducerContextResolutionError("Cannot hydrate runtime session: no runtime session store configured"));
9649
+ },
9650
+ async uploadAttemptFinal() {}
9651
+ };
9652
+ }
9590
9653
  function assertPlanAllowedByWorkspacePolicy(plan, policy) {
9591
9654
  const allowed = new Set(policy?.allowedWorkspaceModes && policy.allowedWorkspaceModes.length > 0 ? policy.allowedWorkspaceModes : [
9592
9655
  "none",
@@ -9604,10 +9667,26 @@ function planToRuntimeProfileWorkspaceMode(plan) {
9604
9667
  function buildClaimedTaskKey(task) {
9605
9668
  return `${task.task.id}:${task.attemptN}`;
9606
9669
  }
9607
- async function resolveWarmSlot(slotRegistry, teamId, sourceTaskId, sourceAttemptN, stateDirs) {
9670
+ async function hydrateRemoteRuntimeSession(runtimeSessionStore, teamId, sourceTaskId, sourceAttemptN, stateDirs) {
9671
+ if (!await runtimeSessionStore.findRuntimeSessionByTaskAttempt(teamId, sourceTaskId, sourceAttemptN)) return null;
9672
+ return runtimeSessionStore.hydrateSession({
9673
+ attemptN: sourceAttemptN,
9674
+ destinationDir: `${stateDirs.piSessionsDir}/remote-${sourceTaskId}-attempt-${sourceAttemptN}`,
9675
+ taskId: sourceTaskId,
9676
+ teamId
9677
+ });
9678
+ }
9679
+ async function resolveWarmSlot(slotRegistry, runtimeSessionStore, teamId, sourceTaskId, sourceAttemptN, stateDirs) {
9608
9680
  const producerContext = await slotRegistry.findLatestSlotByTaskAttempt(teamId, sourceTaskId, sourceAttemptN);
9609
- if (!producerContext) return { kind: "missing" };
9610
- const sourceSessionPath = resolveProducerSessionPath(producerContext);
9681
+ if (!producerContext) {
9682
+ const remoteSessionPath = await hydrateRemoteRuntimeSession(runtimeSessionStore, teamId, sourceTaskId, sourceAttemptN, stateDirs);
9683
+ return remoteSessionPath ? {
9684
+ kind: "remote-session",
9685
+ sessionPath: remoteSessionPath
9686
+ } : { kind: "missing" };
9687
+ }
9688
+ const localSessionPath = resolveProducerSessionPath(producerContext);
9689
+ const sourceSessionPath = localSessionPath ? localSessionPath : await hydrateRemoteRuntimeSession(runtimeSessionStore, teamId, sourceTaskId, sourceAttemptN, stateDirs);
9611
9690
  if (!sourceSessionPath) return { kind: "no-session-path" };
9612
9691
  return {
9613
9692
  kind: "found",
@@ -9616,19 +9695,54 @@ async function resolveWarmSlot(slotRegistry, teamId, sourceTaskId, sourceAttempt
9616
9695
  workspacePath: resolveProducerWorkspaceCopySource(producerContext, stateDirs)
9617
9696
  };
9618
9697
  }
9619
- async function maybeAttachWarmSlotContext(claimedTask, basePlan, stateDirs, slotRegistry) {
9698
+ async function maybeAttachWarmSlotContext(claimedTask, basePlan, stateDirs, slotRegistry, runtimeSessionStore, sourceAttemptResolver) {
9620
9699
  if (claimedTask.task.taskType === "freeform") {
9621
9700
  const continueFrom = claimedTask.task.input.continueFrom;
9622
9701
  if (!continueFrom) return basePlan;
9623
- const resolution = await resolveWarmSlot(slotRegistry, claimedTask.task.teamId, continueFrom.taskId, continueFrom.attemptN, stateDirs);
9624
- if (resolution.kind === "missing") throw new ProducerContextResolutionError(`Continuation source task ${continueFrom.taskId} attempt ${continueFrom.attemptN} has no live runtime slot on this daemon — claim affinity filter should have prevented this claim`);
9702
+ const resolution = await resolveWarmSlot(slotRegistry, runtimeSessionStore, claimedTask.task.teamId, continueFrom.taskId, continueFrom.attemptN, stateDirs);
9703
+ 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`);
9625
9704
  if (resolution.kind === "no-session-path") throw new ProducerContextResolutionError(`Continuation source attempt ${continueFrom.taskId}/${continueFrom.attemptN} has no persisted Pi session path`);
9626
9705
  const sessionDir = `${stateDirs.piSessionsDir}/continue-${claimedTask.task.id}-attempt-${claimedTask.attemptN}`;
9706
+ if (resolution.kind === "remote-session") {
9707
+ const recoveredBranch = await sourceAttemptResolver.findOutputBranch({
9708
+ attemptN: continueFrom.attemptN,
9709
+ taskId: continueFrom.taskId
9710
+ });
9711
+ if (continueFrom.mode === "fork") {
9712
+ if (recoveredBranch) {
9713
+ const forkWorkspaceId = `fork-${claimedTask.task.id}-attempt-${claimedTask.attemptN}`;
9714
+ const forkBranch = buildForkBranch(recoveredBranch, claimedTask.task.id, claimedTask.attemptN);
9715
+ return {
9716
+ ...basePlan,
9717
+ workspaceMode: "dedicated_worktree",
9718
+ workspaceId: forkWorkspaceId,
9719
+ worktreeBranch: forkBranch,
9720
+ worktreeBaseRef: recoveredBranch,
9721
+ workspaceKind: "fork",
9722
+ sessionPersistence: {
9723
+ sessionDir,
9724
+ forkFromSessionPath: resolution.sessionPath
9725
+ }
9726
+ };
9727
+ }
9728
+ throw new ProducerContextResolutionError(`Cannot fork continuation of ${continueFrom.taskId}/${continueFrom.attemptN}: durable runtime session is available but the source attempt output did not report a branch`);
9729
+ }
9730
+ return {
9731
+ ...basePlan,
9732
+ workspaceMode: "dedicated_worktree",
9733
+ workspaceId: recoveredBranch ? `extend-${continueFrom.taskId}-attempt-${continueFrom.attemptN}` : null,
9734
+ worktreeBranch: recoveredBranch,
9735
+ sessionPersistence: {
9736
+ sessionDir,
9737
+ forkFromSessionPath: resolution.sessionPath
9738
+ }
9739
+ };
9740
+ }
9627
9741
  const parentBranch = resolution.producerSlot.workspace?.worktreeBranch ?? null;
9628
9742
  if (continueFrom.mode === "fork") {
9629
9743
  if (!parentBranch) throw new ProducerContextResolutionError(`Cannot fork continuation of ${continueFrom.taskId}/${continueFrom.attemptN}: producer slot has no worktree branch to fork from`);
9630
9744
  const forkWorkspaceId = `fork-${claimedTask.task.id}-attempt-${claimedTask.attemptN}`;
9631
- const forkBranch = `${parentBranch}-fork-${claimedTask.task.id.slice(0, 8)}-${claimedTask.attemptN}`;
9745
+ const forkBranch = buildForkBranch(parentBranch, claimedTask.task.id, claimedTask.attemptN);
9632
9746
  return {
9633
9747
  ...basePlan,
9634
9748
  workspaceMode: "dedicated_worktree",
@@ -9657,9 +9771,10 @@ async function maybeAttachWarmSlotContext(claimedTask, basePlan, stateDirs, slot
9657
9771
  const targetTaskId = typeof claimedTask.task.input.targetTaskId === "string" ? claimedTask.task.input.targetTaskId : null;
9658
9772
  const targetAttemptN = typeof claimedTask.task.input.targetAttemptN === "number" ? claimedTask.task.input.targetAttemptN : null;
9659
9773
  if (!targetTaskId || !targetAttemptN) throw new ProducerContextResolutionError("judge_eval_attempt is missing targetTaskId/targetAttemptN");
9660
- const resolution = await resolveWarmSlot(slotRegistry, claimedTask.task.teamId, targetTaskId, targetAttemptN, stateDirs);
9774
+ const resolution = await resolveWarmSlot(slotRegistry, runtimeSessionStore, claimedTask.task.teamId, targetTaskId, targetAttemptN, stateDirs);
9661
9775
  if (resolution.kind === "missing") throw new ProducerContextResolutionError(`No live producer runtime slot found for task ${targetTaskId} attempt ${targetAttemptN}`);
9662
9776
  if (resolution.kind === "no-session-path") throw new ProducerContextResolutionError(`Producer task ${targetTaskId} attempt ${targetAttemptN} has no persisted Pi session path`);
9777
+ if (resolution.kind === "remote-session") throw new ProducerContextResolutionError(`Producer task ${targetTaskId} attempt ${targetAttemptN} has a durable runtime session but no workspace metadata to copy`);
9663
9778
  return {
9664
9779
  ...basePlan,
9665
9780
  workspaceMode: "scratch_mount",
@@ -9675,6 +9790,9 @@ async function maybeAttachWarmSlotContext(claimedTask, basePlan, stateDirs, slot
9675
9790
  }
9676
9791
  };
9677
9792
  }
9793
+ function buildForkBranch(parentBranch, childTaskId, childAttemptN) {
9794
+ return `${parentBranch}-fork-${childTaskId.slice(0, 8)}-${childAttemptN}`;
9795
+ }
9678
9796
  function resolveProducerSessionPath(producer) {
9679
9797
  const explicit = producer.session?.sessionPath ?? null;
9680
9798
  if (explicit && existsSync(explicit)) return explicit;
@@ -9704,12 +9822,11 @@ function recoverScratchWorkspacePath(producer, stateDirs) {
9704
9822
  //#endregion
9705
9823
  //#region src/lib/finalize.ts
9706
9824
  /**
9707
- * Build the `daemonState` payload for a `/complete` call. Only freeform
9708
- * attempts that ran with a runtime slot are eligible for continuation
9709
- * (`tasks_continue`, see issue #1287). Other task types and slot-less
9710
- * freeform completions report `null` for `slotResumableUntil`, which
9711
- * the server persists verbatim — continuations against such attempts
9712
- * fail validation with `freeform.sourceNotResumeEligible`.
9825
+ * Build the `daemonState` payload for a `/complete` call. Freeform
9826
+ * attempts report the local warm-slot hint when one exists; slot-less
9827
+ * freeform completions report `null` for `slotResumableUntil`. The server
9828
+ * persists this verbatim as diagnostic/runtime metadata, not as the
9829
+ * continuation eligibility gate.
9713
9830
  *
9714
9831
  * Returns `null` for non-freeform task types so the field is omitted
9715
9832
  * from the request body (the server treats null and absent the same).
@@ -10067,6 +10184,70 @@ function isExecutable(path) {
10067
10184
  }
10068
10185
  }
10069
10186
  //#endregion
10187
+ //#region src/lib/runtime-sessions.ts
10188
+ function resolveRuntimeSessionKind(claimedTask) {
10189
+ const continueFrom = resolveContinueFrom(claimedTask);
10190
+ if (!continueFrom) return "root";
10191
+ return continueFrom.mode === "fork" ? "fork" : "extend";
10192
+ }
10193
+ async function resolveParentRuntimeSession(runtimeSessionStore, claimedTask) {
10194
+ const continueFrom = resolveContinueFrom(claimedTask);
10195
+ if (!continueFrom) return null;
10196
+ return runtimeSessionStore.findRuntimeSessionByTaskAttempt(claimedTask.task.teamId, continueFrom.taskId, continueFrom.attemptN);
10197
+ }
10198
+ function applyRuntimeSessionUploadFailure(output, err) {
10199
+ if (output.status !== "completed") return output;
10200
+ return {
10201
+ ...output,
10202
+ contentSignature: void 0,
10203
+ error: {
10204
+ code: "runtime_session_upload_failed",
10205
+ message: "Task completed, but durable runtime session checkpoint upload failed: " + (err instanceof Error ? err.message : String(err)),
10206
+ retryable: true
10207
+ },
10208
+ output: null,
10209
+ outputCid: null,
10210
+ status: "failed"
10211
+ };
10212
+ }
10213
+ function createApiRuntimeSessionStore(args) {
10214
+ const { agent } = args;
10215
+ return {
10216
+ async findRuntimeSessionByTaskAttempt(teamId, taskId, attemptN) {
10217
+ return agent.runtimeSessions.getForAttempt({
10218
+ attemptN,
10219
+ taskId
10220
+ }, { teamId });
10221
+ },
10222
+ async hydrateSession(input) {
10223
+ const downloaded = await agent.runtimeSessions.download({
10224
+ attemptN: input.attemptN,
10225
+ taskId: input.taskId
10226
+ }, { teamId: input.teamId });
10227
+ await mkdir(input.destinationDir, { recursive: true });
10228
+ const sessionPath = join(input.destinationDir, `remote-${input.taskId}-attempt-${input.attemptN}.jsonl`);
10229
+ await pipeline(downloaded, createWriteStream(sessionPath));
10230
+ return sessionPath;
10231
+ },
10232
+ async uploadAttemptFinal(input) {
10233
+ const sessionPath = resolveLatestPiSessionPath(input.sessionDir);
10234
+ if (!sessionPath) throw new Error(`Cannot upload runtime session for ${input.taskId}/${input.attemptN}: no local session file in ${input.sessionDir}`);
10235
+ await agent.runtimeSessions.upload({
10236
+ attemptN: input.attemptN,
10237
+ taskId: input.taskId
10238
+ }, createReadStream(sessionPath), {
10239
+ parentSessionId: input.parentSessionId ?? void 0,
10240
+ sessionKind: input.sessionKind,
10241
+ sourceRuntimeProfileId: input.sourceRuntimeProfileId ?? void 0,
10242
+ sourceSlotId: input.sourceSlotId ?? void 0
10243
+ }, { teamId: input.teamId });
10244
+ }
10245
+ };
10246
+ }
10247
+ function resolveContinueFrom(claimedTask) {
10248
+ return claimedTask.task.input.continueFrom;
10249
+ }
10250
+ //#endregion
10070
10251
  //#region src/lib/runtime-slots.ts
10071
10252
  function createApiRuntimeSlotStore(args) {
10072
10253
  const { agent } = args;
@@ -10108,7 +10289,11 @@ function createApiRuntimeSlotStore(args) {
10108
10289
  }, { teamId });
10109
10290
  if (!resolved) return null;
10110
10291
  return {
10111
- slot: { expiresAtMs: resolved.slot.expiresAtMs },
10292
+ slot: {
10293
+ expiresAtMs: resolved.slot.expiresAtMs,
10294
+ id: resolved.slot.id,
10295
+ runtimeProfileId: resolved.slot.runtimeProfileId
10296
+ },
10112
10297
  session: resolved.slot.sessionDir ? {
10113
10298
  sessionDir: resolved.slot.sessionDir,
10114
10299
  sessionPath: resolved.slot.sessionPath
@@ -10157,6 +10342,21 @@ function signalExitCode(signal) {
10157
10342
  return signal === "SIGINT" ? 130 : 143;
10158
10343
  }
10159
10344
  //#endregion
10345
+ //#region src/lib/source-attempts.ts
10346
+ function createApiSourceAttemptResolver(args) {
10347
+ const { agent } = args;
10348
+ return { async findOutputBranch(input) {
10349
+ const attempt = (await agent.tasks.listAttempts(input.taskId)).find((candidate) => candidate.attemptN === input.attemptN);
10350
+ if (!attempt || attempt.status !== "completed") return null;
10351
+ return resolveOutputBranch(attempt.output);
10352
+ } };
10353
+ }
10354
+ function resolveOutputBranch(output) {
10355
+ if (!output || typeof output !== "object") return null;
10356
+ const branch = output.branch;
10357
+ return typeof branch === "string" && branch.length > 0 ? branch : null;
10358
+ }
10359
+ //#endregion
10160
10360
  //#region src/lib/state-dir.ts
10161
10361
  function ensureDaemonStateDirs(mountPath) {
10162
10362
  const rootDir = join(mountPath, ".moltnet", "d");
@@ -10263,6 +10463,8 @@ async function runPolling(opts) {
10263
10463
  });
10264
10464
  for (const profile of profiles) validateRuntimeProfilePrerequisites(profile, cfg.profilePrerequisiteEnv, cfg.profilePrerequisitePath);
10265
10465
  const slotRegistry = createApiRuntimeSlotStore({ agent: ctx.agent });
10466
+ const runtimeSessionStore = createApiRuntimeSessionStore({ agent: ctx.agent });
10467
+ const sourceAttemptResolver = createApiSourceAttemptResolver({ agent: ctx.agent });
10266
10468
  const runtimes = /* @__PURE__ */ new Map();
10267
10469
  for (const profile of profiles) {
10268
10470
  const common = parseCommonOptions(values, { runtimeDefaults: {
@@ -10292,7 +10494,9 @@ async function runPolling(opts) {
10292
10494
  defaultWorkspaceMode: profile.defaultWorkspaceMode,
10293
10495
  allowedWorkspaceModes: profile.allowedWorkspaceModes
10294
10496
  },
10295
- slotRegistry
10497
+ slotRegistry,
10498
+ runtimeSessionStore,
10499
+ sourceAttemptResolver
10296
10500
  });
10297
10501
  runtimes.set(profile.id, {
10298
10502
  common,
@@ -10401,7 +10605,9 @@ async function runPolling(opts) {
10401
10605
  stopWhenEmpty: opts.stopWhenEmpty,
10402
10606
  debug: baseCommon.debug,
10403
10607
  logger: rootLogger,
10404
- slotRegistry
10608
+ slotRegistry,
10609
+ sessionRegistry: runtimeSessionStore,
10610
+ sourceAttemptResolver
10405
10611
  }),
10406
10612
  makeReporter: (claimedTask) => {
10407
10613
  const selected = runtimeForClaimedTask(runtimes, claimedTask);
@@ -10416,7 +10622,28 @@ async function runPolling(opts) {
10416
10622
  onTaskFinished: async (output, claimedTask) => {
10417
10623
  const selected = runtimeForClaimedTask(runtimes, claimedTask);
10418
10624
  const resolved = await slotRegistry.findLatestSlotByTaskAttempt(claimedTask.task.teamId, claimedTask.task.id, claimedTask.attemptN);
10419
- return finalizeTask(ctx.agent, output, {
10625
+ let terminalOutput = output;
10626
+ if (resolved?.session?.sessionDir) try {
10627
+ const parentSession = await resolveParentRuntimeSession(runtimeSessionStore, claimedTask);
10628
+ await runtimeSessionStore.uploadAttemptFinal({
10629
+ attemptN: claimedTask.attemptN,
10630
+ parentSessionId: parentSession?.id ?? null,
10631
+ sessionDir: resolved.session.sessionDir,
10632
+ sessionKind: resolveRuntimeSessionKind(claimedTask),
10633
+ sourceRuntimeProfileId: resolved.slot.runtimeProfileId,
10634
+ sourceSlotId: resolved.slot.id,
10635
+ taskId: claimedTask.task.id,
10636
+ teamId: claimedTask.task.teamId
10637
+ });
10638
+ } catch (err) {
10639
+ rootLogger.error({
10640
+ err,
10641
+ taskId: claimedTask.task.id,
10642
+ attemptN: claimedTask.attemptN
10643
+ }, "agent-daemon.runtime_session_upload_failed");
10644
+ terminalOutput = applyRuntimeSessionUploadFailure(output, err);
10645
+ }
10646
+ return finalizeTask(ctx.agent, terminalOutput, {
10420
10647
  task: claimedTask.task,
10421
10648
  slot: resolved ? { expiresAtMs: resolved.slot.expiresAtMs } : null,
10422
10649
  writeCorrelationAnchors: makePrBodyAnchorWriter({
@@ -10681,6 +10908,8 @@ async function runOnce(argv) {
10681
10908
  activatePiCodingAgentDir(piAgentDir.path);
10682
10909
  const stateDirs = ensureDaemonStateDirs(sandbox.rootDir);
10683
10910
  const slotRegistry = createApiRuntimeSlotStore({ agent: ctx.agent });
10911
+ const runtimeSessionStore = createApiRuntimeSessionStore({ agent: ctx.agent });
10912
+ const sourceAttemptResolver = createApiSourceAttemptResolver({ agent: ctx.agent });
10684
10913
  const slotIdentity = {
10685
10914
  agentName: opts.agent,
10686
10915
  runtimeProfileId: profile.id
@@ -10693,7 +10922,9 @@ async function runOnce(argv) {
10693
10922
  defaultWorkspaceMode: profile.defaultWorkspaceMode,
10694
10923
  allowedWorkspaceModes: profile.allowedWorkspaceModes
10695
10924
  },
10696
- slotRegistry
10925
+ slotRegistry,
10926
+ runtimeSessionStore,
10927
+ sourceAttemptResolver
10697
10928
  });
10698
10929
  const otelShutdown = await initWorkerOtel({
10699
10930
  serviceName: "moltnet.agent-daemon.once",
@@ -10853,7 +11084,28 @@ async function runOnce(argv) {
10853
11084
  }),
10854
11085
  onTaskFinished: async (output, claimedTask) => {
10855
11086
  const resolved = await slotRegistry.findLatestSlotByTaskAttempt(claimedTask.task.teamId, claimedTask.task.id, claimedTask.attemptN);
10856
- return finalizeTask(ctx.agent, output, {
11087
+ let terminalOutput = output;
11088
+ if (resolved?.session?.sessionDir) try {
11089
+ const parentSession = await resolveParentRuntimeSession(runtimeSessionStore, claimedTask);
11090
+ await runtimeSessionStore.uploadAttemptFinal({
11091
+ attemptN: claimedTask.attemptN,
11092
+ parentSessionId: parentSession?.id ?? null,
11093
+ sessionDir: resolved.session.sessionDir,
11094
+ sessionKind: resolveRuntimeSessionKind(claimedTask),
11095
+ sourceRuntimeProfileId: resolved.slot.runtimeProfileId,
11096
+ sourceSlotId: resolved.slot.id,
11097
+ taskId: claimedTask.task.id,
11098
+ teamId: claimedTask.task.teamId
11099
+ });
11100
+ } catch (err) {
11101
+ rootLogger.error({
11102
+ err,
11103
+ taskId: claimedTask.task.id,
11104
+ attemptN: claimedTask.attemptN
11105
+ }, "agent-daemon.runtime_session_upload_failed");
11106
+ terminalOutput = applyRuntimeSessionUploadFailure(output, err);
11107
+ }
11108
+ return finalizeTask(ctx.agent, terminalOutput, {
10857
11109
  task: claimedTask.task,
10858
11110
  slot: resolved ? { expiresAtMs: resolved.slot.expiresAtMs } : null,
10859
11111
  writeCorrelationAnchors,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@themoltnet/agent-daemon",
3
- "version": "0.24.0",
3
+ "version": "0.26.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.",
@@ -45,9 +45,9 @@
45
45
  "@opentelemetry/semantic-conventions": "^1.39.0",
46
46
  "pino": "^10.3.1",
47
47
  "pino-pretty": "^13.1.3",
48
- "@themoltnet/agent-runtime": "0.29.0",
49
- "@themoltnet/pi-extension": "0.27.0",
50
- "@themoltnet/sdk": "0.112.0"
48
+ "@themoltnet/pi-extension": "0.27.3",
49
+ "@themoltnet/sdk": "0.113.1",
50
+ "@themoltnet/agent-runtime": "0.31.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "tsx": "^4.7.0",
@@ -55,9 +55,9 @@
55
55
  "vite": "^8.0.0",
56
56
  "vitest": "^3.0.0",
57
57
  "@moltnet/observability": "0.1.0",
58
- "@moltnet/bootstrap": "0.1.0",
58
+ "@moltnet/tasks": "0.1.0",
59
59
  "@moltnet/crypto-service": "0.1.0",
60
- "@moltnet/tasks": "0.1.0"
60
+ "@moltnet/bootstrap": "0.1.0"
61
61
  },
62
62
  "nx": {
63
63
  "tags": [