@sema-agent/core 7.11.0 → 7.11.1

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.
@@ -4,7 +4,7 @@ import { deliverDelegationLifecycle, deliverEngineNotice, undrainedUserInputNoti
4
4
  import { planRejectionClears, resolveTriggerWindow } from "../context-edit.js";
5
5
  import { AgentHarness, DEFAULT_COMPACTION_SETTINGS, isSyntheticApiErrorMessage, uuidv7 } from "../../internal/harness.js";
6
6
  import { snapshotActorAssertion } from "../../internal/llm.js";
7
- import { CheckpointError, remainingBudgetMicroUsd, readPendingSteerQueue, remainingTokens, resolveCheckpointStore, LEGACY_PENDING_STEER_INPUT_ID, MAX_STEER_INPUT_ID_CHARS, realApprovalOrgFact } from "../checkpoint-store.js";
7
+ import { CheckpointError, remainingBudgetMicroUsd, readPendingSteerQueue, remainingTokens, resolveCheckpointStore, realApprovalOrgFact } from "../checkpoint-store.js";
8
8
  import { GIT_STATUS_ECHO_PREVIEW, stripGitStatusUnits } from "./git-status-frame.js";
9
9
  import { settleExecutionRecord } from "./execution-record.js";
10
10
  import { engineVersion } from "../version.js";
@@ -46,7 +46,7 @@ import { settleTeardownLeg } from "./teardown-bounded.js";
46
46
  import { hasDestroy, isIsolated } from "../remote-env.js";
47
47
  import { hasBackgroundShell, sweepBackgroundShells } from "../background-shell.js";
48
48
  import { formatHookFeedback, hookSeatExpiredError, resolveHookTimeoutMs, runHookSeat } from "../hooks.js";
49
- import { buildHumanInputEvent, frameMidTurnUserInput, projectHumanInput } from "../human-input-projection.js";
49
+ import { buildHumanInputEvent, projectHumanInput } from "../human-input-projection.js";
50
50
  import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY, sanitizeUntrustedText } from "../untrusted-text.js";
51
51
  import { appendInterruptionMarker, reconcileInterruptedSession } from "../session-reconcile.js";
52
52
  import { RunnerSharedToolResultStore } from "../tool-result-store.js";
@@ -54,11 +54,11 @@ import { PAUSE_REGISTRY } from "../pause-registry.js";
54
54
  import { refuseOutOfContractDecision, toolPolicyNameSets } from "../tool-policy.js";
55
55
  import { mintGateOutcome } from "./gate-exit.js";
56
56
  import { defaultTaskRegistry } from "../task-registry.js";
57
- import { discloseDroppedPending, isDelegatedAgentTerminal, isSystemInjectionPriority, isTerminalTaskNotification, PendingSessionNotifications, renderTaskNotificationXml, SYSTEM_INJECTION_PRIORITIES, SystemInjectionQueue, taskNotificationDedupKey } from "../task-notification.js";
57
+ import { discloseDroppedPending, isDelegatedAgentTerminal, isTerminalTaskNotification, PendingSessionNotifications, renderTaskNotificationXml, SystemInjectionQueue, taskNotificationDedupKey } from "../task-notification.js";
58
58
  import { ToolDetachHub } from "../tool-detach.js";
59
59
  import { createPeerInboundChainRef, createPeerSelfRef } from "../../agents/peer-admission.js";
60
60
  import { createRunState } from "./initial-run-state.js";
61
- import { nextHumanInputSeq, sameAcceptedSteerInput } from "./steer-admission.js";
61
+ import { nextHumanInputSeq } from "./steer-admission.js";
62
62
  import { reconciledToolEndBody, toolEndBodyFrom, toolResultMsg, writeFamilyOfCanonical } from "./tool-end-body.js";
63
63
  import { answerFaceForRedeemedCall, deepJsonEqual, DEFERRED_REISSUE, pendingContentAskCallId, resumeContinuation, resumeDecisionWasNegative } from "./decide-continuation.js";
64
64
  import { awaitChargeWithSlowDisclosure, DEFAULT_PRECALL_OUTPUT_TOKENS, discloseUnevaluableWindow, platformLimitTerminal, resolveMaxTurns, startTimeout, TIMER_LATENESS_REPORT_MS } from "./clock-and-limits.js";
@@ -73,6 +73,11 @@ import { resumeInternalsAndConfig } from "./resume-internals-and-config.js";
73
73
  import { resumeApply } from "./resume-apply.js";
