@deepstrike/wasm 0.2.74 → 0.2.76

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.
@@ -1,16 +1,20 @@
1
1
  import { prepareProviderRequest } from "../providers/prepared-request.js";
2
2
  import { createNativeContextPreparationAdapter } from "./context.js";
3
+ import { sha256Hex } from "./sha256.js";
3
4
  import { extractSessionMemories } from "../memory/extraction.js";
4
5
  import { resolvePermissionRequest } from "./execution-plane.js";
5
- import { governancePolicyToKernelEvent, governanceFilterSchema } from "../governance.js";
6
+ import { governancePolicyToKernelEvent } from "../governance.js";
7
+ import { parseToolCallArguments } from "./tool-arguments.js";
8
+ import { stripSkillFrontmatter } from "./execution-plane.js";
6
9
  import { getKernel } from "./kernel.js";
7
- import { CanonicalRunnerRuntime, canonicalKernelAction, canonicalKernelApply, canonicalKernelMaybeAction, canonicalStartAgent, canonicalStartWorkflow, sha256 as canonicalSha256, } from "./canonical-kernel-step.js";
10
+ import { checkMemoryWrite, deriveMemoryRecall } from "../memory/authority.js";
11
+ import { CanonicalRunnerRuntime, canonicalKernelAction, canonicalKernelApply, isInvalidInputError, canonicalKernelMaybeAction, canonicalStartAgent, canonicalStartWorkflow, sha256 as canonicalSha256, } from "./canonical-kernel-step.js";
8
12
  import { peekProviderReplay, seedProviderReplayFromEvents } from "./provider-replay.js";
9
13
  import { sanitizeReplayText } from "./replay-sanitize.js";
10
14
  import { decodeDurableContent, decodeDurableToolResult, durableBlocksToToolOutput, toolOutputBlocksToDurable } from "./durable-content.js";
11
15
  import { formatToolError } from "../tools/errors.js";
12
16
  import { buildLlmCompletedEvent, buildRunTerminalEvent, buildWorkflowNodeCompletedEvent, buildWorkflowNodesSubmittedEvent, } from "./session-repair.js";
13
- import { messageToKernelMessage, archivePresentationFromObservations, skillMetadataToKernel, taskUpdateToKernel, toolResultToKernel, toolSchemaToKernel, } from "./kernel-step.js";
17
+ import { messageToKernelMessage, archivePresentationFromObservations, skillMetadataToKernel, toolResultToKernel, toolSchemaToKernel, } from "./kernel-step.js";
14
18
  import { agentRunSpecToKernel, MILESTONE_UNVERIFIED_REASON, milestoneCheckFail, milestoneCheckPass, milestoneCheckResultToKernel, subAgentResultToKernel, workflowBudgetNote, workflowNodeSpecToKernel, workflowNodeOutcomeFromKernel, workflowNodeStatusFromTermination, workflowNodeToManifest, workflowNodeToSpec, workflowSpecToKernel, } from "./types/agent.js";
15
19
  import { defaultSubAgentOrchestrator } from "./sub-agent-orchestrator.js";
16
20
  import { extractJsonValue, schemaInstruction, schemaRetryInstruction, validateAgainstSchema, } from "./output-schema.js";
@@ -65,6 +69,7 @@ function memoryPolicyToKernel(policy) {
65
69
  "validationEnabled",
66
70
  "maxContentBytes",
67
71
  "maxNameLength",
72
+ "promotionRecallThreshold",
68
73
  ]);
69
74
  const unknown = Object.keys(policy).filter(key => !allowed.has(key));
70
75
  if (unknown.length > 0) {
@@ -76,16 +81,19 @@ function memoryPolicyToKernel(policy) {
76
81
  ...(policy.validationEnabled !== undefined ? { validation_enabled: policy.validationEnabled } : {}),
77
82
  ...(policy.maxContentBytes !== undefined ? { max_content_bytes: policy.maxContentBytes } : {}),
78
83
  ...(policy.maxNameLength !== undefined ? { max_name_length: policy.maxNameLength } : {}),
84
+ ...(policy.promotionRecallThreshold !== undefined
85
+ ? { promotion_recall_threshold: policy.promotionRecallThreshold }
86
+ : {}),
79
87
  };
80
88
  }
89
+ /** Logical tool calls the cancellation abandons. The kernel's `pending_call_ids` is the call-id
90
+ * namespace only: provider/host effect ids and sub-agent ids are not calls and are settled by the
91
+ * cancel transition itself. */
81
92
  function pendingCallIds(action) {
82
93
  switch (action.kind) {
83
- case "call_provider": return [action.effectId];
84
94
  case "execute_tool": return action.calls.map(call => call.id);
85
95
  case "request_approval": return action.requests.map(request => request.callId);
86
- case "spawn_workflow": return action.nodes.map(node => String(node.agent_id ?? "")).filter(Boolean);
87
- case "preempt_sub_agents": return action.agentIds;
88
- default: return "effectId" in action ? [action.effectId] : [];
96
+ default: return [];
89
97
  }
90
98
  }
