@themoltnet/agent-daemon 0.25.1 → 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 (2) hide show
  1. package/dist/main.js +113 -55
  2. package/package.json +6 -6
package/dist/main.js CHANGED
@@ -5613,6 +5613,7 @@ var FreeformArtifact = _Object_({
5613
5613
  });
5614
5614
  var FreeformOutput = _Object_({
5615
5615
  summary: String$1({ minLength: 1 }),
5616
+ branch: Optional(String$1({ minLength: 1 })),
5616
5617
  artifacts: Optional(_Array_(FreeformArtifact, { maxItems: 20 })),
5617
5618
  proposedTaskType: Optional(FreeformTaskTypeProposal),
5618
5619
  diaryEntryIds: Optional(_Array_(String$1({ format: "uuid" }))),
@@ -5625,7 +5626,7 @@ var FreeformOutput = _Object_({
5625
5626
  * Server-side preflight for `freeform` task-create. Runs after the
5626
5627
  * sync TypeBox check passes and only kicks in when
5627
5628
  * `input.continueFrom` is set — i.e. the proposer is asking to
5628
- * resume a prior freeform attempt's warm slot (#1287).
5629
+ * continue from a prior freeform attempt (#1287).
5629
5630
  *
5630
5631
  * Failure modes, in evaluation order:
5631
5632
  * 1. `freeform.sourceTaskNotFound` — source task id does not resolve
@@ -5633,23 +5634,17 @@ var FreeformOutput = _Object_({
5633
5634
  * 2. `freeform.sourceTaskTypeNotSupported` — source isn't `freeform`.
5634
5635
  * v1 only supports freeform → freeform continuation.
5635
5636
  * 3. `freeform.sourceAttemptNotCompleted` — named attempt is missing
5636
- * or not in `completed` state; warm continuation only makes sense
5637
+ * or not in `completed` state; continuation only makes sense
5637
5638
  * once the parent has produced a terminal output.
5638
5639
  * 4. `freeform.executionWorkspaceNotInheritable` — caller set
5639
5640
  * `execution.workspace` together with `continueFrom`. Workspace
5640
- * mode for a continuation is inherited from the parent slot
5641
- * (`maybeAttachWarmSlotContext` forces `dedicated_worktree` +
5642
- * the parent's worktreeBranch), so any caller-supplied override
5643
- * is silently dropped at the daemon plan stage. Reject explicitly
5644
- * so misconfiguration surfaces at create time.
5645
- * 5. `freeform.sourceNotResumeEligible` — `daemonState` is null or
5646
- * `slotResumableUntil` is null. Older completions (pre-#1287) and
5647
- * daemons that opt out fall here.
5648
- * 6. `freeform.sourceResumeExpired` — `slotResumableUntil` is in the
5649
- * past; the warm slot's TTL has elapsed and no daemon is
5650
- * 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.
5651
5646
  *
5652
- * Returns on the first failure (no "report all six") — the checks
5647
+ * Returns on the first failure — the checks
5653
5648
  * are sequential preconditions, later ones presume earlier ones hold.
5654
5649
  */
5655
5650
  async function validateFreeformInputAsync(input, ctx) {
@@ -5668,7 +5663,7 @@ async function validateFreeformInputAsync(input, ctx) {
5668
5663
  }];
5669
5664
  if (input.execution?.workspace) return [{
5670
5665
  field: "input/execution/workspace",
5671
- 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",
5672
5667
  code: "freeform.executionWorkspaceNotInheritable"
5673
5668
  }];
5674
5669
  if (ctx.deferReadinessChecks) return [];
@@ -5678,17 +5673,6 @@ async function validateFreeformInputAsync(input, ctx) {
5678
5673
  message: `Source attempt ${cf.attemptN} on task ${cf.taskId} is not in 'completed' state`,
5679
5674
  code: "freeform.sourceAttemptNotCompleted"
5680
5675
  }];
5681
- if (!attempt.daemonState || attempt.daemonState.slotResumableUntil === null) return [{
5682
- field: "input/continueFrom",
5683
- message: "Source attempt did not report continuation eligibility (older completion or daemon opted out)",
5684
- code: "freeform.sourceNotResumeEligible"
5685
- }];
5686
- const expiresAt = new Date(attempt.daemonState.slotResumableUntil).getTime();
5687
- if (Number.isNaN(expiresAt) || expiresAt <= Date.now()) return [{
5688
- field: "input/continueFrom",
5689
- message: `Source attempt's warm slot expired at ${attempt.daemonState.slotResumableUntil} (reported at ${attempt.daemonState.reportedAt})`,
5690
- code: "freeform.sourceResumeExpired"
5691
- }];
5692
5676
  return [];
5693
5677
  }
5694
5678
  //#endregion
@@ -8982,11 +8966,12 @@ var MAX_CLAIM_CONDITION_STATUSES = 8;
8982
8966
  /**
8983
8967
  * Daemon-asserted runtime state stamped onto a `TaskAttemptSummary` at
8984
8968
  * attempt-completion time. The server persists this block verbatim and
8985
- * reads `slotResumableUntil` for `tasks_continue` create-time
8986
- * eligibility; the daemon-side claim-affinity filter is the runtime
8987
- * truth. The block carries its own `reportedAt` so consumers can reason
8988
- * about staleness without reading documentation. All daemon-asserted
8989
- * 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.
8990
8975
  *
8991
8976
  * Adding new fields requires explicit design review (intentional
8992
8977
  * boundary; see docs/superpowers/specs/2026-06-04-tasks-continue-design.md).
@@ -9633,12 +9618,13 @@ var ProducerContextResolutionError = class extends Error {
9633
9618
  function createExecutionPlanCache(args) {
9634
9619
  const cache = /* @__PURE__ */ new Map();
9635
9620
  const runtimeSessionStore = args.runtimeSessionStore ?? createNullRuntimeSessionStore();
9621
+ const sourceAttemptResolver = args.sourceAttemptResolver ?? createNullSourceAttemptResolver();
9636
9622
  return {
9637
9623
  async getOrCreate(claimedTask) {
9638
9624
  const key = buildClaimedTaskKey(claimedTask);
9639
9625
  const existing = cache.get(key);
9640
9626
  if (existing) return existing;
9641
- const plan = await maybeAttachWarmSlotContext(claimedTask, buildDaemonTaskExecutionPlan(claimedTask.task, args.stateDirs, args.slotIdentity, args.warmSessionTtlSec, args.workspacePolicy), args.stateDirs, args.slotRegistry, runtimeSessionStore);
9627
+ const plan = await maybeAttachWarmSlotContext(claimedTask, buildDaemonTaskExecutionPlan(claimedTask.task, args.stateDirs, args.slotIdentity, args.warmSessionTtlSec, args.workspacePolicy), args.stateDirs, args.slotRegistry, runtimeSessionStore, sourceAttemptResolver);
9642
9628
  assertPlanAllowedByWorkspacePolicy(plan, args.workspacePolicy);
9643
9629
  cache.set(key, plan);
9644
9630
  return plan;
@@ -9648,13 +9634,18 @@ function createExecutionPlanCache(args) {
9648
9634
  }
9649
9635
  };
9650
9636
  }
9637
+ function createNullSourceAttemptResolver() {
9638
+ return { findOutputBranch() {
9639
+ return Promise.resolve(null);
9640
+ } };
9641
+ }
9651
9642
  function createNullRuntimeSessionStore() {
9652
9643
  return {
9653
- async findRuntimeSessionByTaskAttempt() {
9654
- return null;
9644
+ findRuntimeSessionByTaskAttempt() {
9645
+ return Promise.resolve(null);
9655
9646
  },
9656
- async hydrateSession() {
9657
- throw new ProducerContextResolutionError("Cannot hydrate runtime session: no runtime session store configured");
9647
+ hydrateSession() {
9648
+ return Promise.reject(new ProducerContextResolutionError("Cannot hydrate runtime session: no runtime session store configured"));
9658
9649
  },
9659
9650
  async uploadAttemptFinal() {}
9660
9651
  };
@@ -9676,17 +9667,26 @@ function planToRuntimeProfileWorkspaceMode(plan) {
9676
9667
  function buildClaimedTaskKey(task) {
9677
9668
  return `${task.task.id}:${task.attemptN}`;
9678
9669
  }
9679
- async function resolveWarmSlot(slotRegistry, runtimeSessionStore, teamId, sourceTaskId, sourceAttemptN, stateDirs) {
9680
- const producerContext = await slotRegistry.findLatestSlotByTaskAttempt(teamId, sourceTaskId, sourceAttemptN);
9681
- if (!producerContext) return { kind: "missing" };
9682
- const localSessionPath = resolveProducerSessionPath(producerContext);
9683
- const remoteSession = localSessionPath ? null : await runtimeSessionStore.findRuntimeSessionByTaskAttempt(teamId, sourceTaskId, sourceAttemptN);
9684
- const sourceSessionPath = localSessionPath ? localSessionPath : remoteSession ? await runtimeSessionStore.hydrateSession({
9670
+ async function hydrateRemoteRuntimeSession(runtimeSessionStore, teamId, sourceTaskId, sourceAttemptN, stateDirs) {
9671
+ if (!await runtimeSessionStore.findRuntimeSessionByTaskAttempt(teamId, sourceTaskId, sourceAttemptN)) return null;
9672
+ return runtimeSessionStore.hydrateSession({
9685
9673
  attemptN: sourceAttemptN,
9686
9674
  destinationDir: `${stateDirs.piSessionsDir}/remote-${sourceTaskId}-attempt-${sourceAttemptN}`,
9687
9675
  taskId: sourceTaskId,
9688
9676
  teamId
9689
- }) : null;
9677
+ });
9678
+ }
9679
+ async function resolveWarmSlot(slotRegistry, runtimeSessionStore, teamId, sourceTaskId, sourceAttemptN, stateDirs) {
9680
+ const producerContext = await slotRegistry.findLatestSlotByTaskAttempt(teamId, sourceTaskId, sourceAttemptN);
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);
9690
9690
  if (!sourceSessionPath) return { kind: "no-session-path" };
9691
9691
  return {
9692
9692
  kind: "found",
@@ -9695,19 +9695,54 @@ async function resolveWarmSlot(slotRegistry, runtimeSessionStore, teamId, source
9695
9695
  workspacePath: resolveProducerWorkspaceCopySource(producerContext, stateDirs)
9696
9696
  };
9697
9697
  }
9698
- async function maybeAttachWarmSlotContext(claimedTask, basePlan, stateDirs, slotRegistry, runtimeSessionStore) {
9698
+ async function maybeAttachWarmSlotContext(claimedTask, basePlan, stateDirs, slotRegistry, runtimeSessionStore, sourceAttemptResolver) {
9699
9699
  if (claimedTask.task.taskType === "freeform") {
9700
9700
  const continueFrom = claimedTask.task.input.continueFrom;
9701
9701
  if (!continueFrom) return basePlan;
9702
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 live runtime slot on this daemon — claim affinity filter should have prevented this claim`);
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`);
9704
9704
  if (resolution.kind === "no-session-path") throw new ProducerContextResolutionError(`Continuation source attempt ${continueFrom.taskId}/${continueFrom.attemptN} has no persisted Pi session path`);
9705
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
+ }
9706
9741
  const parentBranch = resolution.producerSlot.workspace?.worktreeBranch ?? null;
9707
9742
  if (continueFrom.mode === "fork") {
9708
9743
  if (!parentBranch) throw new ProducerContextResolutionError(`Cannot fork continuation of ${continueFrom.taskId}/${continueFrom.attemptN}: producer slot has no worktree branch to fork from`);
9709
9744
  const forkWorkspaceId = `fork-${claimedTask.task.id}-attempt-${claimedTask.attemptN}`;
9710
- const forkBranch = `${parentBranch}-fork-${claimedTask.task.id.slice(0, 8)}-${claimedTask.attemptN}`;
9745
+ const forkBranch = buildForkBranch(parentBranch, claimedTask.task.id, claimedTask.attemptN);
9711
9746
  return {
9712
9747
  ...basePlan,
9713
9748
  workspaceMode: "dedicated_worktree",
@@ -9739,6 +9774,7 @@ async function maybeAttachWarmSlotContext(claimedTask, basePlan, stateDirs, slot
9739
9774
  const resolution = await resolveWarmSlot(slotRegistry, runtimeSessionStore, claimedTask.task.teamId, targetTaskId, targetAttemptN, stateDirs);
9740
9775
  if (resolution.kind === "missing") throw new ProducerContextResolutionError(`No live producer runtime slot found for task ${targetTaskId} attempt ${targetAttemptN}`);
9741
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`);
9742
9778
  return {
9743
9779
  ...basePlan,
9744
9780
  workspaceMode: "scratch_mount",
@@ -9754,6 +9790,9 @@ async function maybeAttachWarmSlotContext(claimedTask, basePlan, stateDirs, slot
9754
9790
  }
9755
9791
  };
9756
9792
  }
9793
+ function buildForkBranch(parentBranch, childTaskId, childAttemptN) {
9794
+ return `${parentBranch}-fork-${childTaskId.slice(0, 8)}-${childAttemptN}`;
9795
+ }
9757
9796
  function resolveProducerSessionPath(producer) {
9758
9797
  const explicit = producer.session?.sessionPath ?? null;
9759
9798
  if (explicit && existsSync(explicit)) return explicit;
@@ -9783,12 +9822,11 @@ function recoverScratchWorkspacePath(producer, stateDirs) {
9783
9822
  //#endregion
9784
9823
  //#region src/lib/finalize.ts
9785
9824
  /**
9786
- * Build the `daemonState` payload for a `/complete` call. Only freeform
9787
- * attempts that ran with a runtime slot are eligible for continuation
9788
- * (`tasks_continue`, see issue #1287). Other task types and slot-less
9789
- * freeform completions report `null` for `slotResumableUntil`, which
9790
- * the server persists verbatim — continuations against such attempts
9791
- * 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.
9792
9830
  *
9793
9831
  * Returns `null` for non-freeform task types so the field is omitted
9794
9832
  * from the request body (the server treats null and absent the same).
@@ -10304,6 +10342,21 @@ function signalExitCode(signal) {
10304
10342
  return signal === "SIGINT" ? 130 : 143;
10305
10343
  }
10306
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
10307
10360
  //#region src/lib/state-dir.ts
10308
10361
  function ensureDaemonStateDirs(mountPath) {
10309
10362
  const rootDir = join(mountPath, ".moltnet", "d");
@@ -10411,6 +10464,7 @@ async function runPolling(opts) {
10411
10464
  for (const profile of profiles) validateRuntimeProfilePrerequisites(profile, cfg.profilePrerequisiteEnv, cfg.profilePrerequisitePath);
10412
10465
  const slotRegistry = createApiRuntimeSlotStore({ agent: ctx.agent });
10413
10466
  const runtimeSessionStore = createApiRuntimeSessionStore({ agent: ctx.agent });
10467
+ const sourceAttemptResolver = createApiSourceAttemptResolver({ agent: ctx.agent });
10414
10468
  const runtimes = /* @__PURE__ */ new Map();
10415
10469
  for (const profile of profiles) {
10416
10470
  const common = parseCommonOptions(values, { runtimeDefaults: {
@@ -10441,7 +10495,8 @@ async function runPolling(opts) {
10441
10495
  allowedWorkspaceModes: profile.allowedWorkspaceModes
10442
10496
  },
10443
10497
  slotRegistry,
10444
- runtimeSessionStore
10498
+ runtimeSessionStore,
10499
+ sourceAttemptResolver
10445
10500
  });
10446
10501
  runtimes.set(profile.id, {
10447
10502
  common,
@@ -10551,7 +10606,8 @@ async function runPolling(opts) {
10551
10606
  debug: baseCommon.debug,
10552
10607
  logger: rootLogger,
10553
10608
  slotRegistry,
10554
- sessionRegistry: runtimeSessionStore
10609
+ sessionRegistry: runtimeSessionStore,
10610
+ sourceAttemptResolver
10555
10611
  }),
10556
10612
  makeReporter: (claimedTask) => {
10557
10613
  const selected = runtimeForClaimedTask(runtimes, claimedTask);
@@ -10853,6 +10909,7 @@ async function runOnce(argv) {
10853
10909
  const stateDirs = ensureDaemonStateDirs(sandbox.rootDir);
10854
10910
  const slotRegistry = createApiRuntimeSlotStore({ agent: ctx.agent });
10855
10911
  const runtimeSessionStore = createApiRuntimeSessionStore({ agent: ctx.agent });
10912
+ const sourceAttemptResolver = createApiSourceAttemptResolver({ agent: ctx.agent });
10856
10913
  const slotIdentity = {
10857
10914
  agentName: opts.agent,
10858
10915
  runtimeProfileId: profile.id
@@ -10866,7 +10923,8 @@ async function runOnce(argv) {
10866
10923
  allowedWorkspaceModes: profile.allowedWorkspaceModes
10867
10924
  },
10868
10925
  slotRegistry,
10869
- runtimeSessionStore
10926
+ runtimeSessionStore,
10927
+ sourceAttemptResolver
10870
10928
  });
10871
10929
  const otelShutdown = await initWorkerOtel({
10872
10930
  serviceName: "moltnet.agent-daemon.once",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@themoltnet/agent-daemon",
3
- "version": "0.25.1",
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,19 +45,19 @@
45
45
  "@opentelemetry/semantic-conventions": "^1.39.0",
46
46
  "pino": "^10.3.1",
47
47
  "pino-pretty": "^13.1.3",
48
- "@themoltnet/pi-extension": "0.27.2",
48
+ "@themoltnet/pi-extension": "0.27.3",
49
49
  "@themoltnet/sdk": "0.113.1",
50
- "@themoltnet/agent-runtime": "0.30.1"
50
+ "@themoltnet/agent-runtime": "0.31.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "tsx": "^4.7.0",
54
54
  "typescript": "~5.9.2",
55
55
  "vite": "^8.0.0",
56
56
  "vitest": "^3.0.0",
57
- "@moltnet/bootstrap": "0.1.0",
58
- "@moltnet/crypto-service": "0.1.0",
59
57
  "@moltnet/observability": "0.1.0",
60
- "@moltnet/tasks": "0.1.0"
58
+ "@moltnet/tasks": "0.1.0",
59
+ "@moltnet/crypto-service": "0.1.0",
60
+ "@moltnet/bootstrap": "0.1.0"
61
61
  },
62
62
  "nx": {
63
63
  "tags": [