74
74
  import { resumePreflight } from "./resume-preflight.js";
75
75
  import { resumeClaim } from "./resume-claim.js";
76
+ import { streamSettleBackstop } from "./stream-settle-backstop.js";
77
+ import { streamReap } from "./stream-reap.js";
78
+ import { streamSteerVerb } from "./stream-steer-verb.js";
79
+ import { streamLifecycleVerbs } from "./stream-lifecycle-verbs.js";
80
+ import { streamHaltVerbs } from "./stream-halt-verbs.js";
76
81
  const STOP_HOOK_BLOCK_CAP = 8;
77
82
  const ORG_DISCLOSURE_MAX_CHARS = 600;
78
83
  const SUGGESTIONS_DEFAULT_COUNT = 3;
@@ -308,7 +313,6 @@ export class Runner {
308
313
  return Promise.race([p, timeout]).finally(() => clearTimeout(t));
309
314
  };
310
315
  let reapHandle;
311
- let steerChain = Promise.resolve();
312
316
  const acceptedSteerInputs = new Map();
313
317
  const notifyRef = {};
314
318
  const captureOptOutRef = {};
@@ -363,149 +367,29 @@ export class Runner {
363
367
  drainManualCompactWaiters("mooted");
364
368
  }
365
369
  })();
366
- const settled = run.catch(async (err) => {
367
- let code = errorCodeOf(err);
368
- const reopenReason = code === "resume.tool_unavailable" || code === "resume.tool_contract_mismatch" ? "tool_unavailable" : "env_failed";
369
- const reopenable = resume !== undefined && resume.pendingActionStarted !== true && reopenReason !== undefined && !(resumeDecisionWasNegative(resume) && resume.decisionDelivered === true);
370
- let reopenCommitted = false;
371
- let reopenStateUnknown = false;
372
- if (reopenable && resume?.onEnvRestoreFailed) {
373
- try {
374
- await resume.onEnvRestoreFailed(reopenReason);
375
- reopenCommitted = true;
376
- }
377
- catch (reopenErr) {
378
- this.parentConstraintRegistry.delete(resume.cp.token);
379
- const original = err instanceof Error ? err : new Error(String(err));
380
- const reopenMsg = reopenErr instanceof Error ? reopenErr.message : String(reopenErr);
381
- const definitive = errorCodeOf(reopenErr) === "checkpoint.reopen_failed";
382
- reopenStateUnknown = !definitive;
383
- err = Object.assign(new Error(definitive
384
- ? `${reopenMsg} (original resume failure: ${original.message})`
385
- : `checkpoint reopen threw mid-flight — state UNKNOWN, confirm via the checkpoint store before retrying: ${reopenMsg} (original resume failure: ${original.message})`, { cause: original }), { code: "checkpoint.reopen_failed" });
386
- code = "checkpoint.reopen_failed";
387
- }
388
- }
389
- if (resume)
390
- this.locallyClaimedTokens.delete(resume.cp.token);
391
- if (resume && !reopenCommitted) {
392
- this.parentConstraintRegistry.delete(resume.cp.token);
393
- await settleTeardownLeg(() => this.sessions.unpin?.(resume.cp.sessionId), "sessions.unpin (resume-failure leg)", (e) => this.deps.onError?.(e, { phase: "config", sessionId: resume.cp.sessionId }));
394
- if (!reopenStateUnknown) {
395
- const failedReap = this.suspendedEnvReaps.get(resume.cp.token);
396
- this.suspendedEnvReaps.delete(resume.cp.token);
397
- if (failedReap?.env !== undefined && hasDestroy(failedReap.env)) {
398
- const failedEnv = failedReap.env;
399
- await settleTeardownLeg(() => failedEnv.destroy(), "terminal resume-failure env destroy", (e) => this.deps.onError?.(e, { phase: "config", sessionId: resume.cp.sessionId }));
400
- }
401
- }
402
- }
403
- if (resultValue !== undefined) {
404
- try {
405
- this.deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "config", sessionId: resultValue.sessionId });
406
- }
407
- catch {
408
- }
409
- queue.close();
410
- return;
411
- }
412
- const remoteEnvFailure = (() => {
413
- let cur = err;
414
- for (let depth = 0; cur && typeof cur === "object" && depth < 8; depth++) {
415
- const note = cur.remoteEnvFailure;
416
- if (note !== undefined)
417
- return [note];
418
- cur = cur.cause;
419
- }
420
- return undefined;
421
- })();
422
- resultValue = {
423
- taskId: taskIdRef.current ?? "unknown",
424
- sessionId: taskIdRef.sessionId ?? "unknown",
425
- ...(taskIdRef.runId !== undefined ? { runId: taskIdRef.runId } : {}),
426
- terminal: { kind: "failed", ...(code !== undefined ? { code } : {}), message: err instanceof Error ? err.message : String(err) },
427
- result: "",
428
- ...(remoteEnvFailure !== undefined ? { remoteEnvFailures: remoteEnvFailure } : {}),
429
- ...(taskIdRef.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: taskIdRef.effectiveMemoryScopes } : {}),
430
- ...(taskIdRef.effectiveReasoning !== undefined ? { effectiveReasoning: taskIdRef.effectiveReasoning } : {}),
431
- ...(() => {
432
- const observed = taskIdRef.editedFiles?.();
433
- return observed !== undefined && observed.length > 0 ? { editedFiles: observed } : {};
434
- })(),
435
- ...(() => {
436
- const hinted = err.retryAfterMs;
437
- return code === "memory.admission_required" && typeof hinted === "number" && Number.isFinite(hinted) && hinted > 0
438
- ? { retryAfterMs: hinted }
439
- : {};
440
- })(),
441
- stats: { turns: 0, tokens: 0, toolCalls: 0, cachedTokens: 0, costMicroUsd: 0 },
442
- };
443
- emitTrace(entryTracer.tracer, () => ({
444
- kind: "task.end",
445
- version: 1,
446
- taskId: taskIdRef.current ?? "unknown",
447
- ...(taskIdRef.runId !== undefined ? { runId: taskIdRef.runId } : {}),
448
- status: "failed",
449
- errorCode: code,
450
- turns: 0,
451
- tokens: 0,
452
- durationMs: Date.now() - runStartedAt,
453
- ts: Date.now(),
454
- }));
455
- const owedTerminal = taskIdRef.delegationTerminalOwed;
456
- if (owedTerminal !== undefined) {
457
- taskIdRef.delegationTerminalOwed = undefined;
458
- deliverDelegationLifecycle(this.deps.onDelegationLifecycle, { phase: "terminal", identity: owedTerminal, status: "failed", turns: 0, ...(code !== undefined ? { errorCode: code } : {}) }, createSafeNotifier({
459
- onError: (f) => console.warn(`[sema-core] ${f.site}: delegation-lifecycle observer threw (contained; further failures counted, not re-disclosed): ${f.error.message}`),
460
- }), "runtask.onDelegationLifecycle");
461
- }
462
- if (forwardsSubagentEvents(spec))
463
- await drainForwardedFramesBeforeDone();
464
- queue.push({ type: "done", result: resultValue });
465
- queue.close();
466
- });
370
+ const live = {
371
+ get resultValue() { return resultValue; },
372
+ set resultValue(v) { resultValue = v; },
373
+ get handle() { return handle; },
374
+ get reapHandle() { return reapHandle; },
375
+ };
376
+ const settled = run.catch(streamSettleBackstop({
377
+ spec, resume, queue, taskIdRef, entryTracer, runStartedAt, live, runner: this.depsSeat, sessions: this.sessions,
378
+ locallyClaimedTokens: this.locallyClaimedTokens, parentConstraintRegistry: this.parentConstraintRegistry, suspendedEnvReaps: this.suspendedEnvReaps,
379
+ }).onRunRejected);
467
380
  void settled.then(() => acceptedSteerInputs.clear(), () => acceptedSteerInputs.clear());
