@rulvar/plan 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AdmissionDecision, AgentResult, CanonicalLadderSpec, ChatRequest, Effort, Engine, EntryRef, EscalationDecision, EscalationOptions, HashVersion, IsolationSpec, JournalEntry, JournalStore, Json, KbProposalTrigger, KeyDeriver, LadderSpec, LeasableStore, LineageStats, LogicalTaskId, NodeId, OrchestrateOptions, OrchestratorExtension, ProviderAdapter, ReuseConfig, RunHandle, SchemaSpec, SpawnLineageOpt, TerminationAccountSnapshot, TerminationLimits, ToolDef, TriggerClass, UsageLimits, WireError } from "@rulvar/core";
1
+ import { AdmissionDecision, AdmitRejectReason, AgentResult, CanonicalLadderSpec, ChatRequest, Effort, Engine, EntryRef, EscalationDecision, EscalationOptions, HashVersion, IsolationSpec, JournalEntry, JournalStore, Json, KbProposalTrigger, KeyDeriver, LadderSpec, LeasableStore, LineageStats, LogicalTaskId, NodeId, OrchestrateOptions, OrchestratorExtension, ProviderAdapter, ReuseConfig, RunHandle, SchemaSpec, SpawnLineageOpt, TerminationAccountSnapshot, TerminationLimits, ToolDef, TriggerClass, UsageLimits, WireError } from "@rulvar/core";
2
2
 
3
3
  //#region src/plan-state.d.ts
4
4
  /**
@@ -287,7 +287,16 @@ interface PlanReviseRequest {
287
287
  }
288
288
  /** The canonical result form (XF-11): DEF-8 shape plus the DEF-2 balance. */
289
289
  interface PlanReviseResult {
290
- outcomes: RebaseOutcome[];
290
+ /**
291
+ * Journaled outcomes, enriched IN THE RESULT ONLY: a dropped
292
+ * admission_denied op carries its typed reject reason (account,
293
+ * reserves, minimum correction) so the model can act on it without
294
+ * digging into the journal. The plan.revision entry stays byte-stable;
295
+ * the full verdicts live in its `admissions`.
296
+ */
297
+ outcomes: Array<RebaseOutcome & {
298
+ verdictReason?: AdmitRejectReason;
299
+ }>;
291
300
  assignedNodeIds: Record<number, NodeId>;
292
301
  planHashAfter: string;
293
302
  droppedAll: boolean;
@@ -327,14 +336,14 @@ interface PlanRevisionValue {
327
336
  }>;
328
337
  }
329
338
  /** Engine authorship origins of plan.decision entries. */
330
- type PlanDecisionOrigin = "escalation-default" | "escalation-class" | "escalation-live" | "no-progress" | "child-result" | "park-landed" | "cancel-landed";
339
+ type PlanDecisionOrigin = "escalation-default" | "escalation-class" | "escalation-live" | "no-progress" | "child-result" | "park-landed" | "cancel-landed" | "dispatch-rejected";
331
340
  /** The closed EnginePlanOp set. */