91
99
  function controlRequestRejection(observations, operation) {
@@ -111,6 +119,9 @@ export class RuntimeRunner {
111
119
  fallbackPayloadStore = null;
112
120
  /** O2 (system-reminder channel): host-pushed notes awaiting the next turn-boundary drain. */
113
121
  injectedSignals = [];
122
+ /** Host control commands queued by the public control methods; the main loop applies them
123
+ * between effects so a command that publishes an effect never races an in-flight one. */
124
+ pendingControl = [];
114
125
  /** Skill names whose content has already been pushed into the durable `knowledge` slot this
115
126
  * run — guards against re-pushing a duplicate entry if the model calls `skill(name)` again for
116
127
  * an already-active skill (loading is idempotent; the knowledge push should be too). */
@@ -206,7 +217,10 @@ export class RuntimeRunner {
206
217
  * the kernel disposition ladder: `"normal"` queues (default), `"high"` soft-interrupts, `"critical"`
207
218
  * preempts. */
208
219
  injectNote(text, urgency = "normal") {
209
- this.injectedSignals.push({ source: "custom", signalType: "event", urgency, payload: { goal: text } });
220
+ this.injectedSignals.push({
221
+ signal: { source: "custom", signalType: "event", urgency, payload: { goal: text } },
222
+ note: text,
223
+ });
210
224
  }
211
225
  /** The most recent kernel session-entropy sample (one per completed turn), or `null` before the
212
226
  * first boundary. A pull companion to the streamed `entropy_sample` events. */
@@ -222,7 +236,8 @@ export class RuntimeRunner {
222
236
  signalId: crypto.randomUUID(),
223
237
  deliveryId: `injected-${crypto.randomUUID()}`,
224
238
  deliveryAttempt: 1,
225
- signal: injected,
239
+ signal: injected.signal,
240
+ note: injected.note,
226
241
  ack: async () => true,
227
242
  nack: async () => true,
228
243
  };
@@ -327,25 +342,87 @@ export class RuntimeRunner {
327
342
  }
328
343
  yield* this.execute(sessionId, start.run_id, start.goal, start.criteria, extensions, events, true, start.attachments);
329
344
  }
345
+ /**
346
+ * §22.13 · a host-authored memory write. The kernel decides whether it may happen: a live
347
+ * operation answers through `admit_memory_write` (the model's validation rule and rolling write
348
+ * quota); with no live operation the same kernel rule answers statelessly. Resolves `true` when
349
+ * the record was persisted.
350
+ */
330
351
  async writeMemory(memory, sessionId) {
331
352
  if (!this.opts.memoryStore || !this.opts.agentId)
332
- return;
353
+ return false;
354
+ const turn = this.activeKernel?.turn() ?? 0;
355
+ const error = await this.admitMemoryWrite(memory);
356
+ if (error) {
357
+ if (sessionId) {
358
+ await this.opts.sessionLog.append(sessionId, {
359
+ kind: "memory_validation_failed", turn, record_id: memory.record_id, error,
360
+ });
361
+ }
362
+ return false;
363
+ }
333
364
  try {
334
365
  await this.opts.memoryStore.put(this.opts.agentId, memory);
335
366
  }
336
367
  catch (cause) {
337
368
  throw new Error(formatToolError(cause));
338
369
  }
370
+ if (sessionId) {
371
+ await this.opts.sessionLog.append(sessionId, {
372
+ kind: "memory_written", turn, record_id: memory.record_id, scope: memory.scope,
373
+ memory_kind: memory.kind, name: memory.name,
374
+ size_bytes: new TextEncoder().encode(memory.content).byteLength,
375
+ });
376
+ }
377
+ return true;
378
+ }
379
+ async admitMemoryWrite(memory) {
380
+ const runtime = this.activeKernel;
381
+ if (runtime && runtime.hasStarted() && !runtime.isTerminal()) {
382
+ // The caller logs the verdict, so the fact is consumed here rather than queued for the
383
+ // per-turn drain (which would log it a second time).
384
+ const observations = await canonicalKernelApply(runtime, [], {
385
+ kind: "admit_memory_write",
386
+ record_id: memory.record_id,
387
+ name: memory.name,
388
+ content_bytes: new TextEncoder().encode(memory.content).byteLength,
389
+ });
390
+ let refusal;
391
+ for (const observation of observations) {
392
+ if (observation.kind === "memory_validation_failed" && observation.record_id === memory.record_id) {
393
+ refusal = String(observation.error ?? "memory write refused");
394
+ }
395
+ else {
396
+ this.pendingObservations.push(observation);
397
+ }
398
+ }
399
+ return refusal;
400
+ }
401
+ return checkMemoryWrite(this.kernelMemoryPolicyWire(), memory.name, memory.content);
402
+ }
403
+ /** The run's memory policy as the kernel's canonical wire carries it. */
404
+ kernelMemoryPolicyWire() {
405
+ if (!this.opts.memoryPolicy)
406
+ return undefined;
407
+ const { promotion_recall_threshold: threshold, ...rest } = memoryPolicyToKernel(this.opts.memoryPolicy);
408
+ return {
409
+ ...rest,
410
+ ...(threshold !== undefined ? { promotion_recall_threshold: String(threshold) } : {}),
411
+ };
339
412
  }
340
- async appendMemorySyscallObservations(sessionId, observations) {
341
- if (!sessionId)
413
+ /**
414
+ * M3/M4 · a host-initiated recall (prefetch). The store reports the counts it holds; the kernel
415
+ * derives the next counts and the promotion edge, and the host only mirrors them — the same
416
+ * derivation a model `query_memory` resolution journals as `memory_recalled`.
417
+ */
418
+ async recordHostMemoryRecall(hits) {
419
+ if (!this.opts.agentId)
342
420
  return;
343
- for (const observation of observations) {
344
- if (!["memory_written", "memory_queried", "memory_validation_failed"].includes(observation.kind))
345
- continue;
346
- const event = kernelObservationToSessionEvent(observation, 0);
347
- if (event)
348
- await this.opts.sessionLog.append(sessionId, event);
421
+ const { recalls, promotions } = await deriveMemoryRecall(this.kernelMemoryPolicyWire(), Date.now(), hits);
422
+ if (recalls.length > 0)
423
+ await this.opts.memoryStore?.recordRecall?.(this.opts.agentId, recalls);
424
+ for (const promotion of promotions) {
425
+ this.opts.onPromotionSuggested?.({ recordId: promotion.record_id, recallCount: promotion.recall_count });
349
426
  }
350
427
  }
351
428
  /** Push content into Slot 2 (system_knowledge) via add_knowledge_message.
@@ -378,6 +455,41 @@ export class RuntimeRunner {
378
455
  }
379
456
  /** K3: host-driven skill deactivation — toolset re-widens at the next provider call, the
380
457
  * skill's knowledge pin drops at the next boundary. Errs-open: not-active is a no-op. */
458
+ /** §13.2 live policy patch (signal / governance / resource quota / recovery), guarded by the
459
+ * policy revision. Mirrors Node: the runtime tracks the committed revision; after a restore
460
+ * `expectedRevision` must be given. Rejects when the kernel refuses the patch. */
461
+ applyPolicyPatch(patch, opts = {}) {
462
+ return this.enqueueControl({
463
+ kind: "apply_policy_patch",
464
+ patch,
465
+ ...(opts.expectedRevision !== undefined ? { expected_revision: opts.expectedRevision } : {}),
466
+ });
467
+ }
468
+ /** Move the operation's absolute deadline (epoch ms), or clear it with `null`. */
469
+ updateDeadline(deadlineMs) {
470
+ return this.enqueueControl({ kind: "update_deadline", deadline_ms: deadlineMs });
471
+ }
472
+ /** Ask the kernel to compact context now; any archive it owes runs as a normal page-out effect. */
473
+ forceCompact() {
474
+ return this.enqueueControl({ kind: "force_compact" });
475
+ }
476
+ /** Host-driven skill activation, admitted by the kernel against the operation's skill catalog. */
477
+ activateSkill(name, opts = {}) {
478
+ return this.enqueueControl({
479
+ kind: "skill_activation",
480
+ name,
481
+ ...(opts.leaseTurns !== undefined ? { lease_turns: opts.leaseTurns } : {}),
482
+ });
483
+ }
484
+ enqueueControl(event) {
485
+ if (!this.activeKernel)
486
+ return Promise.reject(new Error(`${String(event.kind)} requires an active run`));
487
+ return new Promise((resolve, reject) => this.pendingControl.push({ event, resolve, reject }));
488
+ }
489
+ rejectPendingControl(reason) {
490
+ for (const pending of this.pendingControl.splice(0))
491
+ pending.reject(new Error(reason));
492
+ }
381
493
  async deactivateSkill(name) {
382
494
  if (!this.activeKernel)
383
495
  return;
@@ -627,6 +739,11 @@ export class RuntimeRunner {
627
739
  ? { ...baseSpec, capabilityFilter: { ...baseSpec.capabilityFilter, allowedIds: allowedToolIds } }
628
740
  : baseSpec;
629
741
  spec = { ...spec, exposureBaseline: baselineToolIds };
742
+ // The loaded milestone contract only gates a run whose spec names it; without this binding
743
+ // the kernel never publishes `evaluate_milestone` and the whole phase gate is inert.
744
+ if (this.opts.milestoneContract && !spec.verificationContractId) {
745
+ spec = { ...spec, verificationContractId: "node-default" };
746
+ }
630
747
  startRunSpec = agentRunSpecToKernel(spec);
631
748
  }
632
749
  if (!resumeMidRun)
@@ -656,6 +773,22 @@ export class RuntimeRunner {
656
773
  });
657
774
  break;
658
775
  }
776
+ if (this.pendingControl.length > 0) {
777
+ for (const pending of this.pendingControl.splice(0)) {
778
+ try {
779
+ await this.commitKernelMaybeAction(runtime, this.pendingObservations, pending.event);
780
+ pending.resolve();
781
+ }
782
+ catch (error) {
783
+ pending.reject(error);
784
+ }
785
+ }
786
+ // A command can publish an effect ahead of the one in hand (a forced compaction's page-out);
787
+ // the kernel's projection decides what runs next.
788
+ action = runtime.resumeAction() ?? action;
789
+ if (runtime.isTerminal())
790
+ break;
791
+ }
659
792
  if (this.opts.signalSource || this.injectedSignals.length > 0) {
660
793
  const delivery = await this.nextInboundSignal();
661
794
  if (delivery) {
@@ -683,22 +816,10 @@ export class RuntimeRunner {
683
816
  const attemptStartedAtMs = Date.now();
684
817
  const finalToolCalls = [];
685
818
  let finalText = "";
686
- // I5: governance schema-level pre-filter — see Node runner for full rationale.
687
- let context = action.context;
688
- let tools = action.tools;
689
- if (this.opts.governancePolicy && this.opts.governancePolicy.surfaceDeniedInSystem !== false) {
690
- const { allowed, denied } = governanceFilterSchema(tools, this.opts.governancePolicy);
691
- if (denied.length > 0) {
692
- tools = allowed;
693
- const note = `[governance] the following tools are denied for this run and will fail if called: ${denied.join(", ")}.`;
694
- context = {
695
- ...context,
696
- systemKnowledge: context.systemKnowledge
697
- ? `${context.systemKnowledge}\n\n${note}`
698
- : note,
699
- };
700
- }
701
- }
819
+ // I5: the kernel itself withholds statically denied tools (`hide_denied_tools`) and renders
820
+ // the denial note — the host sends exactly the context and surface the kernel committed.
821
+ const context = action.context;
822
+ const tools = action.tools;
702
823
  let turnTokens = 0;
703
824
  let turnInputTokens = 0;
704
825
  let turnOutputTokens = 0;
@@ -802,7 +923,7 @@ export class RuntimeRunner {
802
923
  finalText += evt.delta;
803
924
  else if (evt.type === "tool_call") {
804
925
  const tc = evt;
805
- finalToolCalls.push({ id: tc.id, name: tc.name, arguments: JSON.stringify(tc.arguments) });
926
+ finalToolCalls.push({ id: tc.id, name: tc.name, arguments: tc.rawArguments ?? JSON.stringify(tc.arguments) });
806
927
  }
807
928
  }
808
929
  }
@@ -879,7 +1000,6 @@ export class RuntimeRunner {
879
1000
  action = await this.commitKernelAction(runtime, this.pendingObservations, {
880
1001
  kind: "cancel_operation",
881
1002
  reason: this.cancellationReason ?? "user",
882
- pending_call_ids: [providerEffectId],
883
1003
  });
884
1004
  break;
885
1005
  }
@@ -932,7 +1052,29 @@ export class RuntimeRunner {
932
1052
  ...(attemptUsage !== undefined ? { usage: attemptUsage } : {}),
933
1053
  wireEvidence,
934
1054
  });
1055
+ // Skill content is STAGED before the provider resolution commits, so the kernel's next render
1056
+ // can include it. The kernel adjudicates the `skill` syscall in that commit and withdraws the
1057
+ // staged `skill:<name>` entry when it refuses the activation — the host never decides.
1058
+ if (this.opts.skillContentMap?.size) {
1059
+ for (const call of finalToolCalls) {
1060
+ if (call.name !== "skill")
1061
+ continue;
1062
+ const parsed = parseToolCallArguments(call.arguments);
1063
+ const name = parsed.ok ? String(parsed.args.name ?? "") : "";
1064
+ const raw = name ? this.opts.skillContentMap.get(name) : undefined;
1065
+ if (raw == null)
1066
+ continue;
1067
+ const content = stripSkillFrontmatter(raw);
1068
+ await this.pushKnowledge({ role: "system", content }, undefined, { key: `skill:${name}`, pinned: true });
1069
+ }
1070
+ }
1071
+ const providerObservationStart = this.pendingObservations.length;
935
1072
  action = await this.commitKernelAction(runtime, this.pendingObservations, providerEvent);
1073
+ for (const observation of this.pendingObservations.slice(providerObservationStart)) {
1074
+ if (observation.kind === "skill_admitted" && typeof observation.name === "string") {
1075
+ this.knowledgePushedSkills.add(observation.name);
1076
+ }
1077
+ }
936
1078
  await this.opts.sessionLog.append(sessionId, buildLlmCompletedEvent({
937
1079
  turn: runtime.turn(),
938
1080
  content: finalText,
@@ -980,11 +1122,34 @@ export class RuntimeRunner {
980
1122
  });
981
1123
  }
982
1124
  else if (action.kind === "persist_memory") {
1125
+ // The kernel hands over the validated canonical write, which carries no store identity:
1126
+ // build the full record here, and let the receipt name exactly what the store now holds.
1127
+ const canonicalMemory = action.memory;
1128
+ const acceptedAt = Number(canonicalMemory.accepted_at_ms ?? Date.now());
1129
+ const record = {
1130
+ record_id: `memory:${crypto.randomUUID()}`,
1131
+ scope: this.opts.memoryScope ?? { tenant_id: "default", namespace: this.opts.agentId ?? "default" },
1132
+ name: String(canonicalMemory.name ?? ""),
1133
+ kind: String(canonicalMemory.kind ?? "reference"),
1134
+ content: String(canonicalMemory.content ?? ""),
1135
+ description: String(canonicalMemory.description ?? ""),
1136
+ provenance: {
1137
+ author: "model",
1138
+ trust: "untrusted",
1139
+ evidence_refs: Array.isArray(canonicalMemory.evidence_refs) ? canonicalMemory.evidence_refs.map(String) : [],
1140
+ },
1141
+ created_at: acceptedAt,
1142
+ updated_at: acceptedAt,
1143
+ recall_count: 0,
1144
+ confidence: 1,
1145
+ links: [],
1146
+ pinned: false,
1147
+ };
983
1148
  let error;
984
1149
  try {
985
1150
  if (!this.opts.memoryStore || !this.opts.agentId)
986
1151
  throw new Error("WASM memory persistence requires memoryStore and agentId");
987
- await this.opts.memoryStore.put(this.opts.agentId, action.memory);
1152
+ await this.opts.memoryStore.put(this.opts.agentId, record);
988
1153
  }
989
1154
  catch (cause) {
990
1155
  error = formatToolError(cause);
@@ -992,7 +1157,10 @@ export class RuntimeRunner {
992
1157
  action = await this.commitKernelAction(runtime, this.pendingObservations, {
993
1158
  kind: "memory_persist_result",
994
1159
  effect_id: action.effectId,
995
- ...(error ? { error } : {}),
1160
+ ...(error ? { error } : {
1161
+ record_ref: record.record_id,
1162
+ digest: `sha256:${sha256Hex(record.content)}`,
1163
+ }),
996
1164
  });
997
1165
  }
998
1166
  else if (action.kind === "query_memory") {
@@ -1092,23 +1260,12 @@ export class RuntimeRunner {
1092
1260
  onPermissionRequest: this.opts.onPermissionRequest,
1093
1261
  };
1094
1262
  const toolResults = [];
1095
- // Syscall tools are consumed by core from the provider result and must never escape as host
1096
- // tool effects. Keep an explicit invariant check below so a projection drift fails closed.
1097
- const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes" || c.name === "start_workflow");
1098
- const planCalls = allCalls.filter(c => c.name === "update_plan");
1263
+ // Syscall tools (`update_plan` included) are consumed by core from the provider result: the
1264
+ // kernel decodes and applies them itself. One reaching this host effect projection is
1265
+ // boundary drift and fails closed — the host never re-applies a model plan as `update_task`.
1266
+ const submitCalls = allCalls.filter(c => c.name === "update_plan" || c.name === "submit_workflow_nodes" || c.name === "start_workflow");
1099
1267
  const normalCalls = allCalls.filter(c => c.name !== "submit_workflow_nodes" && c.name !== "start_workflow"
1100
1268
  && c.name !== "update_plan");
1101
- // `update_plan` is a kernel meta-tool (exposed via `enablePlanTool`), not a registered
1102
- // plane tool — resolve it here as an `update_task` apply, mirroring the node/python runners.
1103
- for (const call of planCalls) {
1104
- const update = parseUpdatePlanArgs(call.arguments);
1105
- await this.commitKernelApply(runtime, this.pendingObservations, {
1106
- kind: "update_task",
1107
- update: taskUpdateToKernel(update),
1108
- });
1109
- toolResults.push({ callId: call.id, output: "success", isError: false });
1110
- yield { type: "tool_result", callId: call.id, content: "success", isError: false };
1111
- }
1112
1269
  for (const call of submitCalls) {
1113
1270
  throw new Error(`canonical kernel published model syscall ${call.name} as a host tool effect`);
1114
1271
  }
@@ -1213,8 +1370,12 @@ export class RuntimeRunner {
1213
1370
  }
1214
1371
  if (!decision)
1215
1372
  continue;
1216
- if (typeof decision.replaceOutput === "string")
1373
+ if (typeof decision.replaceOutput === "string") {
1217
1374
  r.output = decision.replaceOutput;
1375
+ // The replacement is what every consumer sees: drop the raw structured blocks so the
1376
+ // durable session record cannot resurface content the host just redacted.
1377
+ delete r.contentParts;
1378
+ }
1218
1379
  if (decision.note)
1219
1380
  this.injectNote(decision.note);
1220
1381
  }
@@ -1231,34 +1392,8 @@ export class RuntimeRunner {
1231
1392
  content: { blocks: toolOutputBlocksToDurable(r.contentParts?.length ? r.contentParts : [{ type: "text", text: r.output }]) },
1232
1393
  })),
1233
1394
  });
1234
- // Canonical provider-result reduction activates a successfully resolved `skill` call. The
1235
- // host only pins its METHOD content — how to do something — for later turns.
1236
- //
1237
- // Strict dynamic context control: the skill text
1238
- // for the rest of the run, unlike a one-off memory/knowledge lookup (fact content, relevant
1239
- // for the moment it's used). So its text ALSO goes into the durable `knowledge` slot here
1240
- // (in addition to the ordinary tool_result already headed for `history`, where it will decay
1241
- // with the compression pyramid like any other tool output). First activation only.
1242
- for (const call of allCalls) {
1243
- if (call.name !== "skill")
1244
- continue;
1245
- const res = toolResults.find(r => r.callId === call.id);
1246
- if (!res || res.isError)
1247
- continue;
1248
- try {
1249
- const name = JSON.parse(call.arguments || "{}").name;
1250
- if (!name)
1251
- continue;
1252
- // With a lease configured, skip the Set optimization: an expired-then-reloaded skill
1253
- // must re-pin — only the kernel knows the lease state; its put dedupes anyway.
1254
- if (this.opts.skillLeaseTurns !== undefined || !this.knowledgePushedSkills.has(name)) {
1255
- this.knowledgePushedSkills.add(name);
1256
- // K1: keyed `skill:<name>` — the kernel-side put dedupes across runner instances.
1257
- this.pushKnowledge({ role: "system", content: res.output }, undefined, { key: `skill:${name}` });
1258
- }
1259
- }
1260
- catch { /* skip */ }
1261
- }
1395
+ // `skill` is a kernel syscall: it never reaches this host tool effect. The kernel admits or
1396
+ // refuses it at the provider resolution (and withdraws any content staged for a refusal).
1262
1397
  const entropyObsStart = this.pendingObservations.length;
1263
1398
  action = await this.commitKernelAction(runtime, this.pendingObservations, {
1264
1399
  kind: "tool_results",
@@ -1292,6 +1427,8 @@ export class RuntimeRunner {
1292
1427
  else if (action.kind === "evaluate_milestone") {
1293
1428
  const milestoneEffectId = action.effectId;
1294
1429
  const milestonePhaseId = action.phaseId;
1430
+ // Host-owned phase data: the kernel names the phase, the host contract says what it demands.
1431
+ const milestonePhase = this.opts.milestoneContract?.phases.find(phase => phase.id === milestonePhaseId);
1295
1432
  const milestonePolicy = this.opts.milestonePolicy ?? "require_verifier";
1296
1433
  if (milestonePolicy === "auto_pass") {
1297
1434
  action = await this.commitKernelAction(runtime, this.pendingObservations, {
@@ -1304,8 +1441,8 @@ export class RuntimeRunner {
1304
1441
  else if (this.opts.onMilestoneEvaluate) {
1305
1442
  const check = await this.opts.onMilestoneEvaluate({
1306
1443
  phaseId: action.phaseId,
1307
- criteria: action.criteria ?? [],
1308
- requiredEvidence: action.requiredEvidence ?? [],
1444
+ criteria: milestonePhase?.criteria ?? action.criteria ?? [],
1445
+ requiredEvidence: milestonePhase?.requiredEvidence ?? action.requiredEvidence ?? [],
1309
1446
  });
1310
1447
  action = await this.commitKernelAction(runtime, this.pendingObservations, {
1311
1448
  kind: "milestone_result",
@@ -1334,6 +1471,7 @@ export class RuntimeRunner {
1334
1471
  totalTokens: 0,
1335
1472
  }));
1336
1473
  yield { type: "done", iterations: turnsUsed, totalTokens: 0, status: "milestone_pending" };
1474
+ this.rejectPendingControl("the run ended before the control command was applied");
1337
1475
  this.activeKernel = null;
1338
1476
  this.currentSessionId = null;
1339
1477
  return;
@@ -1370,11 +1508,7 @@ export class RuntimeRunner {
1370
1508
  // I0b: kernel rejection (or any other thrown error inside the loop) is observable here —
1371
1509
  // emit run_terminal so downstream code sees a clean end rather than mid-loop EOF.
1372
1510
  const errMsg = formatToolError(err);
1373
- const code = err.code;
1374
- const isInvalidArg = code === "InvalidArg" ||
1375
- errMsg.toLowerCase().includes("invalidarg") ||
1376
- errMsg.toLowerCase().includes("invalid argument");
1377
- const reason = isInvalidArg ? "invalid_arg" : "error";
1511
+ const reason = isInvalidInputError(err) ? "invalid_arg" : "error";
1378
1512
  yield { type: "error", message: errMsg };
1379
1513
  try {
1380
1514
  await this.opts.sessionLog.append(sessionId, buildRunTerminalEvent({
@@ -1385,6 +1519,7 @@ export class RuntimeRunner {
1385
1519
  }
1386
1520
  catch { /* session log failure must not mask the original error */ }
1387
1521
  yield { type: "done", iterations: runtime.turn() || 0, totalTokens: 0, status: reason };
1522
+ this.rejectPendingControl("the run ended before the control command was applied");
1388
1523
  this.activeKernel = null;
1389
1524
  this.currentSessionId = null;
1390
1525
  return;
@@ -1434,6 +1569,7 @@ export class RuntimeRunner {
1434
1569
  // ③ loop-agent: surface the kernel-adjudicated after-round decision to the driver.
1435
1570
  ...(result?.paceDecision ? { paceDecision: result.paceDecision } : {}),
1436
1571
  };
1572
+ this.rejectPendingControl("the run ended before the control command was applied");
1437
1573
  this.activeKernel = null;
1438
1574
  this.currentSessionId = null;
1439
1575
  }
@@ -1707,6 +1843,7 @@ export class RuntimeRunner {
1707
1843
  }
1708
1844
  finally {
1709
1845
  if (bootstrapped) {
1846
+ this.rejectPendingControl("the run ended before the control command was applied");
1710
1847
  this.activeKernel = null;
1711
1848
  this.currentSessionId = null;
1712
1849
  this.abortController = null;
@@ -1720,7 +1857,7 @@ export class RuntimeRunner {
1720
1857
  * `WorkflowRun` down), and we abort the matching children's in-flight LLM calls. Returns the
1721
1858
  * torn-down outcome on preemption, else `null`. No-op without a signal source.
1722
1859
  */
1723
- async monitorWorkflowPreemption(runtime, controllers, batchState) {
1860
+ async monitorWorkflowPreemption(runtime, controllers, batchState, exclusive) {
1724
1861
  const source = this.opts.signalSource;
1725
1862
  if (!source && this.injectedSignals.length === 0)
1726
1863
  return null;
@@ -1733,29 +1870,36 @@ export class RuntimeRunner {
1733
1870
  await new Promise(resolve => setTimeout(resolve, 5));
1734
1871
  continue;
1735
1872
  }
1736
- const observationStart = this.pendingObservations.length;
1737
- const signalAction = await this.consumeInboundSignal(delivery, claimed => this.commitKernelMaybeAction(runtime, this.pendingObservations, signalToKernelEvent(claimed)));
1738
- if (signalAction) {
1739
- if (signalAction.kind !== "preempt_sub_agents") {
1740
- throw new Error(`workflow signal returned unexpected effect: ${signalAction.kind}`);
1873
+ const preemptedBySignal = await exclusive(async () => {
1874
+ const observationStart = this.pendingObservations.length;
1875
+ const signalAction = await this.consumeInboundSignal(delivery, claimed => this.commitKernelMaybeAction(runtime, this.pendingObservations, signalToKernelEvent(claimed)));
1876
+ if (signalAction) {
1877
+ if (signalAction.kind !== "preempt_sub_agents") {
1878
+ throw new Error(`workflow signal returned unexpected effect: ${signalAction.kind}`);
1879
+ }
1880
+ for (const id of signalAction.agentIds)
1881
+ controllers.get(id)?.abort();
1882
+ const continuation = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
1883
+ kind: "preempt_result", effect_id: signalAction.effectId,
1884
+ already_finished_agent_ids: signalAction.agentIds.filter(id => batchState.finished.has(id)),
1885
+ });
1886
+ if (continuation && continuation.kind !== "call_provider" && continuation.kind !== "done") {
1887
+ throw new Error(`workflow preemption returned unexpected effect: ${continuation.kind}`);
1888
+ }
1741
1889
  }
1742
- for (const id of signalAction.agentIds)
1743
- controllers.get(id)?.abort();
1744
- const continuation = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
1745
- kind: "preempt_result", effect_id: signalAction.effectId,
1746
- });
1747
- if (continuation && continuation.kind !== "call_provider" && continuation.kind !== "done") {
1748
- throw new Error(`workflow preemption returned unexpected effect: ${continuation.kind}`);
1890
+ const obs = this.pendingObservations.slice(observationStart);
1891
+ const preempted = obs.find(o => o.kind === "agent_preempted");
1892
+ if (preempted) {
1893
+ batchState.stopping = true;
1894
+ for (const id of preempted.agent_ids ?? [])
1895
+ controllers.get(id)?.abort();
1896
+ const wc = obs.find(o => o.kind === "workflow_completed");
1897
+ return (wc?.node_outcomes ?? []).map(workflowNodeOutcomeFromKernel);
1749
1898
  }
1750
- }
1751
- const obs = this.pendingObservations.slice(observationStart);
1752
- const preempted = obs.find(o => o.kind === "agent_preempted");
1753
- if (preempted) {
1754
- for (const id of preempted.agent_ids ?? [])
1755
- controllers.get(id)?.abort();
1756
- const wc = obs.find(o => o.kind === "workflow_completed");
1757
- return (wc?.node_outcomes ?? []).map(workflowNodeOutcomeFromKernel);
1758
- }
1899
+ return null;
1900
+ });
1901
+ if (preemptedBySignal)
1902
+ return preemptedBySignal;
1759
1903
  }
1760
1904
  return null;
1761
1905
  }
@@ -1802,98 +1946,144 @@ export class RuntimeRunner {
1802
1946
  // W-1: on resume it is pre-seeded from the persisted node outputs, so post-resume dependents
1803
1947
  // still see their (pre-crash) dependencies' outputs.
1804
1948
  const outputs = new Map(seedOutputs ?? []);
1805
- for (;;) {
1806
- if (nodes.length === 0)
1807
- return { nodeOutcomes: [], outputs: Object.fromEntries(outputs) };
1808
- for (const node of nodes) {
1809
- const dependencyOutputs = node.dependency_outputs ?? {};
1810
- for (const [agentId, output] of Object.entries(dependencyOutputs)) {
1811
- if (!outputs.has(agentId))
1812
- outputs.set(agentId, output);
1813
- }
1949
+ if (nodes.length === 0)
1950
+ return { nodeOutcomes: [], outputs: Object.fromEntries(outputs) };
1951
+ // Nodes run as soon as the kernel spawns them and each completion is fed back the moment it
1952
+ // lands — no round barrier, so a fast node's dependents never wait on a slow sibling. A child
1953
+ // that fails is an ordinary completion; a driver that throws aborts every sibling and propagates.
1954
+ // #2-B-ii: per-node abort controllers + a concurrent preemption monitor (see node runner). The
1955
+ // monitor and the completion feeds share one kernel queue; after a teardown nothing is fed.
1956
+ const controllers = new Map();
1957
+ const batchState = { settled: false, stopping: false, finished: new Set() };
1958
+ let kernelQueue = Promise.resolve();
1959
+ const exclusive = (work) => {
1960
+ const run = kernelQueue.then(work, work);
1961
+ kernelQueue = run.catch(() => undefined);
1962
+ return run;
1963
+ };
1964
+ const monitor = this.monitorWorkflowPreemption(runtime, controllers, batchState, exclusive);
1965
+ const inFlight = new Map();
1966
+ const launch = (node) => {
1967
+ const dependencyOutputs = node.dependency_outputs ?? {};
1968
+ for (const [agentId, output] of Object.entries(dependencyOutputs)) {
1969
+ if (!outputs.has(agentId))
1970
+ outputs.set(agentId, output);
1814
1971
  }
1815
- const roundBudget = budget;
1816
- // #2-B-ii: per-node abort controllers + a concurrent preemption monitor (see node runner).
1817
- const controllers = new Map(nodes.map(n => [n.agent_id, new AbortController()]));
1818
- const batchState = { settled: false };
1819
- const monitor = this.monitorWorkflowPreemption(runtime, controllers, batchState);
1820
- const results = await Promise.all(nodes.map(node => this.runWorkflowNode(node, parentSessionId, orchestrator, roundBudget, outputs, controllers.get(node.agent_id)?.signal)));
1972
+ const controller = new AbortController();
1973
+ controllers.set(node.agent_id, controller);
1974
+ const running = this.runWorkflowNode(node, parentSessionId, orchestrator, budget, outputs, controller.signal)
1975
+ .then(result => ({ kind: "node", node, result }))
1976
+ .finally(() => batchState.finished.add(node.agent_id));
1977
+ // A node can fail while the driver is busy feeding another completion; the race observes
1978
+ // it on the next pass, so it must not count as unhandled in between.
1979
+ running.catch(() => undefined);
1980
+ inFlight.set(node.agent_id, running);
1981
+ };
1982
+ const stopAll = async () => {
1821
1983
  batchState.settled = true;
1822
- const preempted = await monitor;
1823
- if (preempted)
1824
- return { nodeOutcomes: preempted, outputs: Object.fromEntries(outputs) };
1825
- // Accumulate next-batch nodes across feeds (per-node unblock can spawn dependents per feed).
1826
- const nextNodes = [];
1827
- done = undefined;
1828
- for (const result of results) {
1829
- // G2: record this node's output so a downstream reduce node can consume it.
1830
- const outContent = result.result.finalMessage?.content;
1831
- const outText = typeof outContent === "string" ? outContent : outContent != null ? JSON.stringify(outContent) : "";
1832
- outputs.set(result.agentId, outText);
1833
- // A loop iteration completes under `wf-node{N}-i{k}` but its dependents consume the STABLE
1834
- // node id `wf-node{N}` — alias it so the LAST iteration's output is what dependents see.
1835
- const stableId = result.agentId.replace(/-i\d+$/, "");
1836
- if (stableId !== result.agentId)
1837
- outputs.set(stableId, outText);
1838
- const observationStart = this.pendingObservations.length;
1839
- const completionAction = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
1840
- kind: "sub_agent_completed",
1841
- result: subAgentResultToKernel(result),
1842
- });
1843
- let obs = this.pendingObservations.slice(observationStart);
1844
- if (completionAction?.kind === "spawn_workflow") {
1845
- nextNodes.push(...completionAction.nodes);
1846
- budget = completionAction.budget ?? budget;
1847
- obs = [...obs, ...await acceptSpawn(completionAction)];
1848
- }
1849
- else if (completionAction?.kind === "call_provider") {
1850
- this.workflowContinuation = completionAction;
1851
- }
1852
- else if (completionAction?.kind === "done") {
1853
- // The correlated child completion may terminalize the workflow in the same canonical
1854
- // step. Its workflow_completed observation below remains the typed outcome source.
1855
- }
1856
- else if (completionAction) {
1857
- throw new Error(`workflow completion returned unexpected effect: ${completionAction.kind}`);
1984
+ for (const controller of controllers.values())
1985
+ controller.abort();
1986
+ await Promise.allSettled(inFlight.values());
1987
+ await monitor;
1988
+ };
1989
+ let watching = monitor.then(outcome => ({ kind: "monitor", outcome }));
1990
+ for (const node of nodes)
1991
+ launch(node);
1992
+ try {
1993
+ while (inFlight.size > 0) {
1994
+ const landed = await Promise.race([...inFlight.values(), ...(watching ? [watching] : [])]);
1995
+ if (landed.kind === "monitor") {
1996
+ watching = undefined;
1997
+ if (landed.outcome === null)
1998
+ continue;
1999
+ await stopAll();
2000
+ return { nodeOutcomes: landed.outcome, outputs: Object.fromEntries(outputs) };
1858
2001
  }
1859
- // Child-authored DAG additions are admitted only as part of the canonical child-completion
1860
- // resolution. Persist the projection only after core reports the admitted base index.
1861
- if (result.submittedNodes?.length) {
1862
- const submitted = obs.find(o => o.kind === "workflow_nodes_submitted");
1863
- if (submitted) {
1864
- await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
1865
- turn: runtime.turn(),
1866
- nodes: result.submittedNodes.map(workflowNodeSpecToKernel),
1867
- baseIndex: submitted.base,
1868
- submitterAgentId: result.agentId,
1869
- }));
2002
+ inFlight.delete(landed.node.agent_id);
2003
+ const settled = await exclusive(async () => {
2004
+ if (batchState.stopping)
2005
+ return { spawned: [], done: undefined };
2006
+ const { node, result } = landed;
2007
+ // G2: record this node's output so a downstream reduce node can consume it.
2008
+ const outContent = result.result.finalMessage?.content;
2009
+ const outText = typeof outContent === "string" ? outContent : outContent != null ? JSON.stringify(outContent) : "";
2010
+ outputs.set(result.agentId, outText);
2011
+ // A loop iteration completes under `wf-node{N}-i{k}` but its dependents consume the STABLE
2012
+ // node id `wf-node{N}` — alias it so the LAST iteration's output is what dependents see.
2013
+ const stableId = result.agentId.replace(/-i\d+$/, "");
2014
+ if (stableId !== result.agentId)
2015
+ outputs.set(stableId, outText);
2016
+ const observationStart = this.pendingObservations.length;
2017
+ const completionAction = await this.commitKernelMaybeAction(runtime, this.pendingObservations, {
2018
+ kind: "sub_agent_completed",
2019
+ result: subAgentResultToKernel(result),
2020
+ ...(node.attempt_id ? { attempt_id: node.attempt_id } : {}),
2021
+ });
2022
+ let obs = this.pendingObservations.slice(observationStart);
2023
+ const spawned = [];
2024
+ if (completionAction?.kind === "spawn_workflow") {
2025
+ spawned.push(...completionAction.nodes);
2026
+ budget = completionAction.budget ?? budget;
2027
+ obs = [...obs, ...await acceptSpawn(completionAction)];
2028
+ }
2029
+ else if (completionAction?.kind === "call_provider") {
2030
+ this.workflowContinuation = completionAction;
2031
+ }
2032
+ else if (completionAction?.kind === "done") {
2033
+ // The correlated child completion may terminalize the workflow in the same canonical
2034
+ // step. Its workflow_completed observation below remains the typed outcome source.
2035
+ }
2036
+ else if (completionAction) {
2037
+ throw new Error(`workflow completion returned unexpected effect: ${completionAction.kind}`);
1870
2038
  }
2039
+ // Child-authored DAG additions are admitted only as part of the canonical child-completion
2040
+ // resolution. Persist the projection only after core reports the admitted base index.
2041
+ if (result.submittedNodes?.length) {
2042
+ const submitted = obs.find(o => o.kind === "workflow_nodes_submitted");
2043
+ if (submitted) {
2044
+ await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
2045
+ turn: runtime.turn(),
2046
+ nodes: result.submittedNodes.map(workflowNodeSpecToKernel),
2047
+ baseIndex: submitted.base,
2048
+ submitterAgentId: result.agentId,
2049
+ }));
2050
+ }
2051
+ }
2052
+ // Persist node completion for resume recovery. W-1: the result-borne control signals ride
2053
+ // along (a resumed classifier re-prunes; a recorded loop stop is honored) plus the output
2054
+ // text (post-resume dependents/reduce still see this node's output).
2055
+ await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodeCompletedEvent({
2056
+ turn: runtime.turn(),
2057
+ agentId: result.agentId,
2058
+ status: workflowNodeStatusFromTermination(result.result.termination),
2059
+ termination: result.result.termination,
2060
+ classifyBranch: result.result.classifyBranch,
2061
+ tournamentWinner: result.result.tournamentWinner,
2062
+ loopContinue: result.result.loopContinue,
2063
+ ...(result.result.finalMessage ? { output: result.result.finalMessage } : {}),
2064
+ }));
2065
+ return { spawned, done: findDone(obs) };
2066
+ });
2067
+ if (settled.done && settled.spawned.length === 0 && inFlight.size === 0) {
2068
+ await stopAll();
2069
+ return {
2070
+ nodeOutcomes: (settled.done.node_outcomes ?? []).map(workflowNodeOutcomeFromKernel),
2071
+ outputs: Object.fromEntries(outputs),
2072
+ };
1871
2073
  }
1872
- const d = findDone(obs);
1873
- if (d)
1874
- done = d;
1875
- // Persist node completion for resume recovery. W-1: the result-borne control signals ride
1876
- // along (a resumed classifier re-prunes; a recorded loop stop is honored) plus the output
1877
- // text (post-resume dependents/reduce still see this node's output).
1878
- await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodeCompletedEvent({
1879
- turn: runtime.turn(),
1880
- agentId: result.agentId,
1881
- status: workflowNodeStatusFromTermination(result.result.termination),
1882
- termination: result.result.termination,
1883
- classifyBranch: result.result.classifyBranch,
1884
- tournamentWinner: result.result.tournamentWinner,
1885
- loopContinue: result.result.loopContinue,
1886
- ...(result.result.finalMessage ? { output: result.result.finalMessage } : {}),
1887
- }));
2074
+ for (const node of settled.spawned)
2075
+ launch(node);
1888
2076
  }
1889
- if (done && nextNodes.length === 0) {
1890
- return {
1891
- nodeOutcomes: (done.node_outcomes ?? []).map(workflowNodeOutcomeFromKernel),
1892
- outputs: Object.fromEntries(outputs),
1893
- };
1894
- }
1895
- nodes = nextNodes;
1896
2077
  }
2078
+ catch (error) {
2079
+ await stopAll();
2080
+ throw error;
2081
+ }
2082
+ await stopAll();
2083
+ const preempted = await monitor;
2084
+ if (preempted)
2085
+ return { nodeOutcomes: preempted, outputs: Object.fromEntries(outputs) };
2086
+ return { nodeOutcomes: [], outputs: Object.fromEntries(outputs) };
1897
2087
  }
1898
2088
  async appendObservations(sessionId, runtime, nextArchiveStart) {
1899
2089
  const turn = runtime.turn();
@@ -1945,11 +2135,17 @@ export class RuntimeRunner {
1945
2135
  try {
1946
2136
  const queries = await preQuery({ goal: this.currentGoal, phase });
1947
2137
  const lines = [];
2138
+ const seen = new Set();
2139
+ const recalled = [];
1948
2140
  for (const q of queries ?? []) {
1949
2141
  if (!q.query.trim())
1950
2142
  continue;
1951
2143
  const hits = await this.opts.memoryStore.search(this.opts.agentId, q);
1952
2144
  for (const hit of hits) {
2145
+ if (seen.has(hit.record.record_id))
2146
+ continue;
2147
+ seen.add(hit.record.record_id);
2148
+ recalled.push(hit);
1953
2149
  lines.push(`[memory record_id=${hit.record.record_id} trust=${hit.record.provenance.trust} score=${hit.score.toFixed(3)}] ${hit.record.content}`);
1954
2150
  }
1955
2151
  }
@@ -1959,6 +2155,7 @@ export class RuntimeRunner {
1959
2155
  message: { role: "user", content: lines.join("\n") },
1960
2156
  });
1961
2157
  }
2158
+ await this.recordHostMemoryRecall(recalled);
1962
2159
  }
1963
2160
  catch { /* errs-open */ }
1964
2161
  }
@@ -2132,47 +2329,27 @@ async function withTimeout(promise, timeoutMs) {
2132
2329
  clearTimeout(timer);
2133
2330
  }
2134
2331
  }
2135
- /** Parse `update_plan` meta-tool args into a task update (snake_case aliases accepted, mirroring
2136
- * the node/python runners). Malformed payload → an empty update (a no-op `update_task`). */
2137
- function parseUpdatePlanArgs(argsStr) {
2138
- let parsed = {};
2139
- try {
2140
- parsed = JSON.parse(argsStr);
2141
- }
2142
- catch {
2143
- // Ignore parse error → empty update.
2144
- }
2145
- return {
2146
- plan: parsed.plan,
2147
- currentStep: parsed.currentStep !== undefined ? Number(parsed.currentStep) : parsed.current_step !== undefined ? Number(parsed.current_step) : undefined,
2148
- progress: parsed.progress,
2149
- scratchpad: parsed.scratchpad,
2150
- blockedOn: parsed.blockedOn !== undefined
2151
- ? parsed.blockedOn
2152
- : parsed.blocked_on,
2153
- };
2154
- }
2155
- /** Lower a claimed signal delivery to the kernel's `deliver_signal` input event. Shared by the main
2156
- * loop's per-turn poll and #2-B-ii's workflow-batch preemption monitor (so the two never drift). */
2157
- function signalToKernelEvent(delivery) {
2332
+ /** Lower a claimed signal delivery straight into the kernel's `LogicalSignal` vocabulary. Shared by
2333
+ * the main loop's per-turn poll and #2-B-ii's workflow-batch preemption monitor (so the two never
2334
+ * drift). `signalType` / `coalesceKey` / `coalescedCount` are signal-source concepts the canonical
2335
+ * wire deliberately does not carry (§5n); the absolute `deadlineMs` becomes the duration the kernel
2336
+ * anchors to its own accepted time. */
2337
+ export function signalToKernelEvent(delivery, nowMs = Date.now()) {
2158
2338
  const sig = delivery.signal;
2159
2339
  return {
2160
2340
  kind: "deliver_signal",
2161
2341
  delivery_id: delivery.deliveryId,
2162
2342
  attempt: delivery.deliveryAttempt,
2163
2343
  signal: {
2164
- id: delivery.signalId,
2344
+ signal_id: delivery.signalId,
2165
2345
  source: sig.source ?? "custom",
2166
- signal_type: sig.signalType ?? "event",
2346
+ target: sig.recipient ? { kind: "task", task_id: sig.recipient } : { kind: "operation" },
2167
2347
  urgency: sig.urgency ?? "normal",
2168
- summary: String(sig.payload?.goal ?? "signal"),
2169
- payload: sig.payload ?? {},
2348
+ payload: delivery.note ?? sig.payload ?? {},
2170
2349
  ...(sig.dedupeKey ? { dedupe_key: sig.dedupeKey } : {}),
2171
- ...(sig.recipient ? { recipient: sig.recipient } : {}),
2172
- ...(sig.deadlineMs !== undefined ? { deadline_ms: sig.deadlineMs } : {}),
2173
- ...(sig.coalesceKey ? { coalesce_key: sig.coalesceKey } : {}),
2174
- coalesced_count: Math.max(1, sig.coalescedCount ?? 1),
2175
- timestamp_ms: Date.now(),
2350
+ ...(sig.deadlineMs !== undefined
2351
+ ? { escalate_after_ms: String(Math.max(0, Math.floor(sig.deadlineMs - nowMs))) }
2352
+ : {}),
2176
2353
  },
2177
2354
  };
2178
2355
  }