468
381
  const steeringError = (msg, code = "steering.not_running") => {
469
382
  const e = new Error(`cannot steer: ${msg}`);
470
383
  e.code = code;
471
384
  return e;
472
385
  };
473
- let destroyOnce;
474
- const reapSuspended = async () => {
475
- const rh = reapHandle;
476
- if (!rh)
477
- return;
478
- const reportErr = (err) => {
479
- try {
480
- this.deps.onError?.(err, { phase: "config", sessionId: rh.sessionId });
481
- }
482
- catch {
483
- }
484
- };
485
- let won;
486
- try {
487
- won = await rh.store.expire(rh.token, rh.scope);
488
- }
489
- catch (err) {
490
- reportErr(err);
491
- return;
492
- }
493
- if (!won) {
494
- if (!this.locallyClaimedTokens.has(rh.token))
495
- this.suspendedEnvReaps.delete(rh.token);
496
- return;
497
- }
498
- this.parentConstraintRegistry.delete(rh.token);
499
- this.suspendedEnvReaps.delete(rh.token);
500
- if (rh.env && hasDestroy(rh.env)) {
501
- try {
502
- await rh.env.destroy();
503
- }
504
- catch (err) {
505
- reportErr(err);
506
- }
507
- }
508
- };
386
+ const { reapSuspended } = streamReap({
387
+ live, runner: this.depsSeat,
388
+ locallyClaimedTokens: this.locallyClaimedTokens, parentConstraintRegistry: this.parentConstraintRegistry, suspendedEnvReaps: this.suspendedEnvReaps,
389
+ });
390
+ const { steer } = streamSteerVerb({ spec, internals, queue, acceptedSteerInputs, live, ready, orTimeout, readyTimeoutMs: READY_TIMEOUT_MS, steeringError, runner: this.depsSeat });
391
+ const { notify, optOutMemoryCapture, compact, detach, interrupt } = streamLifecycleVerbs({ live, ready, orTimeout, steeringError, notifyRef, captureOptOutRef, manualCompactRef, detachHub });
392
+ const { halt, destroy } = streamHaltVerbs({ spec, live, ready, orTimeout, readyTimeoutMs: READY_TIMEOUT_MS, run, reapSuspended, runner: this.depsSeat });
509
393
  return {
510
394
  [Symbol.asyncIterator]: () => queue[Symbol.asyncIterator](),
511
395
  result: async () => {
@@ -516,374 +400,14 @@ export class Runner {
516
400
  await run.catch(() => { });
517
401
  return suggestionsDone.catch(() => []);
518
402
  },
519
- steer: async (text, options) => {
520
- const trusted = options?.trusted ? true : false;
521
- if (trusted && sanitizeUntrustedText(text) !== text) {
522
- throw steeringError("trusted steering text must not contain a </system-reminder> tag", "steering.invalid_content");
523
- }
524
- const inputId = options?.inputId;
525
- if (inputId !== undefined) {
526
- if (typeof inputId !== "string") {
527
- throw steeringError("inputId must be a string when supplied", "steering.invalid_content");
528
- }
529
- if (inputId === "" || inputId.length > MAX_STEER_INPUT_ID_CHARS) {
530
- throw steeringError(`inputId must be a non-empty string of at most ${MAX_STEER_INPUT_ID_CHARS} characters`, "steering.invalid_content");
531
- }
532
- if (inputId === LEGACY_PENDING_STEER_INPUT_ID) {
533
- throw steeringError(`inputId "${LEGACY_PENDING_STEER_INPUT_ID}" is reserved for a pre-queue parked steer and cannot be supplied by a caller`, "steering.invalid_content");
534
- }
535
- }
536
- const priorityIn = options?.priority;
537
- if (priorityIn !== undefined && !isSystemInjectionPriority(priorityIn)) {
538
- throw steeringError(`priority must be one of ${SYSTEM_INJECTION_PRIORITIES.join("/")} when supplied`, "steering.invalid_content");
539
- }
540
- const priority = priorityIn ?? "next";
541
- const actorIn = options?.actor;
542
- const actor = actorIn === undefined ? undefined : snapshotActorAssertion(actorIn);
543
- const projected = projectHumanInput({ text, actor, source: "steer" });
544
- const effectiveInputId = typeof inputId === "string" ? inputId : uuidv7();
545
- const parkRecord = {
546
- text,
547
- trusted,
548
- inputId: effectiveInputId,
549
- ...(priorityIn !== undefined ? { priority } : {}),
550
- ...(actor !== undefined ? { actor } : {}),
551
- };
552
- let payload;
553
- let mintsAFrame;
554
- let replay;
555
- const noteAccepted = (h) => {
556
- if (!mintsAFrame)
557
- return;
558
- if (typeof inputId === "string")
559
- acceptedSteerInputs.set(inputId, replay);
560
- queue.push({
561
- ...buildHumanInputEvent({
562
- carrier: "steer",
563
- source: "steer",
564
- delivery: "queued",
565
- sessionSeq: nextHumanInputSeq(h.harness),
566
- inputId: effectiveInputId,
567
- ...(actor !== undefined ? { actor } : {}),
568
- ...(actor?.issuer !== undefined ? { issuer: actor.issuer } : {}),
569
- ...(spec.principal !== undefined ? { principal: spec.principal } : {}),
570
- }),
571
- eventId: uuidv7(),
572
- ...(internals?.parentToolCallId !== undefined
573
- ? { parentToolCallId: internals.parentToolCallId, ...(spec.taskId !== undefined ? { sourceTaskId: spec.taskId } : {}) }
574
- : {}),
575
- });
576
- };
577
- const deliver = async () => {
578
- if (resultValue)
579
- throw steeringError("the task has already finished");
580
- const h = handle ?? (await orTimeout(ready));
581
- if (!h)
582
- throw steeringError("the task is not running");
583
- payload = trusted ? formatHookFeedback(projected, h.reminderMark) : frameMidTurnUserInput(projected);
584
- mintsAFrame = payload.trim().length !== 0;
585
- replay = { payload, trusted, priority, ...(actor !== undefined ? { actor } : {}) };
586
- if (typeof inputId === "string") {
587
- const prior = acceptedSteerInputs.get(inputId);
588
- if (prior !== undefined) {
589
- if (resultValue !== undefined || h.loop.ended)
590
- throw steeringError("the task is no longer running");
591
- if (!sameAcceptedSteerInput(prior, replay)) {
592
- throw steeringError("a different steering instruction was already accepted under this inputId — re-issue this one with a fresh inputId " +
593
- "(an identical payload would have been an idempotent retry)", "steering.duplicate_input_id");
594
- }
595
- return;
596
- }
597
- }
598
- if (resultValue !== undefined || h.loop.ended)
599
- throw steeringError("the task is no longer running");
600
- if (mintsAFrame) {
601
- const screen = (spec.hooks ?? this.deps.hooks)?.userPromptSubmit;
602
- if (screen !== undefined) {
603
- let decision;
604
- try {
605
- const seat = await runHookSeat("userPromptSubmit", { timeoutMs: h.hookTimeoutMs, signal: h.abortController.signal, abortEnds: true }, (sig) => screen(text, { identity: h.hookIdentity, signal: sig, source: "steer", inputId: effectiveInputId, ...(actor !== undefined ? { actor: snapshotActorAssertion(actor) } : {}) }));
606
- if (seat.expired) {
607
- if (seat.cause === "timeout") {
608
- try {
609
- this.deps.onError?.(hookSeatExpiredError("userPromptSubmit", h.hookTimeoutMs, seat.cause, "the steering input was NOT accepted (fail-closed) and the caller was refused typed"), { phase: "hook", sessionId: h.sessionId });
610
- }
611
- catch {
612
- }
613
- }
614
- throw steeringError(seat.cause === "timeout"
615
- ? `the deployment's userPromptSubmit hook did not answer within its ${h.hookTimeoutMs}ms bound while screening this steering input; the input was NOT accepted (fail-closed)`
616
- : `the task was cancelled while the deployment's userPromptSubmit hook was still screening this steering input; the input was NOT accepted (fail-closed)`, "steering.blocked_by_hook");
617
- }
618
- decision = seat.value;
619
- }
620
- catch (hookErr) {
621
- if (hookErr instanceof Error && hookErr.code === "steering.blocked_by_hook")
622
- throw hookErr;
623
- const err = hookErr instanceof Error ? hookErr : new Error(String(hookErr));
624
- try {
625
- this.deps.onError?.(err, { phase: "hook", sessionId: h.sessionId });
626
- }
627
- catch {
628
- }
629
- throw steeringError(`the deployment's userPromptSubmit hook crashed while screening this steering input (${inlineUntrusted(err.message)}); the input was NOT accepted (fail-closed)`, "steering.blocked_by_hook");
630
- }
631
- if (decision?.block !== undefined && decision.block !== "") {
632
- throw steeringError(`the deployment's userPromptSubmit hook blocked this steering input: ${inlineUntrusted(decision.block)}`, "steering.blocked_by_hook");
633
- }
634
- if (decision?.additionalContext !== undefined && decision.additionalContext !== "") {
635
- payload = `${formatHookFeedback(decision.additionalContext, h.reminderMark)}\n\n${payload}`;
636
- }
637
- }
638
- }
639
- const injectFramed = async () => {
640
- const noteOptions = { provenance: "engine-note", callerAuthored: true, parkRecord, ...(actor !== undefined ? { actor } : {}) };
641
- if (priority === "later") {
642
- await h.harness.followUp(payload, noteOptions);
643
- noteAccepted(h);
644
- return;
645
- }
646
- const frame = await h.harness.steer(payload, { ...noteOptions, ...(priority === "now" ? { immediate: true } : {}) });
647
- noteAccepted(h);
648
- if (priority === "now" && frame !== undefined && h.harness.interruptTurn(frame)) {
649
- deliverEngineNotice(this.deps.onNotice, {
650
- code: "task.turn_interrupted",
651
- message: "a caller-provenance steer with priority \"now\" interrupted the running turn: in-flight work was cut at " +
652
- "a manufactured boundary (finished tool calls keep their real results; never-started ones settle as " +
653
- "interrupted) and the run continues with the steer at the queue head.",
654
- detail: {
655
- inputId: effectiveInputId,
656
- sessionId: h.sessionId,
657
- runId: h.runId,
658
- ...(actor?.id !== undefined ? { actorId: actor.id } : {}),
659
- ...(spec.taskId !== undefined ? { taskId: spec.taskId } : {}),
660
- },
661
- });
662
- }
663
- };
664
- try {
665
- await injectFramed();
666
- return;
667
- }
668
- catch (e) {
669
- if (!(e instanceof Error && e.code === "invalid_state"))
670
- throw e;
671
- }
672
- const birthDeadline = Date.now() + READY_TIMEOUT_MS;
673
- while (resultValue === undefined && !h.loop.ended && Date.now() < birthDeadline) {
674
- try {
675
- await injectFramed();
676
- return;
677
- }
678
- catch (e2) {
679
- if (!(e2 instanceof Error && e2.code === "invalid_state"))
680
- throw e2;
681
- }
682
- await new Promise((r) => setTimeout(r, 10));
683
- }
684
- throw steeringError("the task is no longer running");
685
- };
686
- const p = steerChain.then(deliver);
687
- steerChain = p.then(() => undefined, () => undefined);
688
- return p;
689
- },
690
- notify: async (input, opts) => {
691
- const notifyError = (msg, code) => {
692
- const e = new Error(`cannot notify: ${msg}`);
693
- e.code = code;
694
- return e;
695
- };
696
- if (typeof input?.task_id !== "string" || input.task_id.trim() === "") {
697
- throw notifyError("input.task_id must be a non-empty string", "notify.invalid_payload");
698
- }
699
- const VALID_STATUSES = new Set(["completed", "failed", "killed", "cancelled", "event"]);
700
- if (!VALID_STATUSES.has(input.status)) {
701
- throw notifyError(`input.status must be one of ${[...VALID_STATUSES].join("/")}`, "notify.invalid_payload");
702
- }
703
- if (typeof input.summary !== "string" || input.summary.trim() === "") {
704
- throw notifyError("input.summary must be a non-empty string", "notify.invalid_payload");
705
- }
706
- if (input.result !== undefined && typeof input.result !== "string") {
707
- throw notifyError("input.result must be a string when present", "notify.invalid_payload");
708
- }
709
- if (input.seq !== undefined && (typeof input.seq !== "number" || !Number.isFinite(input.seq))) {
710
- throw notifyError("input.seq must be a finite number when present", "notify.invalid_payload");
711
- }
712
- if (input.source !== undefined && typeof input.source !== "string") {
713
- throw notifyError("input.source must be a string when present", "notify.invalid_payload");
714
- }
715
- const priorityIn = opts?.priority;
716
- if (priorityIn !== undefined && !isSystemInjectionPriority(priorityIn)) {
717
- throw notifyError(`opts.priority must be one of ${SYSTEM_INJECTION_PRIORITIES.join("/")} when present`, "notify.invalid_payload");
718
- }
719
- const priority = priorityIn;
720
- if (priority === "now") {
721
- throw notifyError('priority "now" (turn interrupt) is not a notification-lane power — it belongs to the steer face; use "next" for earliest-boundary delivery', "notify.invalid_priority");
722
- }
723
- const payload = {
724
- task_id: input.task_id,
725
- task_type: "external",
726
- status: input.status,
727
- summary: input.summary,
728
- ...(input.result !== undefined ? { result: input.result } : {}),
729
- ...(input.seq !== undefined ? { seq: input.seq } : {}),
730
- ...(input.source !== undefined ? { source: input.source } : {}),
731
- };
732
- const h = handle ?? (await orTimeout(ready));
733
- if (!h || notifyRef.inject === undefined) {
734
- throw notifyError("the task is not running", "notify.not_running");
735
- }
736
- notifyRef.inject(payload, priority !== undefined ? { priority } : undefined);
737
- },
738
- optOutMemoryCapture: async (options) => {
739
- const reasonIn = options?.reason;
740
- if (reasonIn !== undefined && typeof reasonIn !== "string") {
741
- const e = new Error(`cannot opt out of memory capture: options.reason must be a string when present`);
742
- e.code = "steering.invalid_content";
743
- throw e;
744
- }
745
- const reason = reasonIn;
746
- if (resultValue)
747
- throw steeringError("the task has already finished");
748
- const h = handle ?? (await orTimeout(ready));
749
- if (!h)
750
- throw steeringError("the task is not running");
751
- if (captureOptOutRef.flip === undefined) {
752
- const e = new Error(`cannot opt out of memory capture: this run mounted no memory session (no memory spec / no backend / prepare degraded memory-less) — there is nothing to opt out of`);
753
- e.code = "memory.capture_optout_unavailable";
754
- throw e;
755
- }
756
- return captureOptOutRef.flip(reason);
757
- },
758
- compact: async (opts) => {
759
- if (resultValue)
760
- throw steeringError("the task has already finished");
761
- if (opts?.signal?.aborted)
762
- return "mooted";
763
- const h = handle ?? (await orTimeout(ready));
764
- if (!h)
765
- throw steeringError("the task is not running");
766
- if (opts?.signal?.aborted)
767
- return "mooted";
768
- if (manualCompactRef.closed)
769
- return "mooted";
770
- return new Promise((resolve) => {
771
- const signal = opts?.signal;
772
- const entry = {
773
- resolve,
774
- ...(signal !== undefined ? { signal } : {}),
775
- ...(opts?.instructions !== undefined ? { instructions: opts.instructions } : {}),
776
- };
777
- if (signal !== undefined) {
778
- const onAbort = () => {
779
- const i = manualCompactRef.waiters.indexOf(entry);
780
- if (i >= 0) {
781
- manualCompactRef.waiters.splice(i, 1);
782
- if (manualCompactRef.waiters.length === 0) {
783
- manualCompactRef.requested = false;
784
- }
785
- manualCompactRef.emitMooted?.("cancelled");
786
- entry.resolve("mooted");
787
- }
788
- };
789
- entry.resolve = (outcome) => {
790
- signal.removeEventListener("abort", onAbort);
791
- resolve(outcome);
792
- };
793
- signal.addEventListener("abort", onAbort, { once: true });
794
- }
795
- manualCompactRef.requested = true;
796
- manualCompactRef.waiters.push(entry);
797
- });
798
- },
799
- detach: (toolCallId) => {
800
- detachHub.request(toolCallId);
801
- },
802
- interrupt: async () => {
803
- const h = handle ?? (await orTimeout(ready));
804
- if (!h)
805
- return;
806
- if (!h.abortController.signal.aborted)
807
- h.loop.userInterrupted = true;
808
- h.abortController.abort();
809
- void h.harness.abort();
810
- },
811
- halt: async () => {
812
- const haltRefused = (msg) => {
813
- const e = new Error(`cannot halt: ${msg}`);
814
- e.code = "steering.not_running";
815
- return e;
816
- };
817
- if (resultValue)
818
- throw haltRefused("the task has already finished");
819
- const h = handle ?? (await orTimeout(ready));
820
- if (!h)
821
- throw haltRefused("the task is not running");
822
- const apply = () => {
823
- const abortOwnedBeforeHalt = h.abortController.signal.aborted;
824
- const receipt = h.harness.halt();
825
- if (receipt.accepted && !abortOwnedBeforeHalt) {
826
- h.loop.userHalted = true;
827
- }
828
- else {
829
- deliverEngineNotice(this.deps.onNotice, {
830
- code: "task.halt_unconsumed",
831
- message: "a user halt arrived while the run was already ending for its own reason: nothing was cut or stopped " +
832
- "by it — the run's own ending stands, and the result will not carry haltedByUser for this halt.",
833
- detail: {
834
- sessionId: h.sessionId,
835
- runId: h.runId,
836
- ...(spec.taskId !== undefined ? { taskId: spec.taskId } : {}),
837
- },
838
- });
839
- }
840
- if (receipt.turnCut) {
841
- deliverEngineNotice(this.deps.onNotice, {
842
- code: "task.turn_interrupted",
843
- message: "a bare user halt cut the running turn: in-flight work was cut at a manufactured boundary " +
844
- "(finished tool calls keep their real results; never-started ones settle as interrupted) and " +
845
- "the run is collecting to a clean, resumable stop — no further model turn will start.",
846
- detail: {
847
- cause: "user_halt",
848
- sessionId: h.sessionId,
849
- runId: h.runId,
850
- ...(spec.taskId !== undefined ? { taskId: spec.taskId } : {}),
851
- },
852
- });
853
- }
854
- return { turnCut: receipt.turnCut };
855
- };
856
- try {
857
- if (resultValue !== undefined || h.loop.ended)
858
- throw haltRefused("the task is no longer running");
859
- return apply();
860
- }
861
- catch (e) {
862
- if (!(e instanceof Error && e.code === "invalid_state"))
863
- throw e;
864
- }
865
- const birthDeadline = Date.now() + READY_TIMEOUT_MS;
866
- while (resultValue === undefined && !h.loop.ended && Date.now() < birthDeadline) {
867
- try {
868
- return apply();
869
- }
870
- catch (e2) {
871
- if (!(e2 instanceof Error && e2.code === "invalid_state"))
872
- throw e2;
873
- }
874
- await new Promise((r) => setTimeout(r, 10));
875
- }
876
- throw haltRefused("the task is no longer running");
877
- },
878
- destroy: () => (destroyOnce ??= (async () => {
879
- const h = handle ?? (await orTimeout(ready));
880
- if (h) {
881
- h.abortController.abort();
882
- void h.harness.abort();
883
- }
884
- await orTimeout(run.catch(() => { }));
885
- await reapSuspended();
886
- })()),
403
+ steer,
404
+ notify,
405
+ optOutMemoryCapture,
406
+ compact,
407
+ detach,
408
+ interrupt,
409
+ halt,
410
+ destroy,
887
411
  };
888
412
  }