332
341
  type EnginePlanOp = {
333
342
  kind: "set_node_status";
334
343
  nodeId: NodeId;
335
344
  from: PlanNodeStatus;
336
345
  to: PlanNodeStatus;
337
- cause: "child-result" | "no-progress" | "park-landed" | "cancel-landed";
346
+ cause: "child-result" | "no-progress" | "park-landed" | "cancel-landed" | "dispatch-rejected";
338
347
  causeRef: EntryRef; /** The retained checkpoint anchor recorded at park landing (M7-T08). */
339
348
  checkpointRef?: EntryRef;
340
349
  } | {
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CURRENT_HASH_VERSION, ConfigError, DedupIndex, InMemoryStore, LEGACY_LTID_PREFIX, LEGACY_SIGNATURE_INPUTS, LineageIndex, PlanInvariantError, ROOT_ACCOUNT, ReplayPlanHashMismatch, TerminationAccount, approachSigCoarse, buildTerminationInitValue, canonicalIsolationTag, canonicalizeLadder, checkpointRefFor, countsAgainstLimit, createEngine, defineWorkflow, deriverV2, evaluateReuse, foldTermination, kMaxOf, knowledgeHash, ladderLengthOf, ladderRungChoice, makeOrchestratorWorkflow, nodeLinkKey, normalizeApproachTag, orchestrate, planNodeScope, profileRegistrySnapshotHash, tool, validateTerminationLimits } from "@rulvar/core";
1
+ import { AdmissionRejectedError, BudgetExhaustedError, CURRENT_HASH_VERSION, ConfigError, DedupIndex, InMemoryStore, LEGACY_LTID_PREFIX, LEGACY_SIGNATURE_INPUTS, LineageIndex, PlanInvariantError, ROOT_ACCOUNT, ReplayPlanHashMismatch, TerminationAccount, approachSigCoarse, buildTerminationInitValue, canonicalIsolationTag, canonicalizeLadder, checkpointRefFor, countsAgainstLimit, createEngine, defineWorkflow, deriverV2, evaluateReuse, foldTermination, kMaxOf, knowledgeHash, ladderLengthOf, ladderRungChoice, makeOrchestratorWorkflow, nodeLinkKey, normalizeApproachTag, orchestrate, planNodeScope, profileRegistrySnapshotHash, tool, validateTerminationLimits } from "@rulvar/core";
2
2
  //#region src/plan-state.ts
3
3
  /**
4
4
  * Plan scope substrate (M7-T01): TaskPlan as engine-owned typed data, the
@@ -3077,17 +3077,51 @@ function planRunner(options) {
3077
3077
  ...ladder === void 0 ? {} : ladderDispatchFields(node, spec, ladder)
3078
3078
  };
3079
3079
  };
3080
+ /**
3081
+ * A dispatch refused by budget/admission facts that changed AFTER the
3082
+ * op was admitted (another child consumed the shared parent between
3083
+ * turns, a rung ceiling re-resolved differently) lands as a terminal
3084
+ * plan decision: the node fails loudly instead of sitting ready
3085
+ * forever, the other ready nodes still dispatch, and the wake digest
3086
+ * carries the failure to the orchestrator (the v1.7.0 follow-up
3087
+ * review's P1: no stranded node without a dispatch root or terminal
3088
+ * decision). Everything else (engine bugs) still propagates.
3089
+ */
3090
+ const landDispatchRejection = async (node, from, thrown) => {
3091
+ const message = thrown instanceof Error ? thrown.message : String(thrown);
3092
+ io.emit({
3093
+ type: "log",
3094
+ level: "warn",
3095
+ msg: `plan node ${node.nodeId} failed dispatch admission and lands terminally failed: ${message}`,
3096
+ data: {
3097
+ nodeId: node.nodeId,
3098
+ logicalTaskId: node.logicalTaskId
3099
+ }
3100
+ });
3101
+ await appendPlanDecision("dispatch-rejected", [{
3102
+ kind: "set_node_status",
3103
+ nodeId: node.nodeId,
3104
+ from,
3105
+ to: "failed",
3106
+ cause: "dispatch-rejected",
3107
+ causeRef: Math.max(planCursor, 0)
3108
+ }], Math.max(planCursor, 0));
3109
+ };
3110
+ const isDispatchRejection = (thrown) => thrown instanceof AdmissionRejectedError || thrown instanceof BudgetExhaustedError;
3080
3111
  /** Dispatches every ready node under its plan/NodeId scope. */