889
413
  async runLocked(spec, queue, setResult, onSuggestions, onReady, onSuspend, manualCompactRef, taskIdRef, resume, internals, notifyRef, captureOptOutRef, entryActor, entryTracer) {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * design/393 S4 — the TaskStream façade's HALT verbs (T7), verbatim from `Runner.runTaskStream`: `halt` (the bare
3
+ * user interrupt — the finished-task refusal, the fresh liveness test on the resolved handle, `apply` with its
4
+ * pre-abort ownership snapshot, the conjunctive attribution seat, the unconsumed-halt and turn-cut notices, and
5
+ * the birth-window poll; never on the steer FIFO chain) and `destroy` (the once-memoized reap: abort, await the
6
+ * run's settle bounded, then the reap lane's step). Two Runner reads, both the deployment's notice sink.
7
+ *
8
+ * Both closures are the ones the one-file façade returned, installed by the driver under their old names — no
9
+ * await inside either moved off its tick. `destroyOnce` (the verb's memo) moved with the verb: it is written and
10
+ * read by `destroy` alone.
11
+ */
12
+ import { type TaskSpec, type TaskStream } from "../types.js";
13
+ import type { LiveHandle, RunnerDepsSeat, TaskStreamLiveSeat } from "./contracts.js";
14
+ export interface StreamHaltVerbsInput {
15
+ /** borrowed-readonly — the task spec (`taskId` on the notices' detail). */
16
+ spec: TaskSpec;
17
+ /** borrowed-readonly — the stream's live state: `resultValue` (the finished-task refusal, the liveness tests) and `handle`. */
18
+ live: TaskStreamLiveSeat;
19
+ /** borrowed-readonly — the readiness promise; halt and destroy resolve the handle through it while the run body has not published one. */
20
+ ready: Promise<LiveHandle | undefined>;
21
+ /** borrowed-readonly — the driver's timeout race, used for the readiness wait and for destroy's wait on the run's settle. */
22
+ orTimeout: <T>(p: Promise<T>) => Promise<T | undefined>;
23
+ /** borrowed-readonly — the readiness bound in ms; the halt's birth-window poll reads it. */
24
+ readyTimeoutMs: number;
25
+ /** borrowed-readonly — the run promise: `destroy` awaits its settle (bounded) before reaping. */
26
+ run: Promise<void>;
27
+ /** borrowed-readonly — the reap lane's step, run after the run settled (the suspended-run env + checkpoint reap). */
28
+ reapSuspended: () => Promise<void>;
29
+ /** borrowed-readonly — the Runner's deployment deps, read LIVE (`onNotice`, after the readiness await). */
30
+ runner: RunnerDepsSeat;
31
+ }
32
+ export interface StreamHaltVerbsResult {
33
+ /** The stream's `halt` verb. */
34
+ halt: TaskStream["halt"];
35
+ /** The stream's `destroy` verb (idempotent at the promise level; never throws). */
36
+ destroy: TaskStream["destroy"];
37
+ }
38
+ export declare function streamHaltVerbs(input: StreamHaltVerbsInput): StreamHaltVerbsResult;