3081
3112
  const scheduleReady = async () => {
3082
3113
  for (const node of Object.values(fold.plan.nodes)) {
3083
3114
  if (node.status === "running" && !dispatched.has(node.nodeId)) {
3084
3115
  const spec = fold.specs[node.nodeId];
3085
- if (spec !== void 0) {
3116
+ if (spec !== void 0) try {
3086
3117
  const { handle } = await io.dispatch(buildDispatchSpec(node, spec), nodeScopeOf(node.nodeId), {
3087
3118
  nodeId: node.nodeId,
3088
3119
  logicalTaskId: node.logicalTaskId
3089
3120
  });
3090
3121
  dispatched.set(node.nodeId, handle);
3122
+ } catch (thrown) {
3123
+ if (!isDispatchRejection(thrown)) throw thrown;
3124
+ await landDispatchRejection(node, "running", thrown);
3091
3125
  }
3092
3126
  continue;
3093
3127
  }
@@ -3114,10 +3148,17 @@ function planRunner(options) {
3114
3148
  }
3115
3149
  const spec = fold.specs[node.nodeId];
3116
3150
  if (spec === void 0) continue;
3117
- const { handle } = await io.dispatch(buildDispatchSpec(node, spec), nodeScopeOf(node.nodeId), {
3118
- nodeId: node.nodeId,
3119
- logicalTaskId: node.logicalTaskId
3120
- });
3151
+ let handle;
3152
+ try {
3153
+ ({handle} = await io.dispatch(buildDispatchSpec(node, spec), nodeScopeOf(node.nodeId), {
3154
+ nodeId: node.nodeId,
3155
+ logicalTaskId: node.logicalTaskId
3156
+ }));
3157
+ } catch (thrown) {
3158
+ if (!isDispatchRejection(thrown)) throw thrown;
3159
+ await landDispatchRejection(node, "ready", thrown);
3160
+ continue;
3161
+ }
3121
3162
  dispatched.set(node.nodeId, handle);
3122
3163
  await appendPlanDecision("child-result", [{
3123
3164
  kind: "set_node_status",
@@ -3198,6 +3239,14 @@ function planRunner(options) {
3198
3239
  return { entryRef: entry.seq };
3199
3240
  },
3200
3241
  planRevise: async (request) => writeLock.runExclusive(async () => {
3242
+ const withVerdictDetail = (outcomes, admissions) => outcomes.map((outcome, opIndex) => {
3243
+ if (outcome.kind !== "dropped" || outcome.reason !== "admission_denied") return outcome;
3244
+ const verdict = admissions.find((admission) => admission.opIndex === opIndex)?.decision.verdict;
3245
+ return verdict !== void 0 && verdict.kind === "reject" ? {
3246
+ ...outcome,
3247
+ verdictReason: verdict.reason
3248
+ } : outcome;
3249
+ });
3201
3250
  await io.flush();
3202
3251
  absorbPlan();
3203
3252
  absorbGuardVerdicts();
@@ -3227,7 +3276,7 @@ function planRunner(options) {
3227
3276
  }
3228
3277
  await scheduleReady();
3229
3278
  return {
3230
- outcomes: value.outcomes,
3279
+ outcomes: withVerdictDetail(value.outcomes, value.admissions),
3231
3280
  assignedNodeIds: value.assignedNodeIds,
3232
3281
  planHashAfter: value.planHashAfter,
3233
3282
  droppedAll: value.outcomes.every((outcome) => outcome.kind === "dropped"),
@@ -3241,6 +3290,7 @@ function planRunner(options) {
3241
3290
  const freshNotes = /* @__PURE__ */ new Map();
3242
3291
  const frozen = io.snapshot().some((candidate) => candidate.kind === "decision" && candidate.value?.decisionType === "orchestrator_budget_cap");
3243
3292
  const claimedThisRevision = /* @__PURE__ */ new Set();
3293
+ let pendingReserveUsd = 0;
3244
3294
  const evaluation = rebasePlanRevision(request, {
3245
3295
  state: fold,
3246
3296
  frozen,
@@ -3307,13 +3357,47 @@ function planRunner(options) {
3307
3357
  depth: 1
3308
3358
  }
3309
3359
  };
3360
+ const profile = io.profiles[op.spec.agentType];
3361
+ const childAccount = rootScope === "" ? planNodeScope(nodeId) : `${rootScope}/${planNodeScope(nodeId)}`;
3362
+ const ladder = canonicalLadderOf(profile);
3363
+ const dispatchCeilingUsd = (() => {
3364
+ if (ladder !== void 0) {
3365
+ const startTier = clampStartTier(ladder, op.spec.model_hint?.startTier);
3366
+ const raises = op.lineage === void 0 ? 0 : requireAccount().rungIndexOf(op.lineage.continues);
3367
+ const rung = ladder.rungs[executingRungOf(ladder, startTier, raises)];
3368
+ if (rung?.maxCostUsd !== void 0) return rung.maxCostUsd;
3369
+ }
3370
+ return op.spec.budgetUsd;
3371
+ })();
3372
+ if (profile?.estCost !== void 0 && dispatchCeilingUsd !== void 0 && profile.estCost > dispatchCeilingUsd) return {
3373
+ verdict: {
3374
+ kind: "reject",
3375
+ reason: {
3376
+ code: "reserve_exceeds_budget",
3377
+ agentType: op.spec.agentType,
3378
+ childAccount,
3379
+ estCostUsd: profile.estCost,
3380
+ resolvedReserveUsd: Math.min(profile.estCost, dispatchCeilingUsd),
3381
+ childCeilingUsd: dispatchCeilingUsd,
3382
+ minimumBudgetUsd: profile.estCost,
3383
+ message: `agent profile '${op.spec.agentType}' declares estCost ${profile.estCost.toFixed(4)} USD, which cannot fit the child ceiling ${dispatchCeilingUsd.toFixed(4)} USD of account '${childAccount}'; raise budgetUsd to at least ${profile.estCost.toFixed(4)} or pick a cheaper profile`
3384
+ }
3385
+ },
3386
+ statsBefore: {
3387
+ spawnsBefore: 0,
3388
+ childrenOfParentBefore: 0,
3389
+ depth: 1
3390
+ }
3391
+ };
3310
3392
  const admitted = io.admission.admit({
3311
3393
  origin: "spawn_agent",
3312
3394
  name: op.spec.agentType,
3313
- childScope: rootScope === "" ? planNodeScope(nodeId) : `${rootScope}/${planNodeScope(nodeId)}`,
3395
+ childScope: childAccount,
3314
3396
  parentAccountScope: ROOT_ACCOUNT,
3315
3397
  nodeKey: planScope,
3316
3398
  ...op.spec.budgetUsd === void 0 ? {} : { budgetUsd: op.spec.budgetUsd },
3399
+ ...profile?.estCost === void 0 ? {} : { estCostUsd: profile.estCost },
3400
+ ...pendingReserveUsd === 0 ? {} : { pendingReserveUsd },
3317
3401
  ...op.lineage === void 0 ? {} : { lineage: op.lineage },
3318
3402
  ...(op.approach ?? op.spec.approach) === void 0 ? {} : { approach: op.approach ?? op.spec.approach },
3319
3403
  signature: {
@@ -3322,6 +3406,10 @@ function planRunner(options) {
3322
3406
  },
3323
3407
  ladderLength: ladderLengthOf(io.profiles[op.spec.agentType])
3324
3408
  }, { commitReserve: false });
3409
+ if (admitted.verdict.kind === "admit") pendingReserveUsd += io.admission.projectedDispatchReserveUsd({
3410
+ ...profile?.estCost === void 0 ? {} : { estCostUsd: profile.estCost },
3411
+ ...op.spec.budgetUsd === void 0 ? {} : { budgetUsd: op.spec.budgetUsd }
3412
+ });
3325
3413
  const note = freshNotes.get(opIndex);
3326
3414
  if (note !== void 0 && admitted.verdict.kind === "admit") return {
3327
3415
  ...admitted,
@@ -3335,12 +3423,13 @@ function planRunner(options) {
3335
3423
  admitUnpark: (op, node) => {
3336
3424
  const spec = fold.specs[op.nodeId];
3337
3425
  const wasDispatched = nodeRootOf(op.nodeId) !== void 0;
3338
- return io.admission.admit({
3426
+ const unparkAdmitted = io.admission.admit({
3339
3427
  origin: "spawn_agent",
3340
3428
  name: spec?.agentType ?? "unknown",
3341
3429
  childScope: nodeScopeOf(op.nodeId),
3342
3430
  parentAccountScope: ROOT_ACCOUNT,
3343
3431
  nodeKey: planScope,
3432
+ ...pendingReserveUsd === 0 ? {} : { pendingReserveUsd },
3344
3433
  ...wasDispatched ? { lineage: {
3345
3434
  continues: node.logicalTaskId,
3346
3435
  causeRef: node.checkpointRef ?? Math.max(planCursor, 1),
@@ -3352,6 +3441,8 @@ function planRunner(options) {
3352
3441
  },
3353
3442
  ladderLength: ladderLengthOf(io.profiles[spec?.agentType ?? ""])
3354
3443
  }, { commitReserve: false });
3444
+ if (unparkAdmitted.verdict.kind === "admit") pendingReserveUsd += io.admission.projectedDispatchReserveUsd({});
3445
+ return unparkAdmitted;
3355
3446
  },
3356
3447
  lineageCheck: (continues) => {
3357
3448
  const index = io.admission.lineage();
@@ -3386,19 +3477,6 @@ function planRunner(options) {
3386
3477
  consumedRevisionSeqs.add(entry.seq);
3387
3478
  await io.flush();
3388
3479
  absorbPlan();
3389
- await drainGuardVerdicts();
3390
- await landReuseLinks(entry);
3391
- await landCancelAbandons(value, entry.seq);
3392
- await landRevisionEscalations(value);
3393
- for (const outcome of evaluation.outcomes) {
3394
- if (outcome.kind === "dropped") continue;
3395
- const applied = outcome.kind === "applied" ? outcome.op : outcome.applied;
3396
- if ((applied.op === "cancel_task" || applied.op === "park_task") && applied.requestOnly === true) {
3397
- const handle = dispatched.get(applied.nodeId);
3398
- if (handle !== void 0) io.cancel(handle, applied.op === "cancel_task" ? applied.reason : "park_task");
3399
- }
3400
- }
3401
- await scheduleReady();
3402
3480
  const appliedCount = evaluation.outcomes.filter((o) => o.kind !== "dropped").length;
3403
3481
  io.emit({
3404
3482
  type: "plan:revised",
@@ -3415,8 +3493,21 @@ function planRunner(options) {
3415
3493
  remaining: debit.balanceAfter,
3416
3494
  phi: requireAccount().phi()
3417
3495
  });
3496
+ await drainGuardVerdicts();
3497
+ await landReuseLinks(entry);
3498
+ await landCancelAbandons(value, entry.seq);
3499
+ await landRevisionEscalations(value);
3500
+ for (const outcome of evaluation.outcomes) {
3501
+ if (outcome.kind === "dropped") continue;
3502
+ const applied = outcome.kind === "applied" ? outcome.op : outcome.applied;
3503
+ if ((applied.op === "cancel_task" || applied.op === "park_task") && applied.requestOnly === true) {
3504
+ const handle = dispatched.get(applied.nodeId);
3505
+ if (handle !== void 0) io.cancel(handle, applied.op === "cancel_task" ? applied.reason : "park_task");
3506
+ }
3507
+ }
3508
+ await scheduleReady();
3418
3509
  return {
3419
- outcomes: evaluation.outcomes,
3510
+ outcomes: withVerdictDetail(evaluation.outcomes, evaluation.admissions),
3420
3511
  assignedNodeIds: evaluation.assignedNodeIds,
3421
3512
  planHashAfter: evaluation.planHashAfter,
3422
3513
  droppedAll: evaluation.droppedAll,
@@ -3510,8 +3601,8 @@ function planRunner(options) {
3510
3601
  maxDepth: options?.limits?.maxDepth ?? 1,
3511
3602
  kMax: kMaxOf(io.profiles),
3512
3603
  runBudgetUsdCeiling: io.runCeilingUsd ?? 0,
3513
- orchestratorCapUsd: 0,
3514
- finalizeReserveUsd: 0
3604
+ orchestratorCapUsd: io.orchestratorCapUsd ?? 0,
3605
+ finalizeReserveUsd: io.finalizeReserveUsd ?? 0
3515
3606
  });
3516
3607
  const value = buildTerminationInitValue(limits, profileRegistrySnapshotHash(io.profiles));
3517
3608
  await io.append({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/plan",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Rulvar adaptive orchestration extension: PlanRunner, RunLedger, escalation extensions, ModelLadder configuration.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,13 +22,13 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@rulvar/core": "1.6.0"
25
+ "@rulvar/core": "1.8.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.20.0",
29
29
  "tsdown": "^0.22.3",
30
30
  "typescript": "~6.0.3",
31
- "@rulvar/store-sqlite": "1.6.0"
31
+ "@rulvar/store-sqlite": "1.8.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",