@raidiant/notifai 11.7.1 → 12.0.0-beta.6
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/CHANGELOG.md +48 -0
- package/NOTICE +16 -0
- package/THIRD_PARTY_NOTICES +725 -0
- package/bin/notifai.mjs +11387 -0
- package/data/install.ps1 +318 -0
- package/data/release.json +1 -0
- package/inventory.json +1 -0
- package/npm-adapter-files.json +52 -0
- package/package.json +14 -44
- package/README.md +0 -168
- package/dist/agent-update-notice.d.ts +0 -7
- package/dist/agent-update-notice.js +0 -77
- package/dist/agent-update-notice.js.map +0 -1
- package/dist/atomic-file.d.ts +0 -30
- package/dist/atomic-file.js +0 -148
- package/dist/atomic-file.js.map +0 -1
- package/dist/attendant-update.d.ts +0 -15
- package/dist/attendant-update.js +0 -96
- package/dist/attendant-update.js.map +0 -1
- package/dist/claude-wake.d.ts +0 -108
- package/dist/claude-wake.js +0 -369
- package/dist/claude-wake.js.map +0 -1
- package/dist/cli-bin.d.ts +0 -26
- package/dist/cli-bin.js +0 -220
- package/dist/cli-bin.js.map +0 -1
- package/dist/cli-contract.d.ts +0 -16
- package/dist/cli-contract.js +0 -27
- package/dist/cli-contract.js.map +0 -1
- package/dist/cli-release.d.ts +0 -36
- package/dist/cli-release.js +0 -85
- package/dist/cli-release.js.map +0 -1
- package/dist/client.d.ts +0 -109
- package/dist/client.js +0 -217
- package/dist/client.js.map +0 -1
- package/dist/codex-answer-control.d.ts +0 -14
- package/dist/codex-answer-control.js +0 -41
- package/dist/codex-answer-control.js.map +0 -1
- package/dist/codex-answer-presentation.d.ts +0 -18
- package/dist/codex-answer-presentation.js +0 -113
- package/dist/codex-answer-presentation.js.map +0 -1
- package/dist/codex-input-lifecycle.d.ts +0 -10
- package/dist/codex-input-lifecycle.js +0 -63
- package/dist/codex-input-lifecycle.js.map +0 -1
- package/dist/codex-native-control.d.ts +0 -13
- package/dist/codex-native-control.js +0 -120
- package/dist/codex-native-control.js.map +0 -1
- package/dist/codex-native-turn.d.ts +0 -37
- package/dist/codex-native-turn.js +0 -155
- package/dist/codex-native-turn.js.map +0 -1
- package/dist/codex-question-bindings.d.ts +0 -76
- package/dist/codex-question-bindings.js +0 -151
- package/dist/codex-question-bindings.js.map +0 -1
- package/dist/codex-queue-control.d.ts +0 -21
- package/dist/codex-queue-control.js +0 -59
- package/dist/codex-queue-control.js.map +0 -1
- package/dist/codex-tool-messages.d.ts +0 -7
- package/dist/codex-tool-messages.js +0 -126
- package/dist/codex-tool-messages.js.map +0 -1
- package/dist/codex-wake.d.ts +0 -58
- package/dist/codex-wake.js +0 -135
- package/dist/codex-wake.js.map +0 -1
- package/dist/command-session.d.ts +0 -14
- package/dist/command-session.js +0 -31
- package/dist/command-session.js.map +0 -1
- package/dist/commands-acknowledge.d.ts +0 -13
- package/dist/commands-acknowledge.js +0 -116
- package/dist/commands-acknowledge.js.map +0 -1
- package/dist/commands-agent-sessions.d.ts +0 -6
- package/dist/commands-agent-sessions.js +0 -51
- package/dist/commands-agent-sessions.js.map +0 -1
- package/dist/commands-ask.d.ts +0 -62
- package/dist/commands-ask.js +0 -503
- package/dist/commands-ask.js.map +0 -1
- package/dist/commands-auth.d.ts +0 -76
- package/dist/commands-auth.js +0 -421
- package/dist/commands-auth.js.map +0 -1
- package/dist/commands-close.d.ts +0 -6
- package/dist/commands-close.js +0 -273
- package/dist/commands-close.js.map +0 -1
- package/dist/commands-config.d.ts +0 -31
- package/dist/commands-config.js +0 -272
- package/dist/commands-config.js.map +0 -1
- package/dist/commands-core.d.ts +0 -145
- package/dist/commands-core.js +0 -190
- package/dist/commands-core.js.map +0 -1
- package/dist/commands-devices.d.ts +0 -41
- package/dist/commands-devices.js +0 -144
- package/dist/commands-devices.js.map +0 -1
- package/dist/commands-doctor.d.ts +0 -25
- package/dist/commands-doctor.js +0 -1553
- package/dist/commands-doctor.js.map +0 -1
- package/dist/commands-guidance.d.ts +0 -27
- package/dist/commands-guidance.js +0 -150
- package/dist/commands-guidance.js.map +0 -1
- package/dist/commands-harness-context.d.ts +0 -57
- package/dist/commands-harness-context.js +0 -102
- package/dist/commands-harness-context.js.map +0 -1
- package/dist/commands-hook-attend.d.ts +0 -70
- package/dist/commands-hook-attend.js +0 -655
- package/dist/commands-hook-attend.js.map +0 -1
- package/dist/commands-hook-diagnostics.d.ts +0 -37
- package/dist/commands-hook-diagnostics.js +0 -136
- package/dist/commands-hook-diagnostics.js.map +0 -1
- package/dist/commands-hook-install.d.ts +0 -26
- package/dist/commands-hook-install.js +0 -784
- package/dist/commands-hook-install.js.map +0 -1
- package/dist/commands-hook-run.d.ts +0 -29
- package/dist/commands-hook-run.js +0 -933
- package/dist/commands-hook-run.js.map +0 -1
- package/dist/commands-hook-shape.d.ts +0 -19
- package/dist/commands-hook-shape.js +0 -48
- package/dist/commands-hook-shape.js.map +0 -1
- package/dist/commands-init.d.ts +0 -42
- package/dist/commands-init.js +0 -901
- package/dist/commands-init.js.map +0 -1
- package/dist/commands-io.d.ts +0 -2
- package/dist/commands-io.js +0 -117
- package/dist/commands-io.js.map +0 -1
- package/dist/commands-logs.d.ts +0 -30
- package/dist/commands-logs.js +0 -201
- package/dist/commands-logs.js.map +0 -1
- package/dist/commands-native-acknowledge.d.ts +0 -8
- package/dist/commands-native-acknowledge.js +0 -114
- package/dist/commands-native-acknowledge.js.map +0 -1
- package/dist/commands-project.d.ts +0 -4
- package/dist/commands-project.js +0 -41
- package/dist/commands-project.js.map +0 -1
- package/dist/commands-receive.d.ts +0 -3
- package/dist/commands-receive.js +0 -59
- package/dist/commands-receive.js.map +0 -1
- package/dist/commands-send-support.d.ts +0 -59
- package/dist/commands-send-support.js +0 -258
- package/dist/commands-send-support.js.map +0 -1
- package/dist/commands-send.d.ts +0 -34
- package/dist/commands-send.js +0 -900
- package/dist/commands-send.js.map +0 -1
- package/dist/commands-setup-proof.d.ts +0 -43
- package/dist/commands-setup-proof.js +0 -134
- package/dist/commands-setup-proof.js.map +0 -1
- package/dist/commands-skill.d.ts +0 -21
- package/dist/commands-skill.js +0 -199
- package/dist/commands-skill.js.map +0 -1
- package/dist/commands-sounds.d.ts +0 -12
- package/dist/commands-sounds.js +0 -35
- package/dist/commands-sounds.js.map +0 -1
- package/dist/commands-update-check.d.ts +0 -6
- package/dist/commands-update-check.js +0 -102
- package/dist/commands-update-check.js.map +0 -1
- package/dist/commands-update-resume.d.ts +0 -7
- package/dist/commands-update-resume.js +0 -143
- package/dist/commands-update-resume.js.map +0 -1
- package/dist/commands-update-skill.d.ts +0 -5
- package/dist/commands-update-skill.js +0 -36
- package/dist/commands-update-skill.js.map +0 -1
- package/dist/commands-update.d.ts +0 -11
- package/dist/commands-update.js +0 -286
- package/dist/commands-update.js.map +0 -1
- package/dist/commands.d.ts +0 -26
- package/dist/commands.js +0 -27
- package/dist/commands.js.map +0 -1
- package/dist/config-schema.d.ts +0 -62
- package/dist/config-schema.js +0 -302
- package/dist/config-schema.js.map +0 -1
- package/dist/config.d.ts +0 -181
- package/dist/config.js +0 -357
- package/dist/config.js.map +0 -1
- package/dist/credentials.d.ts +0 -96
- package/dist/credentials.js +0 -342
- package/dist/credentials.js.map +0 -1
- package/dist/file-lock.d.ts +0 -34
- package/dist/file-lock.js +0 -367
- package/dist/file-lock.js.map +0 -1
- package/dist/generated-session-label.d.ts +0 -2
- package/dist/generated-session-label.js +0 -152
- package/dist/generated-session-label.js.map +0 -1
- package/dist/guidance-content.d.ts +0 -53
- package/dist/guidance-content.js +0 -248
- package/dist/guidance-content.js.map +0 -1
- package/dist/guidance-render.d.ts +0 -25
- package/dist/guidance-render.js +0 -47
- package/dist/guidance-render.js.map +0 -1
- package/dist/guidance.d.ts +0 -69
- package/dist/guidance.js +0 -173
- package/dist/guidance.js.map +0 -1
- package/dist/harness-session-title.d.ts +0 -22
- package/dist/harness-session-title.js +0 -74
- package/dist/harness-session-title.js.map +0 -1
- package/dist/harnesses.d.ts +0 -65
- package/dist/harnesses.js +0 -159
- package/dist/harnesses.js.map +0 -1
- package/dist/hermes-attendant.d.ts +0 -38
- package/dist/hermes-attendant.js +0 -337
- package/dist/hermes-attendant.js.map +0 -1
- package/dist/hermes-plugin.d.ts +0 -11
- package/dist/hermes-plugin.js +0 -299
- package/dist/hermes-plugin.js.map +0 -1
- package/dist/hook-acknowledgements.d.ts +0 -70
- package/dist/hook-acknowledgements.js +0 -490
- package/dist/hook-acknowledgements.js.map +0 -1
- package/dist/hook-adapter.d.ts +0 -48
- package/dist/hook-adapter.js +0 -419
- package/dist/hook-adapter.js.map +0 -1
- package/dist/hook-events.d.ts +0 -105
- package/dist/hook-events.js +0 -130
- package/dist/hook-events.js.map +0 -1
- package/dist/hook-gates.d.ts +0 -7
- package/dist/hook-gates.js +0 -20
- package/dist/hook-gates.js.map +0 -1
- package/dist/hook-input.d.ts +0 -10
- package/dist/hook-input.js +0 -64
- package/dist/hook-input.js.map +0 -1
- package/dist/hook-lifecycle.d.ts +0 -112
- package/dist/hook-lifecycle.js +0 -2031
- package/dist/hook-lifecycle.js.map +0 -1
- package/dist/hook-project-sessions.d.ts +0 -28
- package/dist/hook-project-sessions.js +0 -141
- package/dist/hook-project-sessions.js.map +0 -1
- package/dist/hook-question-lock.d.ts +0 -29
- package/dist/hook-question-lock.js +0 -217
- package/dist/hook-question-lock.js.map +0 -1
- package/dist/hook-question-retirement.d.ts +0 -74
- package/dist/hook-question-retirement.js +0 -314
- package/dist/hook-question-retirement.js.map +0 -1
- package/dist/hook-question-state.d.ts +0 -58
- package/dist/hook-question-state.js +0 -239
- package/dist/hook-question-state.js.map +0 -1
- package/dist/hook-session-state.d.ts +0 -127
- package/dist/hook-session-state.js +0 -488
- package/dist/hook-session-state.js.map +0 -1
- package/dist/hook-types.d.ts +0 -476
- package/dist/hook-types.js +0 -4
- package/dist/hook-types.js.map +0 -1
- package/dist/injection-render.d.ts +0 -36
- package/dist/injection-render.js +0 -61
- package/dist/injection-render.js.map +0 -1
- package/dist/install-hooks.d.ts +0 -470
- package/dist/install-hooks.js +0 -1622
- package/dist/install-hooks.js.map +0 -1
- package/dist/integration-health.d.ts +0 -26
- package/dist/integration-health.js +0 -133
- package/dist/integration-health.js.map +0 -1
- package/dist/interactive.d.ts +0 -22
- package/dist/interactive.js +0 -644
- package/dist/interactive.js.map +0 -1
- package/dist/invocation-context.d.ts +0 -47
- package/dist/invocation-context.js +0 -164
- package/dist/invocation-context.js.map +0 -1
- package/dist/local-path.d.ts +0 -7
- package/dist/local-path.js +0 -27
- package/dist/local-path.js.map +0 -1
- package/dist/logging.d.ts +0 -157
- package/dist/logging.js +0 -599
- package/dist/logging.js.map +0 -1
- package/dist/main-run.d.ts +0 -1
- package/dist/main-run.js +0 -42
- package/dist/main-run.js.map +0 -1
- package/dist/main.d.ts +0 -2
- package/dist/main.js +0 -12
- package/dist/main.js.map +0 -1
- package/dist/native-answer-operation.d.ts +0 -70
- package/dist/native-answer-operation.js +0 -247
- package/dist/native-answer-operation.js.map +0 -1
- package/dist/native-skills.d.ts +0 -64
- package/dist/native-skills.js +0 -167
- package/dist/native-skills.js.map +0 -1
- package/dist/node-floor.d.ts +0 -19
- package/dist/node-floor.js +0 -32
- package/dist/node-floor.js.map +0 -1
- package/dist/npm-invocation.d.ts +0 -34
- package/dist/npm-invocation.js +0 -53
- package/dist/npm-invocation.js.map +0 -1
- package/dist/openclaw-continuation-bridge.d.ts +0 -7
- package/dist/openclaw-continuation-bridge.js +0 -64
- package/dist/openclaw-continuation-bridge.js.map +0 -1
- package/dist/openclaw-gateway-readiness.d.ts +0 -1
- package/dist/openclaw-gateway-readiness.js +0 -24
- package/dist/openclaw-gateway-readiness.js.map +0 -1
- package/dist/openclaw-generation.d.ts +0 -21
- package/dist/openclaw-generation.js +0 -115
- package/dist/openclaw-generation.js.map +0 -1
- package/dist/openclaw-message-bridge.d.ts +0 -8
- package/dist/openclaw-message-bridge.js +0 -93
- package/dist/openclaw-message-bridge.js.map +0 -1
- package/dist/openclaw-pending.d.ts +0 -8
- package/dist/openclaw-pending.js +0 -47
- package/dist/openclaw-pending.js.map +0 -1
- package/dist/openclaw-plugin.d.ts +0 -75
- package/dist/openclaw-plugin.js +0 -1353
- package/dist/openclaw-plugin.js.map +0 -1
- package/dist/openclaw-session-access.d.ts +0 -12
- package/dist/openclaw-session-access.js +0 -79
- package/dist/openclaw-session-access.js.map +0 -1
- package/dist/opencode-plugin.d.ts +0 -88
- package/dist/opencode-plugin.js +0 -286
- package/dist/opencode-plugin.js.map +0 -1
- package/dist/orca-session-title.d.ts +0 -11
- package/dist/orca-session-title.js +0 -111
- package/dist/orca-session-title.js.map +0 -1
- package/dist/pack-feedback-slice.d.ts +0 -35
- package/dist/pack-feedback-slice.js +0 -126
- package/dist/pack-feedback-slice.js.map +0 -1
- package/dist/pairing-qr.d.ts +0 -5
- package/dist/pairing-qr.js +0 -24
- package/dist/pairing-qr.js.map +0 -1
- package/dist/pending-pairing.d.ts +0 -38
- package/dist/pending-pairing.js +0 -70
- package/dist/pending-pairing.js.map +0 -1
- package/dist/platform.d.ts +0 -67
- package/dist/platform.js +0 -140
- package/dist/platform.js.map +0 -1
- package/dist/process-identity.d.ts +0 -26
- package/dist/process-identity.js +0 -82
- package/dist/process-identity.js.map +0 -1
- package/dist/program.d.ts +0 -59
- package/dist/program.js +0 -691
- package/dist/program.js.map +0 -1
- package/dist/project-enablement.d.ts +0 -21
- package/dist/project-enablement.js +0 -71
- package/dist/project-enablement.js.map +0 -1
- package/dist/question-settlement-process.d.ts +0 -17
- package/dist/question-settlement-process.js +0 -35
- package/dist/question-settlement-process.js.map +0 -1
- package/dist/question-timing.d.ts +0 -46
- package/dist/question-timing.js +0 -48
- package/dist/question-timing.js.map +0 -1
- package/dist/readiness.d.ts +0 -153
- package/dist/readiness.js +0 -149
- package/dist/readiness.js.map +0 -1
- package/dist/release.d.ts +0 -31
- package/dist/release.js +0 -52
- package/dist/release.js.map +0 -1
- package/dist/send-attempts.d.ts +0 -26
- package/dist/send-attempts.js +0 -139
- package/dist/send-attempts.js.map +0 -1
- package/dist/send.d.ts +0 -115
- package/dist/send.js +0 -421
- package/dist/send.js.map +0 -1
- package/dist/session-activation.d.ts +0 -13
- package/dist/session-activation.js +0 -64
- package/dist/session-activation.js.map +0 -1
- package/dist/session-attendant-probe.d.ts +0 -85
- package/dist/session-attendant-probe.js +0 -173
- package/dist/session-attendant-probe.js.map +0 -1
- package/dist/session-attendant-state.d.ts +0 -70
- package/dist/session-attendant-state.js +0 -256
- package/dist/session-attendant-state.js.map +0 -1
- package/dist/session-attendant.d.ts +0 -143
- package/dist/session-attendant.js +0 -520
- package/dist/session-attendant.js.map +0 -1
- package/dist/session-delivery.d.ts +0 -249
- package/dist/session-delivery.js +0 -576
- package/dist/session-delivery.js.map +0 -1
- package/dist/session-handoff.d.ts +0 -98
- package/dist/session-handoff.js +0 -118
- package/dist/session-handoff.js.map +0 -1
- package/dist/session-input-wakes.d.ts +0 -78
- package/dist/session-input-wakes.js +0 -239
- package/dist/session-input-wakes.js.map +0 -1
- package/dist/session-inputs.d.ts +0 -57
- package/dist/session-inputs.js +0 -434
- package/dist/session-inputs.js.map +0 -1
- package/dist/session-labels.d.ts +0 -56
- package/dist/session-labels.js +0 -396
- package/dist/session-labels.js.map +0 -1
- package/dist/session-message-handoff.d.ts +0 -39
- package/dist/session-message-handoff.js +0 -107
- package/dist/session-message-handoff.js.map +0 -1
- package/dist/setup-destinations.d.ts +0 -23
- package/dist/setup-destinations.js +0 -44
- package/dist/setup-destinations.js.map +0 -1
- package/dist/skill-integrity.d.ts +0 -54
- package/dist/skill-integrity.js +0 -164
- package/dist/skill-integrity.js.map +0 -1
- package/dist/skill-source/manifest.json +0 -40
- package/dist/skill-source/notifai/SKILL.md +0 -350
- package/dist/skill-source/notifai/references/diagnostics.md +0 -36
- package/dist/skill-source/notifai/references/harness-setup.md +0 -384
- package/dist/skill-source/notifai/references/native-questions.md +0 -51
- package/dist/skill-source/notifai/references/notes-and-edits.md +0 -54
- package/dist/skill-source/notifai/references/send-details.md +0 -14
- package/dist/skill-source/notifai/references/updates.md +0 -116
- package/dist/skill-source/notifai/references/writing-examples.md +0 -56
- package/dist/sound-ref.d.ts +0 -23
- package/dist/sound-ref.js +0 -28
- package/dist/sound-ref.js.map +0 -1
- package/dist/ui/banner.d.ts +0 -22
- package/dist/ui/banner.js +0 -87
- package/dist/ui/banner.js.map +0 -1
- package/dist/ui/config-view.d.ts +0 -30
- package/dist/ui/config-view.js +0 -108
- package/dist/ui/config-view.js.map +0 -1
- package/dist/ui/help.d.ts +0 -44
- package/dist/ui/help.js +0 -107
- package/dist/ui/help.js.map +0 -1
- package/dist/ui/theme.d.ts +0 -90
- package/dist/ui/theme.js +0 -244
- package/dist/ui/theme.js.map +0 -1
- package/dist/update-handoff.d.ts +0 -29
- package/dist/update-handoff.js +0 -69
- package/dist/update-handoff.js.map +0 -1
- package/dist/url-policy.d.ts +0 -85
- package/dist/url-policy.js +0 -369
- package/dist/url-policy.js.map +0 -1
- package/dist/version.d.ts +0 -40
- package/dist/version.js +0 -132
- package/dist/version.js.map +0 -1
- package/dist/wake-support.d.ts +0 -40
- package/dist/wake-support.js +0 -95
- package/dist/wake-support.js.map +0 -1
package/dist/hook-lifecycle.js
DELETED
|
@@ -1,2031 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { confirmNativeAnswerTarget, recordConfirmedNativeAnswerTarget } from './native-answer-operation.js';
|
|
3
|
-
import { markCodexOrdinaryPresentation, mayRetireFromPrompt, reserveCurrentCodexQuestion } from './codex-question-bindings.js';
|
|
4
|
-
import { ApiCallError, isRetryableReplyPollError } from './client.js';
|
|
5
|
-
import { withFileLock } from './file-lock.js';
|
|
6
|
-
import { HARNESS_CAPABILITIES } from './harnesses.js';
|
|
7
|
-
import { gate } from './hook-gates.js';
|
|
8
|
-
import { acknowledgementBlockContext, amendAcceptedAnswers, answersContext, classifyJournaledAcknowledgements, finishCommittedDelivery, holdForAcknowledgement, owedAcknowledgements, observeQueuedMessagePrompt, presentedMessageAcknowledgements, reconcileAcknowledgementObligations, recoverQueuedAnswers, resetAcknowledgementBlocks, settleAcceptedAnswers, stageAcceptedAnswers, stopAnswerOutput, } from './hook-acknowledgements.js';
|
|
9
|
-
import { clearMatchingProjectSession, writeProjectSession } from './hook-project-sessions.js';
|
|
10
|
-
import { acquireClaimFile, releaseClaimFile, claimHandoffState, claimQuestionPush, releaseQuestionPush } from './hook-question-lock.js';
|
|
11
|
-
import { closeQuietly, drainOrphanRetirements, drainRetirements, finalizeReplies, selectedForClaimedDelivery, orphanRetirements, pendingAnsweredByPrompt, retirePendings, retiringQuestion, } from './hook-question-retirement.js';
|
|
12
|
-
import { admitQueuedQuestion, replaceFrozenSubmission, dropPendingQuestion, isSamePending, pendingQuestions, questionSubmissionAttempted, queuedQuestionStillEligible, rememberQuestionState, sourceContextAtHookEvent, summarizeRequestIds, } from './hook-question-state.js';
|
|
13
|
-
import { clearSessionStateUnlocked, markSessionEnded, pendingList, readSessionState, readSessionIncarnation, readSessionEndMarker, sessionHasEnded, sessionStatePath, updateSessionState, writeSessionStateUnlocked, recordSessionNotified, } from './hook-session-state.js';
|
|
14
|
-
import { currentProcessIdentity, processIdentityLiveness } from './process-identity.js';
|
|
15
|
-
import { userPromptContextOutput } from './session-activation.js';
|
|
16
|
-
import { answersAlreadyWritten, beginHandOff, recordUnclaimedHandOffs, recoverDeliveryJournal, sweepDeliveryJournals, } from './session-delivery.js';
|
|
17
|
-
import { WRITE_ABORTED_REASON } from './wake-support.js';
|
|
18
|
-
import { QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS, QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS, QUESTION_WAITER_CEILING_SECONDS, } from './question-timing.js';
|
|
19
|
-
import { buildDraft } from './send.js';
|
|
20
|
-
/**
|
|
21
|
-
* The wire contract's shortest reply window. A question submitted with less
|
|
22
|
-
* than this is rejected outright, and accepting one would in any case let the
|
|
23
|
-
* server go on taking an answer after the waiter that owns it has returned.
|
|
24
|
-
*/
|
|
25
|
-
export const MIN_REPLY_WINDOW_SECONDS = 60;
|
|
26
|
-
/**
|
|
27
|
-
* The terminal-first wait: the question sits in the terminal for
|
|
28
|
-
* `ask_grace_seconds` from when it was sent, and only then reaches companion
|
|
29
|
-
* devices.
|
|
30
|
-
*
|
|
31
|
-
* A plain timer, and nothing else. It once polled an idle signal so it could
|
|
32
|
-
* abandon the wait the moment the user touched the keyboard — necessary while
|
|
33
|
-
* the wait blocked the terminal, because a user wanting to answer locally was
|
|
34
|
-
* otherwise locked out of their own prompt. Presence no longer changes this
|
|
35
|
-
* deterministic timer; whether the harness holds or wakes is a separate route
|
|
36
|
-
* decision.
|
|
37
|
-
*
|
|
38
|
-
* The window is measured from registration, not from the turn's end: a
|
|
39
|
-
* question the agent asked five minutes ago while it kept working has already
|
|
40
|
-
* served its wait. A stamp from the future would otherwise consume the whole
|
|
41
|
-
* ceiling and leave no room to answer, so it starts no later than now.
|
|
42
|
-
*/
|
|
43
|
-
async function awaitTerminalFirstWindow(ctx, askedAt, ceilingAt) {
|
|
44
|
-
const graceSeconds = ctx.config.ask_grace_seconds.value;
|
|
45
|
-
if (graceSeconds <= 0)
|
|
46
|
-
return;
|
|
47
|
-
const until = Math.min(Math.min(askedAt, ctx.now()) + graceSeconds * 1000, ceilingAt);
|
|
48
|
-
const remaining = until - ctx.now();
|
|
49
|
-
if (remaining > 0)
|
|
50
|
-
await ctx.sleep(remaining);
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* A question is stored so a later hook can push it, and it reaches us from a
|
|
54
|
-
* shell command, so its size is whatever the agent typed. The push itself is
|
|
55
|
-
* bounded by the current 4096-byte push-provider envelopes; this bounds what
|
|
56
|
-
* sits on disk in the meantime, and keeps one runaway agent from writing
|
|
57
|
-
* megabytes per session.
|
|
58
|
-
*/
|
|
59
|
-
const MAX_STORED_QUESTION_CHARS = 2000;
|
|
60
|
-
/**
|
|
61
|
-
* One round of the waiter's reply poll. Short enough that several questions
|
|
62
|
-
* still share one wall clock, long enough not to hammer the server.
|
|
63
|
-
*/
|
|
64
|
-
const REPLY_POLL_SECONDS = 5;
|
|
65
|
-
/** Avoid hot-looping while a live observer cooperatively yields its claim. */
|
|
66
|
-
const CLAIM_HANDOFF_POLL_MS = 1_000;
|
|
67
|
-
/**
|
|
68
|
-
* Push a question and block for the answer.
|
|
69
|
-
*
|
|
70
|
-
* An answered question is closed the moment this wait sees the answer: the
|
|
71
|
-
* first device to answer claims the question and the others are told. Until
|
|
72
|
-
* that close, a later reply is a correction — so what this returns as "the"
|
|
73
|
-
* answer is always the latest reply, with the full stream alongside for
|
|
74
|
-
* free-text answers given in parts. An unanswered question stays open,
|
|
75
|
-
* because nobody has acted on the silence yet and the next turn can still
|
|
76
|
-
* collect the answer with `notifai replies`.
|
|
77
|
-
*/
|
|
78
|
-
/**
|
|
79
|
-
* Put one registered question on the user's devices. Submission only — the
|
|
80
|
-
* wait happens once, across every live question, in the caller.
|
|
81
|
-
*/
|
|
82
|
-
async function prepareQuestionSubmission(ctx, options) {
|
|
83
|
-
const collapseKey = `notifai-hook-${randomBytes(8).toString('base64url')}`;
|
|
84
|
-
// A draft carrying `reply` is rejected outright if it targets a device that
|
|
85
|
-
// cannot answer, so resolve the healthy companion platforms explicitly.
|
|
86
|
-
const answerable = await answerableDevices(ctx);
|
|
87
|
-
if (answerable.length === 0) {
|
|
88
|
-
return { error: 'no device can answer a question yet; leaving this to the terminal' };
|
|
89
|
-
}
|
|
90
|
-
const build = buildDraft(ctx.config, {
|
|
91
|
-
title: options.questions[0].text,
|
|
92
|
-
summary: options.summary,
|
|
93
|
-
...(options.body !== undefined ? { body: options.body } : {}),
|
|
94
|
-
lifecycle: { tier: 'needs_you' },
|
|
95
|
-
...(options.project !== undefined ? { project: options.project } : {}),
|
|
96
|
-
...(options.media !== undefined ? { media: options.media } : {}),
|
|
97
|
-
device: answerable,
|
|
98
|
-
reply: true,
|
|
99
|
-
replyWindow: Math.max(MIN_REPLY_WINDOW_SECONDS, options.windowSeconds),
|
|
100
|
-
questions: options.questions,
|
|
101
|
-
collapseKey,
|
|
102
|
-
}, options.source === undefined ? {} : { source: options.source });
|
|
103
|
-
if (!build.ok)
|
|
104
|
-
return { error: build.error };
|
|
105
|
-
return {
|
|
106
|
-
request_id: `req_${randomBytes(18).toString('base64url')}`,
|
|
107
|
-
idempotency_key: `hook-${randomBytes(12).toString('base64url')}`,
|
|
108
|
-
collapse_key: collapseKey,
|
|
109
|
-
device_ids: answerable,
|
|
110
|
-
draft: build.draft,
|
|
111
|
-
owner_deadline_at: options.ownerDeadlineAt,
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
function submitQuestion(ctx, sessionId, entry, intent, notes) {
|
|
115
|
-
try {
|
|
116
|
-
const attempt = admitQueuedQuestion(sessionId, ctx.env, entry, intent, ctx.now(), async () => {
|
|
117
|
-
const receipt = await ctx.client.submit({
|
|
118
|
-
request_id: intent.request_id,
|
|
119
|
-
idempotency_key: intent.idempotency_key,
|
|
120
|
-
draft: intent.draft,
|
|
121
|
-
}, 0);
|
|
122
|
-
if (receipt.request_id !== intent.request_id) {
|
|
123
|
-
throw new Error(`server replay returned ${receipt.request_id}, expected reserved ${intent.request_id}`);
|
|
124
|
-
}
|
|
125
|
-
recordConfirmedNativeAnswerTarget(sessionId, ctx.env, { ...entry, request_id: receipt.request_id });
|
|
126
|
-
// Wakes this session's dormant Session Attendant.
|
|
127
|
-
recordSessionNotified(sessionId, ctx.env, ctx.now());
|
|
128
|
-
return receipt;
|
|
129
|
-
}, ctx.service_identity);
|
|
130
|
-
if (attempt === null)
|
|
131
|
-
notes.push('the question was retired or its Approved Machine changed before submission; not uploading it');
|
|
132
|
-
return attempt;
|
|
133
|
-
}
|
|
134
|
-
catch (err) {
|
|
135
|
-
// Only synchronous local admission failed. The callback returns a promise,
|
|
136
|
-
// so transport failures stay on the caller's ambiguous-response path.
|
|
137
|
-
notes.push(`could not admit the queued question; preserving it without upload: ${String(err)}`);
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
function canSubmitCompleteWindow(ctx, intent, fallbackWindowSeconds) {
|
|
142
|
-
const frozenWindowSeconds = intent.draft.reply?.expires_in_seconds ?? fallbackWindowSeconds;
|
|
143
|
-
return (intent.owner_deadline_at - ctx.now() >=
|
|
144
|
-
(frozenWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000);
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* The waiter's wait: one bounded poll across every live question at once.
|
|
148
|
-
*
|
|
149
|
-
* Each round polls all of them concurrently, so several questions cost the
|
|
150
|
-
* same wall clock as one; the first round that finds any reply returns
|
|
151
|
-
* everything found in that round, and whatever was not answered stays
|
|
152
|
-
* registered. A question whose polling is permanently rejected drops out of
|
|
153
|
-
* the round rather than failing the whole wait.
|
|
154
|
-
*/
|
|
155
|
-
async function waitForAnyReply(ctx, requestIds, timeoutSeconds, interruption) {
|
|
156
|
-
const deadline = ctx.now() + timeoutSeconds * 1000;
|
|
157
|
-
let degraded = false;
|
|
158
|
-
let firstPoll = true;
|
|
159
|
-
const permanentFailures = new Map();
|
|
160
|
-
if (timeoutSeconds <= 0 || deadline <= ctx.now()) {
|
|
161
|
-
return { byRequest: new Map(), degraded: false, permanentFailures, interrupted: null };
|
|
162
|
-
}
|
|
163
|
-
for (;;) {
|
|
164
|
-
const beforePoll = interruption?.() ?? null;
|
|
165
|
-
if (beforePoll !== null) {
|
|
166
|
-
return { byRequest: new Map(), degraded, permanentFailures, interrupted: beforePoll };
|
|
167
|
-
}
|
|
168
|
-
const remainingMs = Math.max(0, deadline - ctx.now());
|
|
169
|
-
if (!firstPoll && remainingMs === 0) {
|
|
170
|
-
return { byRequest: new Map(), degraded, permanentFailures, interrupted: null };
|
|
171
|
-
}
|
|
172
|
-
firstPoll = false;
|
|
173
|
-
const pollSeconds = Math.min(REPLY_POLL_SECONDS, Math.max(0, Math.ceil(remainingMs / 1000)));
|
|
174
|
-
const results = await Promise.all(requestIds
|
|
175
|
-
.filter((requestId) => !permanentFailures.has(requestId))
|
|
176
|
-
.map(async (requestId) => {
|
|
177
|
-
try {
|
|
178
|
-
return { requestId, ...(await ctx.waitForFirstReply(requestId, pollSeconds)) };
|
|
179
|
-
}
|
|
180
|
-
catch (err) {
|
|
181
|
-
if (isRetryableReplyPollError(err)) {
|
|
182
|
-
return { requestId, replies: [], degraded: true };
|
|
183
|
-
}
|
|
184
|
-
return {
|
|
185
|
-
requestId,
|
|
186
|
-
replies: [],
|
|
187
|
-
degraded: false,
|
|
188
|
-
permanentFailure: replyPollFailure(err),
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
}));
|
|
192
|
-
const byRequest = new Map();
|
|
193
|
-
for (const result of results) {
|
|
194
|
-
degraded ||= result.degraded === true;
|
|
195
|
-
if (result.permanentFailure !== undefined) {
|
|
196
|
-
permanentFailures.set(result.requestId, result.permanentFailure);
|
|
197
|
-
}
|
|
198
|
-
if (result.replies.length > 0)
|
|
199
|
-
byRequest.set(result.requestId, result.replies);
|
|
200
|
-
}
|
|
201
|
-
const afterPoll = interruption?.() ?? null;
|
|
202
|
-
// SessionEnd or terminal-side retirement wins even if an in-flight poll
|
|
203
|
-
// happened to return an answer. A newly registered question merely asks
|
|
204
|
-
// this owner to yield; an answer already observed remains authoritative.
|
|
205
|
-
if (afterPoll === 'ownership-ended') {
|
|
206
|
-
return { byRequest: new Map(), degraded, permanentFailures, interrupted: afterPoll };
|
|
207
|
-
}
|
|
208
|
-
if (byRequest.size > 0 || permanentFailures.size === requestIds.length) {
|
|
209
|
-
return { byRequest, degraded, permanentFailures, interrupted: null };
|
|
210
|
-
}
|
|
211
|
-
if (afterPoll === 'new-question') {
|
|
212
|
-
return { byRequest: new Map(), degraded, permanentFailures, interrupted: afterPoll };
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
/**
|
|
217
|
-
* Local lifecycle signal for a long-lived observer.
|
|
218
|
-
*
|
|
219
|
-
* SessionEnd and terminal-side retirement remove an owned request from pending
|
|
220
|
-
* state; the detached process must stop instead of recreating that session.
|
|
221
|
-
* A newly registered or submitted question asks a native observer to hand off
|
|
222
|
-
* to an owner whose lifetime covers the later answer window.
|
|
223
|
-
*/
|
|
224
|
-
function waiterInterruption(sessionId, env, requestIds, attemptedLocal) {
|
|
225
|
-
if (sessionHasEnded(sessionId, env))
|
|
226
|
-
return 'ownership-ended';
|
|
227
|
-
const pending = pendingList(readSessionState(sessionId, env));
|
|
228
|
-
const owned = new Set(requestIds);
|
|
229
|
-
// One question can be retired from the terminal while this observer owns
|
|
230
|
-
// several. That is a per-question change, not whole-session cancellation;
|
|
231
|
-
// the caller filters the changed set after each poll and keeps the rest.
|
|
232
|
-
return pending.some((entry) => entry.request_id !== undefined ? !owned.has(entry.request_id) :
|
|
233
|
-
!attemptedLocal.has(entry.question_id ?? entry.question))
|
|
234
|
-
? 'new-question'
|
|
235
|
-
: null;
|
|
236
|
-
}
|
|
237
|
-
/** A non-retryable replies fault, with enough server identity to act on it. */
|
|
238
|
-
function replyPollFailure(err) {
|
|
239
|
-
return err instanceof ApiCallError
|
|
240
|
-
? `${err.code} (HTTP ${err.status}): ${err.message}`
|
|
241
|
-
: String(err);
|
|
242
|
-
}
|
|
243
|
-
function permanentReplyFailureNote(failures) {
|
|
244
|
-
if (failures.size === 0)
|
|
245
|
-
return null;
|
|
246
|
-
const details = [...failures].map(([requestId, failure]) => `${requestId}: ${failure}`).join('; ');
|
|
247
|
-
return `reply polling stopped after a permanent server rejection (${details}); the affected question will be retired before its continuation owner exits`;
|
|
248
|
-
}
|
|
249
|
-
/** Healthy Companion devices that advertise the current answer job. */
|
|
250
|
-
async function answerableDevices(ctx) {
|
|
251
|
-
const configured = ctx.config.devices.value;
|
|
252
|
-
const { devices } = await ctx.client.listDevices();
|
|
253
|
-
return devices
|
|
254
|
-
.filter((device) => (device.platform === 'ios' ||
|
|
255
|
-
device.platform === 'macos' ||
|
|
256
|
-
device.platform === 'android') &&
|
|
257
|
-
device.registration_healthy &&
|
|
258
|
-
device.capabilities?.includes('answer') === true &&
|
|
259
|
-
device.derived_status !== 'must_update')
|
|
260
|
-
.filter((device) => configured === null || configured.includes(device.device_id))
|
|
261
|
-
.map((device) => device.device_id);
|
|
262
|
-
}
|
|
263
|
-
const LATE_STOP_POLL_SECONDS = 4;
|
|
264
|
-
const LATE_PROMPT_POLL_SECONDS = 3;
|
|
265
|
-
/**
|
|
266
|
-
* The one bound on answer deliveries, for every route.
|
|
267
|
-
*
|
|
268
|
-
* It caps two things that both used to be able to run away: how many times a
|
|
269
|
-
* single accepted answer may be handed to a route before the journal gives up
|
|
270
|
-
* on it, and how many answer-driven continuation generations one chain may
|
|
271
|
-
* produce.
|
|
272
|
-
*/
|
|
273
|
-
export const MAX_CONTINUATION_COUNT = 3;
|
|
274
|
-
/**
|
|
275
|
-
* How many turns an answer may be *held* before it is settled unread.
|
|
276
|
-
*
|
|
277
|
-
* A hold is not a delivery: the route reported that it handed nothing over, so
|
|
278
|
-
* the agent has not seen the answer and the wake-loop the continuation cap
|
|
279
|
-
* exists to stop has not happened. Counting holds against that cap meant three
|
|
280
|
-
* turns where a liveness probe could not reach a busy session were enough to
|
|
281
|
-
* discard an answer the user had already given.
|
|
282
|
-
*
|
|
283
|
-
* It still has to end. A route that can never deliver would otherwise retry
|
|
284
|
-
* every turn for the life of the session, so holds get their own, far looser
|
|
285
|
-
* bound — loose because each one costs nothing and the answer is still wanted.
|
|
286
|
-
*/
|
|
287
|
-
export const MAX_HELD_DELIVERIES = 20;
|
|
288
|
-
/** One bounded recovery poll for a question that outlived its original wait. */
|
|
289
|
-
async function pollPendingReply(ctx, pending, timeoutSeconds) {
|
|
290
|
-
if (pending.request_id === undefined) {
|
|
291
|
-
return { reply: null, replies: [], degraded: false, permanentFailure: null };
|
|
292
|
-
}
|
|
293
|
-
try {
|
|
294
|
-
const result = await ctx.waitForFirstReply(pending.request_id, timeoutSeconds);
|
|
295
|
-
return {
|
|
296
|
-
reply: result.replies.at(-1) ?? null,
|
|
297
|
-
replies: result.replies,
|
|
298
|
-
degraded: result.degraded === true,
|
|
299
|
-
permanentFailure: null,
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
catch (err) {
|
|
303
|
-
return isRetryableReplyPollError(err)
|
|
304
|
-
? { reply: null, replies: [], degraded: true, permanentFailure: null }
|
|
305
|
-
: { reply: null, replies: [], degraded: false, permanentFailure: replyPollFailure(err) };
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
/**
|
|
309
|
-
* How an answer-collecting close names itself: `deliver` only when this
|
|
310
|
-
* waiter will claim the fenced answer before writing it in place — a Claude
|
|
311
|
-
* inbox write or a Codex thread-queue write while this session's attendant
|
|
312
|
-
* holds a lease. Otherwise no disposition, exactly as released CLIs close.
|
|
313
|
-
*/
|
|
314
|
-
function answerCloseDisposition(ctx, route) {
|
|
315
|
-
// OpenClaw's Gateway service starts the attendant before it submits the
|
|
316
|
-
// question, but an immediate reply can beat the first lease exchange. The
|
|
317
|
-
// service retains the exact-session owner and retries after the lease is
|
|
318
|
-
// ready, so preserve the server's selected delivery claim at close.
|
|
319
|
-
if ((ctx.harness === 'openclaw' || ctx.harness === 'hermes') && route.kind === 'session-queue')
|
|
320
|
-
return 'deliver';
|
|
321
|
-
return claimableRoute(route) && ctx.answerClaims?.lease() != null ? 'deliver' : undefined;
|
|
322
|
-
}
|
|
323
|
-
/** Routes that write into the running session itself, so a claim can cover the write. */
|
|
324
|
-
function claimableRoute(route) {
|
|
325
|
-
return route.kind === 'inbox-socket' || route.kind === 'session-queue';
|
|
326
|
-
}
|
|
327
|
-
/** The claim marker for an answer its own `deliver` close selected. */
|
|
328
|
-
function claimMarker(response) {
|
|
329
|
-
return selectedForClaimedDelivery(response) ? { delivery_claim: true } : {};
|
|
330
|
-
}
|
|
331
|
-
/** Close several windows concurrently without confusing failure with silence. */
|
|
332
|
-
async function finalizePendings(ctx, sessionId, pending, disposition) {
|
|
333
|
-
return Promise.all(pending.map(async (entry) => {
|
|
334
|
-
const response = await finalizeReplies(ctx, entry.request_id, disposition);
|
|
335
|
-
if (response !== null && response.request_id === entry.request_id) {
|
|
336
|
-
recordConfirmedNativeAnswerTarget(sessionId, ctx.env, entry);
|
|
337
|
-
}
|
|
338
|
-
ctx.log?.info('hook.retirement', {
|
|
339
|
-
request_id: entry.request_id,
|
|
340
|
-
attempted: true,
|
|
341
|
-
proven: response !== null,
|
|
342
|
-
});
|
|
343
|
-
return { pending: entry, response };
|
|
344
|
-
}));
|
|
345
|
-
}
|
|
346
|
-
/** Convert a fenced final reply set into the exact answer handed to the model. */
|
|
347
|
-
function finalizedAnswer(finalized) {
|
|
348
|
-
const replies = finalized.response?.replies ?? [];
|
|
349
|
-
const reply = replies.at(-1);
|
|
350
|
-
return reply === undefined
|
|
351
|
-
? null
|
|
352
|
-
: {
|
|
353
|
-
pending: finalized.pending,
|
|
354
|
-
reply,
|
|
355
|
-
replies,
|
|
356
|
-
agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
|
|
357
|
-
agent_acknowledgement_text_required: finalized.response?.agent_acknowledgement_text_required,
|
|
358
|
-
...claimMarker(finalized.response),
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
/** Persist and surface one answer without letting the two representations drift. */
|
|
362
|
-
function reportAnswer(ctx, notes, answered, late) {
|
|
363
|
-
ctx.log?.info('hook.answer', {
|
|
364
|
-
answered: true,
|
|
365
|
-
stage: 'queued',
|
|
366
|
-
...(late ? { late: true } : {}),
|
|
367
|
-
request_id: answered.pending.request_id,
|
|
368
|
-
device: answered.reply.device_name,
|
|
369
|
-
text_chars: answered.reply.text.length,
|
|
370
|
-
});
|
|
371
|
-
notes.push(`${late ? 'late ' : ''}answer from ${answered.reply.device_name}: ${answered.reply.text}`);
|
|
372
|
-
}
|
|
373
|
-
/**
|
|
374
|
-
* One bounded recovery poll across every delivered question, concurrently —
|
|
375
|
-
* N questions must not multiply the hook's latency budget by N.
|
|
376
|
-
*/
|
|
377
|
-
async function pollPendingReplies(ctx, live, timeoutSeconds) {
|
|
378
|
-
const polls = await Promise.all(live.map((entry) => pollPendingReply(ctx, entry, timeoutSeconds)));
|
|
379
|
-
const answered = [];
|
|
380
|
-
let transientTrouble = false;
|
|
381
|
-
const permanentFailures = new Map();
|
|
382
|
-
for (const [index, poll] of polls.entries()) {
|
|
383
|
-
if (poll.reply !== null) {
|
|
384
|
-
answered.push({ pending: live[index], reply: poll.reply, replies: poll.replies });
|
|
385
|
-
}
|
|
386
|
-
if (poll.degraded)
|
|
387
|
-
transientTrouble = true;
|
|
388
|
-
if (poll.permanentFailure !== null) {
|
|
389
|
-
permanentFailures.set(live[index].request_id, poll.permanentFailure);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
return { answered, transientTrouble, permanentFailures };
|
|
393
|
-
}
|
|
394
|
-
/**
|
|
395
|
-
* The frozen draft itself will never be accepted. Replaying it forever hides a
|
|
396
|
-
* contract-skew (deleted fields, unexpected properties) as a recoverable wait.
|
|
397
|
-
* Auth, conflicts, and timeouts are not this class.
|
|
398
|
-
*/
|
|
399
|
-
function isTerminalDraftRejection(err) {
|
|
400
|
-
return err.status === 422 || (err.status === 400 && err.code === 'invalid_request');
|
|
401
|
-
}
|
|
402
|
-
// ---------------------------------------------------------------------------
|
|
403
|
-
// UserPromptSubmit — the user is at the keyboard
|
|
404
|
-
// ---------------------------------------------------------------------------
|
|
405
|
-
/**
|
|
406
|
-
* Records presence. Retires a live question only when this prompt plausibly
|
|
407
|
-
* answers it. Typing in the terminal is not by itself a resolution: the User
|
|
408
|
-
* may be answering a different question on a device.
|
|
409
|
-
*/
|
|
410
|
-
export async function handleUserPromptSubmit(ctx, envelope) {
|
|
411
|
-
const sessionId = envelope.session_id;
|
|
412
|
-
if (!sessionId)
|
|
413
|
-
return { notes: [] };
|
|
414
|
-
if (ctx.harness === 'codex') {
|
|
415
|
-
const presented = observeQueuedMessagePrompt(sessionId, ctx.env, envelope.prompt);
|
|
416
|
-
if (presented.length > 0)
|
|
417
|
-
ctx.log?.info('delivery.handoff', {
|
|
418
|
-
route: 'session-queue', stage: 'prompt-observed', message_ids: presented,
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
// Owed Session Message acknowledgements come before any resumed work: a
|
|
422
|
-
// session resumed after SessionEnd has no Stop to remind it first.
|
|
423
|
-
const reminder = { text: await messageAcknowledgementReminder(ctx, sessionId), used: false };
|
|
424
|
-
const outcome = await answerPrompt(ctx, envelope, sessionId, reminder);
|
|
425
|
-
if (reminder.text === null || reminder.used || outcome.stdout !== undefined || sessionHasEnded(sessionId, ctx.env)) {
|
|
426
|
-
return outcome;
|
|
427
|
-
}
|
|
428
|
-
const stdout = userPromptContextOutput(ctx.harness, reminder.text);
|
|
429
|
-
if (stdout === undefined)
|
|
430
|
-
return outcome;
|
|
431
|
-
outcome.notes.push('an owed Session Message acknowledgement was added to the user\'s new turn');
|
|
432
|
-
return {
|
|
433
|
-
...outcome,
|
|
434
|
-
stdout,
|
|
435
|
-
decided: false,
|
|
436
|
-
log: { ...outcome.log, stage: outcome.log?.['stage'] ?? 'acknowledgement-reminded' },
|
|
437
|
-
};
|
|
438
|
-
}
|
|
439
|
-
/** Context for the new turn, carrying the owed-acknowledgement reminder once. */
|
|
440
|
-
function withReminder(reminder, context) {
|
|
441
|
-
if (reminder.text === null)
|
|
442
|
-
return context;
|
|
443
|
-
reminder.used = true;
|
|
444
|
-
return `${context}\n\n${reminder.text}`;
|
|
445
|
-
}
|
|
446
|
-
/**
|
|
447
|
-
* The instruction for Session Message acknowledgements still owed after asking
|
|
448
|
-
* the service, or null when none is.
|
|
449
|
-
*/
|
|
450
|
-
async function messageAcknowledgementReminder(ctx, sessionId) {
|
|
451
|
-
const generation = ctx.harness === 'openclaw' ? readSessionIncarnation(sessionId, ctx.env)?.openclaw_generation : undefined;
|
|
452
|
-
const owed = presentedMessageAcknowledgements(readSessionState(sessionId, ctx.env))
|
|
453
|
-
.filter((entry) => ctx.harness !== 'openclaw' || (generation !== undefined && entry.openclaw_generation === generation));
|
|
454
|
-
if (owed.length === 0)
|
|
455
|
-
return null;
|
|
456
|
-
const still = await reconcileAcknowledgementObligations(ctx, sessionId, owed);
|
|
457
|
-
return still.length === 0 ? null : acknowledgementBlockContext(still);
|
|
458
|
-
}
|
|
459
|
-
async function answerPrompt(ctx, envelope, sessionId, reminder) {
|
|
460
|
-
const notes = [];
|
|
461
|
-
recoverQueuedAnswers(sessionId, ctx.env);
|
|
462
|
-
let state = readSessionState(sessionId, ctx.env);
|
|
463
|
-
if (state.accepted !== undefined || (state.delivered_answers?.length ?? 0) > 0) {
|
|
464
|
-
// A successful command can clear local debt before the next Stop, and a
|
|
465
|
-
// interrupted command can leave debt after the service recorded its
|
|
466
|
-
// acknowledgement. Reconcile both before replaying recovery context.
|
|
467
|
-
await reconcileAcknowledgementObligations(ctx, sessionId, state.acknowledgement_due ?? []);
|
|
468
|
-
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
469
|
-
notes.push('the Agent Session ended before answer delivery; stopping this observer');
|
|
470
|
-
return { notes };
|
|
471
|
-
}
|
|
472
|
-
state = readSessionState(sessionId, ctx.env);
|
|
473
|
-
if (state.accepted !== undefined &&
|
|
474
|
-
acceptedAnswersAwaitingAcknowledgement(state.accepted, state).length === 0) {
|
|
475
|
-
settleAcceptedAnswers(ctx, sessionId, state.accepted, envelope.cwd);
|
|
476
|
-
state = readSessionState(sessionId, ctx.env);
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
if (state.accepted !== undefined) {
|
|
480
|
-
// A journaled write of this batch already began (claimed, or recorded
|
|
481
|
-
// after an unclaimed hand-off): adding it to this turn would hand it over
|
|
482
|
-
// twice. It settles instead; its acknowledgement stays owed.
|
|
483
|
-
const written = answersAlreadyWritten(sessionId, ctx.env, state.accepted.answers.flatMap(({ pending }) => (pending.request_id === undefined ? [] : [pending.request_id])));
|
|
484
|
-
if (written.length > 0) {
|
|
485
|
-
settleAcceptedAnswers(ctx, sessionId, state.accepted, envelope.cwd);
|
|
486
|
-
ctx.log?.info('hook.answer', {
|
|
487
|
-
answered: true,
|
|
488
|
-
stage: 'replay-suppressed',
|
|
489
|
-
route: 'user-prompt-submit',
|
|
490
|
-
journal: written.map((entry) => `${entry.requestId}:${entry.stage}`),
|
|
491
|
-
});
|
|
492
|
-
notes.push('an earlier hand-off of the journaled answer already began its write; not adding it to this turn');
|
|
493
|
-
state = readSessionState(sessionId, ctx.env);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
if (state.accepted !== undefined) {
|
|
497
|
-
const answers = [
|
|
498
|
-
...(state.delivered_answers ?? []),
|
|
499
|
-
...acceptedAnswersAwaitingAcknowledgement(state.accepted, state),
|
|
500
|
-
];
|
|
501
|
-
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
502
|
-
...current,
|
|
503
|
-
...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
|
|
504
|
-
last_prompt_at: ctx.now(),
|
|
505
|
-
}));
|
|
506
|
-
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
507
|
-
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
|
|
508
|
-
}
|
|
509
|
-
const stdout = userPromptContextOutput(ctx.harness, withReminder(reminder, prepareOrdinaryAnswersContext(ctx, sessionId, answers, state.accepted.remaining)));
|
|
510
|
-
if (stdout !== undefined) {
|
|
511
|
-
notes.push('the journaled device answer was added to the user\'s new turn');
|
|
512
|
-
const selected = unclaimedSelectedAnswers(sessionId, ctx.env, answers);
|
|
513
|
-
return {
|
|
514
|
-
stdout,
|
|
515
|
-
decided: false,
|
|
516
|
-
notes,
|
|
517
|
-
...(selected.length === 0
|
|
518
|
-
? {}
|
|
519
|
-
: { afterOutput: () => reportHandedOffAfterTheFact(ctx, sessionId, selected) }),
|
|
520
|
-
log: {
|
|
521
|
-
stage: 'context-added',
|
|
522
|
-
route: 'user-prompt-submit',
|
|
523
|
-
request_ids: answers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]),
|
|
524
|
-
},
|
|
525
|
-
};
|
|
526
|
-
}
|
|
527
|
-
notes.push('a device answer is safely journaled; the next Stop will deliver it');
|
|
528
|
-
return { notes };
|
|
529
|
-
}
|
|
530
|
-
const live = pendingList(state).filter((entry) => entry.request_id !== undefined);
|
|
531
|
-
let lateAnswers = [];
|
|
532
|
-
if (live.length > 0) {
|
|
533
|
-
const { answered, transientTrouble, permanentFailures } = await pollPendingReplies(ctx, live, LATE_PROMPT_POLL_SECONDS);
|
|
534
|
-
const finalized = await finalizePendings(ctx, sessionId, answered.map((entry) => entry.pending));
|
|
535
|
-
lateAnswers = answered.map((entry) => {
|
|
536
|
-
const response = finalized.find((candidate) => isSamePending(candidate.pending, entry.pending))?.response;
|
|
537
|
-
const replies = response?.replies.length ? response.replies : entry.replies;
|
|
538
|
-
if (response === null) {
|
|
539
|
-
notes.push(`could not confirm the close fence for ${entry.pending.request_id}; preserving the durable answer already observed`);
|
|
540
|
-
}
|
|
541
|
-
return {
|
|
542
|
-
...entry,
|
|
543
|
-
replies,
|
|
544
|
-
reply: replies.at(-1),
|
|
545
|
-
agent_acknowledgement_required: response?.agent_acknowledgement_required ??
|
|
546
|
-
entry.agent_acknowledgement_required,
|
|
547
|
-
agent_acknowledgement_text_required: response?.agent_acknowledgement_text_required ??
|
|
548
|
-
entry.agent_acknowledgement_text_required,
|
|
549
|
-
};
|
|
550
|
-
});
|
|
551
|
-
if (answered.length > 0) {
|
|
552
|
-
stageAcceptedAnswers(ctx, sessionId, lateAnswers, 0);
|
|
553
|
-
for (const answer of lateAnswers)
|
|
554
|
-
reportAnswer(ctx, notes, answer, true);
|
|
555
|
-
}
|
|
556
|
-
if (transientTrouble) {
|
|
557
|
-
notes.push('could not check every pending question for a late answer before the prompt');
|
|
558
|
-
}
|
|
559
|
-
const permanentFailure = permanentReplyFailureNote(permanentFailures);
|
|
560
|
-
if (permanentFailure !== null)
|
|
561
|
-
notes.push(permanentFailure);
|
|
562
|
-
}
|
|
563
|
-
const matched = pendingAnsweredByPrompt(envelope.prompt, pendingList(state)).filter(entry => mayRetireFromPrompt(state, entry));
|
|
564
|
-
for (const entry of matched) {
|
|
565
|
-
ctx.log?.info('hook.retirement', {
|
|
566
|
-
request_id: entry.request_id,
|
|
567
|
-
question_id: entry.question_id,
|
|
568
|
-
reason: 'prompt-matched',
|
|
569
|
-
attempted: entry.request_id !== undefined,
|
|
570
|
-
proven: false,
|
|
571
|
-
});
|
|
572
|
-
}
|
|
573
|
-
// Park before dropping `pending`. If the process dies between these writes,
|
|
574
|
-
// the next hook sees both copies and dedupes them; the old order could die in
|
|
575
|
-
// the gap after erasing the only request/collapse/device identifiers.
|
|
576
|
-
const updated = updateSessionState(sessionId, ctx.env, (current) => {
|
|
577
|
-
const retiring = [...(current.retiring ?? [])];
|
|
578
|
-
const matchedNow = pendingAnsweredByPrompt(envelope.prompt, pendingList(current)).filter(entry => mayRetireFromPrompt(current, entry));
|
|
579
|
-
const unmatched = pendingList(current).filter((entry) => !matchedNow.some((item) => isSamePending(item, entry)));
|
|
580
|
-
for (const entry of matchedNow.filter(questionSubmissionAttempted)) {
|
|
581
|
-
const retirement = retiringQuestion(entry, 'answered_elsewhere', envelope.cwd);
|
|
582
|
-
if (retirement !== null &&
|
|
583
|
-
!retiring.some((parked) => parked.request_id === retirement.request_id)) {
|
|
584
|
-
retiring.push(retirement);
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
const unasked = unmatched.filter((entry) => entry.request_id === undefined);
|
|
588
|
-
const stillLive = unmatched.filter((entry) => entry.request_id !== undefined);
|
|
589
|
-
const remaining = [...stillLive, ...unasked];
|
|
590
|
-
// Start from the whole document so fields introduced by a newer CLI remain
|
|
591
|
-
// attached while this older writer applies the prompt transition it knows.
|
|
592
|
-
const next = {
|
|
593
|
-
...current,
|
|
594
|
-
...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
|
|
595
|
-
last_prompt_at: ctx.now(),
|
|
596
|
-
};
|
|
597
|
-
if (remaining.length > 0)
|
|
598
|
-
next.pending = remaining;
|
|
599
|
-
else
|
|
600
|
-
delete next.pending;
|
|
601
|
-
if (retiring.length > 0)
|
|
602
|
-
next.retiring = retiring;
|
|
603
|
-
else
|
|
604
|
-
delete next.retiring;
|
|
605
|
-
// The user typed, so whatever chain of answer-driven continuations was
|
|
606
|
-
// running, a human has taken the turn and the consecutive count starts
|
|
607
|
-
// over. Only a real prompt reaches here: the journal branch above returns
|
|
608
|
-
// first, and a wake route's injected message always arrives while its own
|
|
609
|
-
// answer is still journaled, so it can never pass for the user's presence.
|
|
610
|
-
if (current.continuation !== undefined) {
|
|
611
|
-
next.continuation = { ...current.continuation, count: 0 };
|
|
612
|
-
}
|
|
613
|
-
return matchedNow.reduce((remembered, entry) => rememberQuestionState(remembered, entry, questionSubmissionAttempted(entry) ? 'retired' : 'withdrawn'), next);
|
|
614
|
-
});
|
|
615
|
-
// The bridge that lets a plain `notifai ask` find the hook's canonical
|
|
616
|
-
// session: an agent shell command gets no hook payload, and not every
|
|
617
|
-
// harness exports an id in the same shape.
|
|
618
|
-
if (envelope.cwd !== undefined && ctx.harness !== undefined) {
|
|
619
|
-
writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now(), ctx.harness);
|
|
620
|
-
}
|
|
621
|
-
if (lateAnswers.length > 0) {
|
|
622
|
-
// UserPromptSubmit can add context to the turn already beginning. Keep the
|
|
623
|
-
// accepted-answer journal until the agent acknowledges: if this process or
|
|
624
|
-
// host dies around stdout, a later prompt or Stop safely replays it.
|
|
625
|
-
const stdout = userPromptContextOutput(ctx.harness, withReminder(reminder, prepareOrdinaryAnswersContext(ctx, sessionId, [...(readSessionState(sessionId, ctx.env).delivered_answers ?? []), ...lateAnswers], pendingList(updated).length)));
|
|
626
|
-
if (stdout !== undefined)
|
|
627
|
-
notes.push('the late device answer was added to the user\'s new turn');
|
|
628
|
-
else
|
|
629
|
-
notes.push('the late device answer will continue the agent at this turn’s Stop');
|
|
630
|
-
return {
|
|
631
|
-
notes,
|
|
632
|
-
...(stdout === undefined
|
|
633
|
-
? {}
|
|
634
|
-
: {
|
|
635
|
-
stdout,
|
|
636
|
-
decided: false,
|
|
637
|
-
log: {
|
|
638
|
-
stage: 'context-added',
|
|
639
|
-
route: 'user-prompt-submit',
|
|
640
|
-
request_ids: lateAnswers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]),
|
|
641
|
-
},
|
|
642
|
-
}),
|
|
643
|
-
settlementRequired: needsQuestionSettlement(updated, ctx.harness),
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
const retired = await drainRetirements(ctx, sessionId, ctx.env);
|
|
647
|
-
const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now());
|
|
648
|
-
await sweepDeliveryJournalsFor(ctx);
|
|
649
|
-
const swept = [...retired, ...orphaned];
|
|
650
|
-
if (swept.length > 0) {
|
|
651
|
-
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
|
|
652
|
-
}
|
|
653
|
-
const delivered = readSessionState(sessionId, ctx.env).delivered_answers ?? [];
|
|
654
|
-
const stdout = delivered.length > 0
|
|
655
|
-
? userPromptContextOutput(ctx.harness, withReminder(reminder, prepareOrdinaryAnswersContext(ctx, sessionId, delivered, pendingList(updated).length)))
|
|
656
|
-
: undefined;
|
|
657
|
-
return {
|
|
658
|
-
notes,
|
|
659
|
-
...(stdout === undefined ? {} : {
|
|
660
|
-
stdout,
|
|
661
|
-
log: { stage: 'context-added', route: 'user-prompt-submit', request_ids: summarizeRequestIds(delivered.map((entry) => entry.pending)).ids },
|
|
662
|
-
}),
|
|
663
|
-
settlementRequired: needsQuestionSettlement(updated, ctx.harness),
|
|
664
|
-
};
|
|
665
|
-
}
|
|
666
|
-
/** Legacy prompt/Stop context routes bypass the common drain. Select ordinary
|
|
667
|
-
* presentation durably before returning any answer bytes to those writers.
|
|
668
|
-
*/
|
|
669
|
-
function prepareOrdinaryAnswersContext(ctx, sessionId, answers, remaining) {
|
|
670
|
-
if (ctx.harness === 'codex') {
|
|
671
|
-
const ids = new Set(answers.flatMap(answer => answer.pending.question_id === undefined ? [] : [answer.pending.question_id]));
|
|
672
|
-
updateSessionState(sessionId, ctx.env, state => markCodexOrdinaryPresentation(state, ids));
|
|
673
|
-
}
|
|
674
|
-
return answersContext(answers, remaining);
|
|
675
|
-
}
|
|
676
|
-
function needsQuestionSettlement(state, harness) {
|
|
677
|
-
return pendingList(state).some((entry) => entry.request_id === undefined || harness === 'codex');
|
|
678
|
-
}
|
|
679
|
-
/** Absence of debt proves consumption only for an explicitly classified answer. */
|
|
680
|
-
function acceptedAnswersAwaitingAcknowledgement(accepted, state) {
|
|
681
|
-
return accepted.answers.filter(({ pending, agent_acknowledgement_required }) => agent_acknowledgement_required !== true ||
|
|
682
|
-
pending.request_id === undefined ||
|
|
683
|
-
(state.acknowledgement_due ?? []).some((entry) => entry.request_id === pending.request_id));
|
|
684
|
-
}
|
|
685
|
-
// ---------------------------------------------------------------------------
|
|
686
|
-
// Answer ownership — observe registered questions through a harness delivery route
|
|
687
|
-
// ---------------------------------------------------------------------------
|
|
688
|
-
/**
|
|
689
|
-
* Only engages for a question the agent explicitly registered with
|
|
690
|
-
* `notifai ask`. Guessing from the last assistant message was the alternative
|
|
691
|
-
* and it is not worth it: a false positive here hijacks the terminal.
|
|
692
|
-
*/
|
|
693
|
-
export async function handleStop(ctx, envelope, processDeadlineAt = ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000, route = hookContinuationRoute(), recordStop = true) {
|
|
694
|
-
const sessionId = envelope.session_id;
|
|
695
|
-
if (!sessionId) {
|
|
696
|
-
gate(ctx, 'held', 'no-session', { stage: 'queued' });
|
|
697
|
-
return { notes: [] };
|
|
698
|
-
}
|
|
699
|
-
return runEscalationWaiter(ctx, {
|
|
700
|
-
sessionId,
|
|
701
|
-
envelope,
|
|
702
|
-
route,
|
|
703
|
-
processDeadlineAt,
|
|
704
|
-
recordStop,
|
|
705
|
-
});
|
|
706
|
-
}
|
|
707
|
-
/**
|
|
708
|
-
* Acquire the per-session owner, waiting only when this Stop carries a
|
|
709
|
-
* newly registered question. The live owner sees that state on its next reply
|
|
710
|
-
* poll and yields; without this handoff the new question could sit unpushed for
|
|
711
|
-
* the older question's complete answer window.
|
|
712
|
-
*/
|
|
713
|
-
async function acquireQuestionOwner(ctx, sessionId, hardDeadlineAt) {
|
|
714
|
-
if (sessionHasEnded(sessionId, ctx.env))
|
|
715
|
-
return false;
|
|
716
|
-
if (claimQuestionPush(sessionId, ctx.env, Date.now(), undefined, hardDeadlineAt))
|
|
717
|
-
return true;
|
|
718
|
-
while (ctx.now() < hardDeadlineAt) {
|
|
719
|
-
const handoff = claimHandoffState(sessionId, ctx.env);
|
|
720
|
-
if (!handoff.hasNewQuestion)
|
|
721
|
-
return false;
|
|
722
|
-
const holderReleaseDeadlineAt = Math.min(hardDeadlineAt, handoff.ownerDeadlineAt === undefined
|
|
723
|
-
? hardDeadlineAt
|
|
724
|
-
: handoff.ownerDeadlineAt + QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS * 1000);
|
|
725
|
-
if (ctx.now() >= holderReleaseDeadlineAt)
|
|
726
|
-
return false;
|
|
727
|
-
await ctx.sleep(Math.min(CLAIM_HANDOFF_POLL_MS, holderReleaseDeadlineAt - ctx.now()));
|
|
728
|
-
if (sessionHasEnded(sessionId, ctx.env))
|
|
729
|
-
return false;
|
|
730
|
-
if (claimQuestionPush(sessionId, ctx.env, Date.now(), undefined, hardDeadlineAt))
|
|
731
|
-
return true;
|
|
732
|
-
}
|
|
733
|
-
return false;
|
|
734
|
-
}
|
|
735
|
-
/**
|
|
736
|
-
* Own one registered question pipeline from its terminal-first timer through
|
|
737
|
-
* its final delivery or retirement. Hosts differ only in the injected route.
|
|
738
|
-
*/
|
|
739
|
-
export async function runEscalationWaiter(ctx, options) {
|
|
740
|
-
const notes = [];
|
|
741
|
-
const { sessionId, envelope } = options;
|
|
742
|
-
// Detached observers have no harness stdout consumer for Stop reminders.
|
|
743
|
-
// They must neither spend reminder attempts nor block fresh answer collection.
|
|
744
|
-
const canRemind = options.recordStop !== false;
|
|
745
|
-
const hardDeadlineAt = options.processDeadlineAt ?? ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000;
|
|
746
|
-
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
747
|
-
notes.push('the Agent Session already ended; no answer observer was started');
|
|
748
|
-
return { notes };
|
|
749
|
-
}
|
|
750
|
-
// This event marker is diagnostic evidence in its own right. Record it
|
|
751
|
-
// before every early return so doctor can distinguish a working Stop route
|
|
752
|
-
// from the prompt-only state that previously looked healthy.
|
|
753
|
-
if (options.recordStop !== false) {
|
|
754
|
-
updateSessionState(sessionId, ctx.env, (current) => ({
|
|
755
|
-
...current,
|
|
756
|
-
...(ctx.harness === undefined ? {} : { harness: ctx.harness }),
|
|
757
|
-
last_stop_at: ctx.now(),
|
|
758
|
-
}));
|
|
759
|
-
}
|
|
760
|
-
// The waiter, not its host, owns this lease. It spans grace, submission,
|
|
761
|
-
// polling, close fencing, route delivery, and every retirement path.
|
|
762
|
-
// Real clock, deliberately, not `ctx.now` — the claim answers whether
|
|
763
|
-
// another process is alive right now, which an injected clock cannot know.
|
|
764
|
-
if (!(await acquireQuestionOwner(ctx, sessionId, hardDeadlineAt))) {
|
|
765
|
-
gate(ctx, 'held', 'claimed-elsewhere', { stage: 'queued' });
|
|
766
|
-
notes.push('another hook is already handling this session; a newly registered question remains queued for the next owner');
|
|
767
|
-
return { notes };
|
|
768
|
-
}
|
|
769
|
-
try {
|
|
770
|
-
recoverQueuedAnswers(sessionId, ctx.env);
|
|
771
|
-
let state = readSessionState(sessionId, ctx.env);
|
|
772
|
-
if (state.accepted !== undefined) {
|
|
773
|
-
const accepted = state.accepted;
|
|
774
|
-
if (canRemind && envelope.stop_hook_active === true && pendingList(state).length > 0) {
|
|
775
|
-
const due = await reconcileAcknowledgementObligations(ctx, sessionId, state.acknowledgement_due ?? []);
|
|
776
|
-
if (due.length > 0) {
|
|
777
|
-
gate(ctx, 'held', 'acknowledgement-required', {
|
|
778
|
-
request_ids: due.map((entry) => entry.request_id),
|
|
779
|
-
});
|
|
780
|
-
return {
|
|
781
|
-
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
782
|
-
notes,
|
|
783
|
-
log: {
|
|
784
|
-
stage: 'acknowledgement-required',
|
|
785
|
-
request_ids: due.map((entry) => entry.request_id),
|
|
786
|
-
},
|
|
787
|
-
};
|
|
788
|
-
}
|
|
789
|
-
state = readSessionState(sessionId, ctx.env);
|
|
790
|
-
}
|
|
791
|
-
// Acknowledgement is per route, because routes end in different places.
|
|
792
|
-
//
|
|
793
|
-
// A blocking continuation ends at this process's stdout, which the
|
|
794
|
-
// harness reads only after the process exits: the recursive Stop's
|
|
795
|
-
// `stop_hook_active` is the acknowledgement, and persisting a separate
|
|
796
|
-
// "offered" bit before stdout would recreate the crash gap this journal
|
|
797
|
-
// exists to close. An out-of-band route ends at a write this process made
|
|
798
|
-
// itself and recorded here. A socket write is not proof the model acted
|
|
799
|
-
// on the answer — delivery is not consumption — but nothing later can
|
|
800
|
-
// prove more, and an answer redelivered without end is strictly worse
|
|
801
|
-
// than one settled on delivery. So a recorded delivery settles.
|
|
802
|
-
const acknowledgementProvesDelivery = accepted.answers.length > 0 &&
|
|
803
|
-
acceptedAnswersAwaitingAcknowledgement(accepted, state).length === 0;
|
|
804
|
-
const deliveryProven = accepted.delivered_at !== undefined ||
|
|
805
|
-
envelope.stop_hook_active === true ||
|
|
806
|
-
acknowledgementProvesDelivery;
|
|
807
|
-
if (!deliveryProven) {
|
|
808
|
-
for (const answer of accepted.answers)
|
|
809
|
-
reportAnswer(ctx, notes, answer, true);
|
|
810
|
-
return await deliverAcceptedAnswers(ctx, sessionId, options.route, accepted, notes, envelope.cwd);
|
|
811
|
-
}
|
|
812
|
-
settleAcceptedAnswers(ctx, sessionId, accepted, envelope.cwd);
|
|
813
|
-
state = readSessionState(sessionId, ctx.env);
|
|
814
|
-
const currentAcceptedIds = new Set(accepted.answers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]));
|
|
815
|
-
const due = await reconcileAcknowledgementObligations(ctx, sessionId, (state.acknowledgement_due ?? []).filter((entry) => currentAcceptedIds.has(entry.request_id)));
|
|
816
|
-
const held = canRemind ? holdForAcknowledgement(ctx, sessionId, due, notes) : null;
|
|
817
|
-
if (held !== null)
|
|
818
|
-
return held;
|
|
819
|
-
state = readSessionState(sessionId, ctx.env);
|
|
820
|
-
}
|
|
821
|
-
if (state.accepted === undefined && owedAcknowledgements(state).length > 0 &&
|
|
822
|
-
canRemind) {
|
|
823
|
-
const due = await reconcileAcknowledgementObligations(ctx, sessionId, owedAcknowledgements(state));
|
|
824
|
-
const held = canRemind ? holdForAcknowledgement(ctx, sessionId, due, notes) : null;
|
|
825
|
-
if (held !== null)
|
|
826
|
-
return held;
|
|
827
|
-
resetAcknowledgementBlocks(sessionId, ctx.env);
|
|
828
|
-
state = readSessionState(sessionId, ctx.env);
|
|
829
|
-
}
|
|
830
|
-
const pending = pendingList(state);
|
|
831
|
-
if (pending.length === 0) {
|
|
832
|
-
const swept = [
|
|
833
|
-
...(await drainRetirements(ctx, sessionId, ctx.env)),
|
|
834
|
-
...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
|
|
835
|
-
];
|
|
836
|
-
await sweepDeliveryJournalsFor(ctx);
|
|
837
|
-
if (swept.length > 0) {
|
|
838
|
-
notes.push(`retired question${swept.length > 1 ? 's' : ''} ${swept.join(', ')}`);
|
|
839
|
-
}
|
|
840
|
-
gate(ctx, 'held', 'no-question', { stage: 'queued' });
|
|
841
|
-
return { notes };
|
|
842
|
-
}
|
|
843
|
-
return await handleClaimedStop(ctx, envelope, sessionId, pending, notes, hardDeadlineAt, options.route, options.recordStop !== false && ctx.harness === 'codex' && options.route.kind === 'session-queue');
|
|
844
|
-
}
|
|
845
|
-
finally {
|
|
846
|
-
releaseQuestionPush(sessionId, ctx.env);
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
/**
|
|
850
|
-
* The blocking Stop hook's own last meter: the answer becomes this process's
|
|
851
|
-
* stdout, and the harness admits it as a new user turn. Exported because a
|
|
852
|
-
* host adapter that has other routes still falls back to this one.
|
|
853
|
-
*/
|
|
854
|
-
export function hookContinuationRoute() {
|
|
855
|
-
return {
|
|
856
|
-
kind: 'hook-continuation',
|
|
857
|
-
deliver: async (event) => ({
|
|
858
|
-
stdout: stopAnswerOutput(event.context),
|
|
859
|
-
commitStdout: event.commitDelivery,
|
|
860
|
-
notes: [],
|
|
861
|
-
// The harness reads this stdout after the process exits, so only the
|
|
862
|
-
// successor Stop can acknowledge it.
|
|
863
|
-
acknowledgement: 'stdout',
|
|
864
|
-
}),
|
|
865
|
-
};
|
|
866
|
-
}
|
|
867
|
-
/**
|
|
868
|
-
* Hand one accepted answer to a route and record what the attempt proved.
|
|
869
|
-
*
|
|
870
|
-
* Every delivery on every route passes through here, which is what makes the
|
|
871
|
-
* cap below a backstop rather than a suggestion. A guard only one route
|
|
872
|
-
* consults is not a backstop: that is how a single answer once reached a
|
|
873
|
-
* session 250 times.
|
|
874
|
-
*/
|
|
875
|
-
async function deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, cwd) {
|
|
876
|
-
if (route.kind === 'session-queue' || route.defer !== undefined) {
|
|
877
|
-
accepted = classifyJournaledAcknowledgements(sessionId, ctx.env) ?? accepted;
|
|
878
|
-
const ids = new Set(accepted.answers.map((answer) => answer.pending.request_id));
|
|
879
|
-
await reconcileAcknowledgementObligations(ctx, sessionId, (readSessionState(sessionId, ctx.env).acknowledgement_due ?? []).filter((entry) => ids.has(entry.request_id)));
|
|
880
|
-
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
881
|
-
notes.push('the Agent Session ended before answer delivery; stopping this observer');
|
|
882
|
-
return { notes };
|
|
883
|
-
}
|
|
884
|
-
const unresolved = acceptedAnswersAwaitingAcknowledgement(accepted, readSessionState(sessionId, ctx.env));
|
|
885
|
-
if (unresolved.length === 0) {
|
|
886
|
-
settleAcceptedAnswers(ctx, sessionId, accepted, cwd);
|
|
887
|
-
return {
|
|
888
|
-
notes,
|
|
889
|
-
log: { stage: 'acknowledgement-reconciled', request_ids: [...ids] },
|
|
890
|
-
settlementRequired: pendingList(readSessionState(sessionId, ctx.env)).length > 0,
|
|
891
|
-
};
|
|
892
|
-
}
|
|
893
|
-
accepted = { ...accepted, answers: unresolved };
|
|
894
|
-
// Keep the complete fenced batch in storage until queue completion, so
|
|
895
|
-
// acknowledged members still retain any unconfirmed retirement work.
|
|
896
|
-
}
|
|
897
|
-
const { answers: answered, remaining } = accepted;
|
|
898
|
-
const requestIds = summarizeRequestIds(answered.map((entry) => entry.pending)).ids;
|
|
899
|
-
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
900
|
-
notes.push('the Agent Session ended before answer delivery; stopping this observer');
|
|
901
|
-
return { notes };
|
|
902
|
-
}
|
|
903
|
-
const replayed = await suppressWrittenReplay(ctx, sessionId, accepted, notes, cwd);
|
|
904
|
-
if (replayed !== null)
|
|
905
|
-
return replayed;
|
|
906
|
-
if (route.defer !== undefined) {
|
|
907
|
-
await route.defer(accepted);
|
|
908
|
-
return {
|
|
909
|
-
notes,
|
|
910
|
-
log: { stage: 'input-staged', route: route.kind, answers: accepted.answers.length },
|
|
911
|
-
settlementRequired: pendingList(readSessionState(sessionId, ctx.env)).length > 0,
|
|
912
|
-
};
|
|
913
|
-
}
|
|
914
|
-
const held = accepted.held_deliveries ?? 0;
|
|
915
|
-
if (held >= MAX_HELD_DELIVERIES) {
|
|
916
|
-
gate(ctx, 'held', 'delivery-limit', {
|
|
917
|
-
route: route.kind,
|
|
918
|
-
held,
|
|
919
|
-
limit: MAX_HELD_DELIVERIES,
|
|
920
|
-
request_ids: requestIds,
|
|
921
|
-
});
|
|
922
|
-
settleAcceptedAnswers(ctx, sessionId, accepted, cwd);
|
|
923
|
-
notes.push(`no route could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns; not holding it any longer`);
|
|
924
|
-
return { notes };
|
|
925
|
-
}
|
|
926
|
-
const attempt = (accepted.delivery_attempts ?? 0) + 1;
|
|
927
|
-
if (attempt > MAX_CONTINUATION_COUNT) {
|
|
928
|
-
gate(ctx, 'held', 'delivery-limit', {
|
|
929
|
-
route: route.kind,
|
|
930
|
-
attempts: accepted.delivery_attempts ?? 0,
|
|
931
|
-
limit: MAX_CONTINUATION_COUNT,
|
|
932
|
-
request_ids: requestIds,
|
|
933
|
-
});
|
|
934
|
-
// Settle rather than journal for ever. The answer has been offered as often
|
|
935
|
-
// as any working route could need, it is already in the notes of each of
|
|
936
|
-
// those turns, and the questions it answered still deserve retirement.
|
|
937
|
-
settleAcceptedAnswers(ctx, sessionId, accepted, cwd);
|
|
938
|
-
notes.push(`the user's answer reached the ${route.kind} route ${MAX_CONTINUATION_COUNT} times without being acknowledged; not delivering it again`);
|
|
939
|
-
return { notes };
|
|
940
|
-
}
|
|
941
|
-
// Count the attempt before making it: an attempt that dies mid-delivery still
|
|
942
|
-
// happened, and a counter written afterwards could be evaded by crashing.
|
|
943
|
-
amendAcceptedAnswers(ctx, sessionId, (current) => ({
|
|
944
|
-
...current,
|
|
945
|
-
delivery_attempts: attempt,
|
|
946
|
-
}));
|
|
947
|
-
// Linearize delivery after the journal write. If SessionEnd acquired the
|
|
948
|
-
// shared state lock in between, its durable marker wins and no wake starts.
|
|
949
|
-
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
950
|
-
notes.push('the Agent Session ended before answer delivery; stopping this observer');
|
|
951
|
-
return { notes };
|
|
952
|
-
}
|
|
953
|
-
ctx.log?.info('hook.gate', {
|
|
954
|
-
verdict: 'proceeding',
|
|
955
|
-
reason: 'answered',
|
|
956
|
-
stage: 'routed',
|
|
957
|
-
route: route.kind,
|
|
958
|
-
answers: answered.length,
|
|
959
|
-
request_ids: requestIds,
|
|
960
|
-
journal_recorded_at: accepted.recorded_at,
|
|
961
|
-
delivery_attempt: attempt,
|
|
962
|
-
});
|
|
963
|
-
let deliveryCommitted = false;
|
|
964
|
-
const commitDelivery = () => {
|
|
965
|
-
if (deliveryCommitted)
|
|
966
|
-
return true;
|
|
967
|
-
const file = sessionStatePath(sessionId, ctx.env);
|
|
968
|
-
return withFileLock(`${file}.lock`, () => {
|
|
969
|
-
if (sessionHasEnded(sessionId, ctx.env))
|
|
970
|
-
return false;
|
|
971
|
-
let current = readSessionState(sessionId, ctx.env);
|
|
972
|
-
if (current.accepted === undefined)
|
|
973
|
-
return false;
|
|
974
|
-
const acceptedBeforeCommit = current.accepted;
|
|
975
|
-
deliveryCommitted = true;
|
|
976
|
-
if (ctx.harness === 'codex')
|
|
977
|
-
current = markCodexOrdinaryPresentation(current, new Set(answered.flatMap(answer => answer.pending.question_id === undefined ? [] : [answer.pending.question_id])));
|
|
978
|
-
writeSessionStateUnlocked(file, sessionId, {
|
|
979
|
-
...current,
|
|
980
|
-
accepted: { ...acceptedBeforeCommit, delivery_committed_at: ctx.now() },
|
|
981
|
-
});
|
|
982
|
-
return true;
|
|
983
|
-
});
|
|
984
|
-
};
|
|
985
|
-
// Answers a `deliver` close selected are claimed first, under this session's
|
|
986
|
-
// delivery lock, so an Answer Edit can only follow the write. A claim that
|
|
987
|
-
// cannot be had never withholds the answer: it is written unclaimed, as
|
|
988
|
-
// released CLIs write it, and its edits simply wait.
|
|
989
|
-
const handOff = await claimAnswerHandOff(ctx, sessionId, route, answered);
|
|
990
|
-
let delivered;
|
|
991
|
-
try {
|
|
992
|
-
delivered = await route.deliver({
|
|
993
|
-
context: answersContext(answered, remaining),
|
|
994
|
-
answers: answered.length,
|
|
995
|
-
remaining,
|
|
996
|
-
request_ids: requestIds,
|
|
997
|
-
journal_recorded_at: accepted.recorded_at,
|
|
998
|
-
commitDelivery: handOff === null
|
|
999
|
-
? commitDelivery
|
|
1000
|
-
: (writer) => handOff.begin(commitDelivery, { subprocess: writer === 'subprocess' }),
|
|
1001
|
-
...(handOff === null || handOff.claimed.length === 0
|
|
1002
|
-
? {}
|
|
1003
|
-
: {
|
|
1004
|
-
writeGuard: { writable: () => handOff.writable(), remainingMs: () => handOff.remainingMs() },
|
|
1005
|
-
writerGroup: (pgid) => handOff.recordGroup(pgid),
|
|
1006
|
-
}),
|
|
1007
|
-
});
|
|
1008
|
-
}
|
|
1009
|
-
catch (err) {
|
|
1010
|
-
await handOff?.finish('failed');
|
|
1011
|
-
throw err;
|
|
1012
|
-
}
|
|
1013
|
-
await handOff?.finish(delivered.acknowledgement === 'delivered'
|
|
1014
|
-
? 'written'
|
|
1015
|
-
: delivered.log?.['reason'] === 'write-unconfirmed'
|
|
1016
|
-
? 'failed'
|
|
1017
|
-
: delivered.log?.['reason'] === WRITE_ABORTED_REASON
|
|
1018
|
-
? 'aborted'
|
|
1019
|
-
: 'not-written');
|
|
1020
|
-
if (delivered.acknowledgement === 'delivered' && deliveryCommitted) {
|
|
1021
|
-
await reportUnclaimedHandOff(ctx, sessionId, answered, handOff);
|
|
1022
|
-
}
|
|
1023
|
-
if (delivered.acknowledgement === 'delivered' && !deliveryCommitted) {
|
|
1024
|
-
delivered = {
|
|
1025
|
-
notes: [
|
|
1026
|
-
...(delivered.notes ?? []),
|
|
1027
|
-
`the ${route.kind} route reported delivery without committing against SessionEnd; preserving the answer instead of accepting an unordered write`,
|
|
1028
|
-
],
|
|
1029
|
-
log: { route: route.kind, stage: 'queued', reason: 'delivery-not-committed' },
|
|
1030
|
-
acknowledgement: 'held',
|
|
1031
|
-
};
|
|
1032
|
-
}
|
|
1033
|
-
const deliveredRoute = typeof delivered.log?.['route'] === 'string' ? delivered.log['route'] : route.kind;
|
|
1034
|
-
const deliveredStage = typeof delivered.log?.['stage'] === 'string' ? delivered.log['stage'] : 'delivered';
|
|
1035
|
-
if (delivered.acknowledgement === 'delivered') {
|
|
1036
|
-
finishCommittedDelivery(ctx, sessionId, accepted, deliveredRoute);
|
|
1037
|
-
}
|
|
1038
|
-
else if (delivered.acknowledgement === 'held') {
|
|
1039
|
-
// Give the delivery attempt back and count the hold instead. The attempt
|
|
1040
|
-
// was counted before the call because one that dies mid-delivery still
|
|
1041
|
-
// happened and a counter written afterwards could be evaded by crashing —
|
|
1042
|
-
// but a route that *returns* `held` proves both that this process survived
|
|
1043
|
-
// and that nothing was handed over.
|
|
1044
|
-
amendAcceptedAnswers(ctx, sessionId, (current) => ({
|
|
1045
|
-
...current,
|
|
1046
|
-
delivery_attempts: accepted.delivery_attempts ?? 0,
|
|
1047
|
-
held_deliveries: (current.held_deliveries ?? 0) + 1,
|
|
1048
|
-
}));
|
|
1049
|
-
}
|
|
1050
|
-
ctx.log?.info('hook.answer', {
|
|
1051
|
-
answered: true,
|
|
1052
|
-
stage: deliveredStage,
|
|
1053
|
-
route: deliveredRoute,
|
|
1054
|
-
acknowledgement: delivered.acknowledgement,
|
|
1055
|
-
delivery_attempt: attempt,
|
|
1056
|
-
request_ids: requestIds,
|
|
1057
|
-
answers: answered.length,
|
|
1058
|
-
journal_recorded_at: accepted.recorded_at,
|
|
1059
|
-
...(delivered.log?.['reason'] === undefined ? {} : { reason: delivered.log['reason'] }),
|
|
1060
|
-
});
|
|
1061
|
-
const outcome = { notes: [...notes, ...(delivered.notes ?? [])] };
|
|
1062
|
-
if (delivered.stdout !== undefined)
|
|
1063
|
-
outcome.stdout = delivered.stdout;
|
|
1064
|
-
if (delivered.commitStdout !== undefined)
|
|
1065
|
-
outcome.commitStdout = delivered.commitStdout;
|
|
1066
|
-
if (delivered.log !== undefined)
|
|
1067
|
-
outcome.log = delivered.log;
|
|
1068
|
-
if (route.kind === 'session-queue' && delivered.acknowledgement === 'delivered') {
|
|
1069
|
-
outcome.settlementRequired = pendingList(readSessionState(sessionId, ctx.env)).length > 0;
|
|
1070
|
-
}
|
|
1071
|
-
return outcome;
|
|
1072
|
-
}
|
|
1073
|
-
/**
|
|
1074
|
-
* A restart must never write an answer twice. When this session's delivery
|
|
1075
|
-
* journal shows that an earlier hand-off of any answer in the batch already
|
|
1076
|
-
* began its write — completed (`written`) or possibly (`writing`, `failed`) —
|
|
1077
|
-
* the batch settles without another write. Journal recovery reports the
|
|
1078
|
-
* earlier attempt's outcome; an unconfirmed one is never retried.
|
|
1079
|
-
*/
|
|
1080
|
-
async function suppressWrittenReplay(ctx, sessionId, accepted, notes, cwd) {
|
|
1081
|
-
const requestIds = accepted.answers.flatMap(({ pending }) => pending.request_id === undefined ? [] : [pending.request_id]);
|
|
1082
|
-
const written = answersAlreadyWritten(sessionId, ctx.env, requestIds);
|
|
1083
|
-
if (written.length === 0)
|
|
1084
|
-
return null;
|
|
1085
|
-
if (ctx.answerClaims !== undefined) {
|
|
1086
|
-
try {
|
|
1087
|
-
await recoverDeliveryJournal(answerSequencer(ctx, sessionId, ctx.answerClaims));
|
|
1088
|
-
}
|
|
1089
|
-
catch {
|
|
1090
|
-
// Reporting is recovery's to retry; suppression does not depend on it.
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
settleAcceptedAnswers(ctx, sessionId, accepted, cwd);
|
|
1094
|
-
ctx.log?.info('hook.answer', {
|
|
1095
|
-
answered: true,
|
|
1096
|
-
stage: 'replay-suppressed',
|
|
1097
|
-
request_ids: requestIds,
|
|
1098
|
-
journal: written.map((entry) => `${entry.requestId}:${entry.stage}`),
|
|
1099
|
-
});
|
|
1100
|
-
notes.push('an earlier hand-off of this answer already began its write; not writing it again');
|
|
1101
|
-
return { notes, log: { stage: 'replay-suppressed', request_ids: requestIds } };
|
|
1102
|
-
}
|
|
1103
|
-
/**
|
|
1104
|
-
* The Answer Edit release rule needs the fenced answer's outcome. A selected
|
|
1105
|
-
* answer written without a claim (the lease moved, the lock stayed busy, the
|
|
1106
|
-
* claim was refused) is recorded after the fact as handed off, so its edits
|
|
1107
|
-
* follow it instead of waiting on an attempt that never existed.
|
|
1108
|
-
*/
|
|
1109
|
-
async function reportUnclaimedHandOff(ctx, sessionId, answered, handOff) {
|
|
1110
|
-
const claimed = new Set((handOff?.claimed ?? []).flatMap((attempt) => attempt.subject.type === 'answer' ? [attempt.subject.request_id] : []));
|
|
1111
|
-
const unclaimed = answered.flatMap(({ pending, delivery_claim }) => delivery_claim === true && pending.request_id !== undefined && !claimed.has(pending.request_id)
|
|
1112
|
-
? [pending.request_id]
|
|
1113
|
-
: []);
|
|
1114
|
-
await reportHandedOffAfterTheFact(ctx, sessionId, unclaimed);
|
|
1115
|
-
}
|
|
1116
|
-
/**
|
|
1117
|
-
* Journal, then record, selected answers already written without a claim. A
|
|
1118
|
-
* record that fails stays in this session's delivery journal, so the next
|
|
1119
|
-
* hand-off here or the machine-wide sweep records it later.
|
|
1120
|
-
*/
|
|
1121
|
-
export async function reportHandedOffAfterTheFact(ctx, sessionId, requestIds) {
|
|
1122
|
-
if (requestIds.length === 0)
|
|
1123
|
-
return;
|
|
1124
|
-
const writer = ctx.answerClaims?.writer ?? currentProcessIdentity();
|
|
1125
|
-
if (writer === null)
|
|
1126
|
-
return;
|
|
1127
|
-
await recordUnclaimedHandOffs({
|
|
1128
|
-
sessionId,
|
|
1129
|
-
env: ctx.env,
|
|
1130
|
-
client: ctx.client,
|
|
1131
|
-
monotonic: ctx.answerClaims?.monotonic ?? (() => performance.now()),
|
|
1132
|
-
wall: ctx.now,
|
|
1133
|
-
sleep: ctx.sleep,
|
|
1134
|
-
writer,
|
|
1135
|
-
...(ctx.log === undefined ? {} : { log: ctx.log }),
|
|
1136
|
-
}, requestIds);
|
|
1137
|
-
}
|
|
1138
|
-
function answerSequencer(ctx, sessionId, claims) {
|
|
1139
|
-
return {
|
|
1140
|
-
sessionId,
|
|
1141
|
-
env: ctx.env,
|
|
1142
|
-
client: ctx.client,
|
|
1143
|
-
monotonic: claims.monotonic,
|
|
1144
|
-
wall: ctx.now,
|
|
1145
|
-
sleep: ctx.sleep,
|
|
1146
|
-
writer: claims.writer,
|
|
1147
|
-
...(ctx.log === undefined ? {} : { log: ctx.log }),
|
|
1148
|
-
};
|
|
1149
|
-
}
|
|
1150
|
-
/**
|
|
1151
|
-
* Selected answers this prompt hands off in its context without a claim; the
|
|
1152
|
-
* ones a claimed write already covered are left to that attempt.
|
|
1153
|
-
*/
|
|
1154
|
-
function unclaimedSelectedAnswers(sessionId, env, answers) {
|
|
1155
|
-
const selected = answers.flatMap(({ pending, delivery_claim }) => delivery_claim === true && pending.request_id !== undefined ? [pending.request_id] : []);
|
|
1156
|
-
const covered = new Set(answersAlreadyWritten(sessionId, env, selected).map((entry) => entry.requestId));
|
|
1157
|
-
return selected.filter((requestId) => !covered.has(requestId));
|
|
1158
|
-
}
|
|
1159
|
-
/** The machine-wide journal sweep, from a hook that already holds a client. */
|
|
1160
|
-
async function sweepDeliveryJournalsFor(ctx) {
|
|
1161
|
-
const writer = ctx.answerClaims?.writer ?? currentProcessIdentity();
|
|
1162
|
-
if (writer === null)
|
|
1163
|
-
return;
|
|
1164
|
-
try {
|
|
1165
|
-
await sweepDeliveryJournals({
|
|
1166
|
-
env: ctx.env,
|
|
1167
|
-
client: ctx.client,
|
|
1168
|
-
writer,
|
|
1169
|
-
now: ctx.now(),
|
|
1170
|
-
...(ctx.log === undefined ? {} : { log: ctx.log }),
|
|
1171
|
-
});
|
|
1172
|
-
}
|
|
1173
|
-
catch {
|
|
1174
|
-
// Housekeeping never fails a hook; the next sweep retries.
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
/** How long a waiter waits for the attendant to re-acquire a moved lease. */
|
|
1178
|
-
const ANSWER_CLAIM_RETRY_MS = 2_000;
|
|
1179
|
-
const ANSWER_CLAIM_ROUNDS = 3;
|
|
1180
|
-
/**
|
|
1181
|
-
* Claim every selected answer of this batch for one in-place write, or null
|
|
1182
|
-
* when nothing may be claimed: no attendant lease, a route that does not write
|
|
1183
|
-
* in place, or a delivery lock another writer kept.
|
|
1184
|
-
*/
|
|
1185
|
-
async function claimAnswerHandOff(ctx, sessionId, route, answered) {
|
|
1186
|
-
const claims = ctx.answerClaims;
|
|
1187
|
-
const subjects = answered.flatMap(({ pending, delivery_claim }) => delivery_claim === true && pending.request_id !== undefined
|
|
1188
|
-
? [{ type: 'answer', request_id: pending.request_id }]
|
|
1189
|
-
: []);
|
|
1190
|
-
if (claims === undefined || subjects.length === 0 || !claimableRoute(route))
|
|
1191
|
-
return null;
|
|
1192
|
-
const deps = answerSequencer(ctx, sessionId, claims);
|
|
1193
|
-
for (let round = 1;; round += 1) {
|
|
1194
|
-
const lease = claims.lease();
|
|
1195
|
-
const handOff = lease === null ? null : await beginHandOff(deps, { lease, subjects });
|
|
1196
|
-
if (handOff === null) {
|
|
1197
|
-
ctx.log?.info('delivery.claimed', {
|
|
1198
|
-
subject: 'answer',
|
|
1199
|
-
request_ids: subjects.map((subject) => subject.request_id),
|
|
1200
|
-
claimed: false,
|
|
1201
|
-
reason: lease === null ? 'no-attendant-lease' : 'delivery-lock-busy',
|
|
1202
|
-
});
|
|
1203
|
-
if (lease === null && round < ANSWER_CLAIM_ROUNDS) {
|
|
1204
|
-
await ctx.sleep(ANSWER_CLAIM_RETRY_MS);
|
|
1205
|
-
continue;
|
|
1206
|
-
}
|
|
1207
|
-
return null;
|
|
1208
|
-
}
|
|
1209
|
-
// A lease that moved between the close and this claim is re-read once the
|
|
1210
|
-
// attendant has had a moment to re-acquire; any other refusal is final.
|
|
1211
|
-
const fenced = handOff.claimed.length === 0 &&
|
|
1212
|
-
handOff.refused.every((refusal) => refusal.reason === 'generation_fenced');
|
|
1213
|
-
if (!fenced || round >= ANSWER_CLAIM_ROUNDS) {
|
|
1214
|
-
if (ctx.harness === 'hermes' && handOff.claimed.length !== subjects.length) {
|
|
1215
|
-
await handOff.finish('not-written');
|
|
1216
|
-
return null;
|
|
1217
|
-
}
|
|
1218
|
-
return handOff;
|
|
1219
|
-
}
|
|
1220
|
-
await handOff.finish('not-written');
|
|
1221
|
-
await ctx.sleep(ANSWER_CLAIM_RETRY_MS);
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
/** True while this Stop owner is still allowed a direct-wake wait. */
|
|
1225
|
-
function ownerLeaseActive(entry, now) {
|
|
1226
|
-
return entry.owner_deadline_at !== undefined && entry.owner_deadline_at > now;
|
|
1227
|
-
}
|
|
1228
|
-
/** The complete per-session Stop decision, after this process owns the claim. */
|
|
1229
|
-
async function handleClaimedStop(ctx, envelope, sessionId, pending, notes, hardDeadlineAt, route, handoffToDetachedOwner) {
|
|
1230
|
-
const live = pending.filter((entry) => entry.request_id !== undefined);
|
|
1231
|
-
const unasked = pending.filter((entry) => entry.request_id === undefined);
|
|
1232
|
-
if (ctx.harness !== undefined &&
|
|
1233
|
-
HARNESS_CAPABILITIES[ctx.harness].stopContinuation === 'unsupported') {
|
|
1234
|
-
for (const entry of live)
|
|
1235
|
-
await closeQuietly(ctx, entry.request_id);
|
|
1236
|
-
await retirePendings(ctx, envelope, sessionId, pending, 'expired');
|
|
1237
|
-
// Not `no-question`: there was a question, and this says so. Reading the
|
|
1238
|
-
// log to find out why an ask never travelled is the whole reason these
|
|
1239
|
-
// reasons exist, and the wrong one sent that reader looking for a question
|
|
1240
|
-
// that was right there.
|
|
1241
|
-
gate(ctx, 'held', 'harness-cannot-continue', { harness: ctx.harness });
|
|
1242
|
-
notes.push(`${HARNESS_CAPABILITIES[ctx.harness].deliveryContract}; use a blocking \`notifai send --reply\` question`);
|
|
1243
|
-
return { notes };
|
|
1244
|
-
}
|
|
1245
|
-
if (live.length > 0) {
|
|
1246
|
-
const { answered, permanentFailures } = await pollPendingReplies(ctx, live, LATE_STOP_POLL_SECONDS);
|
|
1247
|
-
const permanentFailure = permanentReplyFailureNote(permanentFailures);
|
|
1248
|
-
if (permanentFailure !== null)
|
|
1249
|
-
notes.push(permanentFailure);
|
|
1250
|
-
const permanentlyRejected = live.filter((entry) => permanentFailures.has(entry.request_id));
|
|
1251
|
-
await Promise.all(permanentlyRejected.map((entry) => closeQuietly(ctx, entry.request_id)));
|
|
1252
|
-
if (permanentlyRejected.length > 0) {
|
|
1253
|
-
await retirePendings(ctx, envelope, sessionId, permanentlyRejected, 'expired');
|
|
1254
|
-
}
|
|
1255
|
-
if (answered.length > 0) {
|
|
1256
|
-
const finalized = await finalizePendings(ctx, sessionId, answered.map((entry) => entry.pending), answerCloseDisposition(ctx, route));
|
|
1257
|
-
const authoritative = answered.map((entry) => {
|
|
1258
|
-
const response = finalized.find((candidate) => isSamePending(candidate.pending, entry.pending))?.response;
|
|
1259
|
-
const replies = response?.replies.length ? response.replies : entry.replies;
|
|
1260
|
-
if (response === null) {
|
|
1261
|
-
notes.push(`could not confirm the close fence for ${entry.pending.request_id}; delivering the durable answer already observed`);
|
|
1262
|
-
}
|
|
1263
|
-
return {
|
|
1264
|
-
...entry,
|
|
1265
|
-
replies,
|
|
1266
|
-
reply: replies.at(-1),
|
|
1267
|
-
agent_acknowledgement_required: response?.agent_acknowledgement_required ??
|
|
1268
|
-
entry.agent_acknowledgement_required,
|
|
1269
|
-
agent_acknowledgement_text_required: response?.agent_acknowledgement_text_required ??
|
|
1270
|
-
entry.agent_acknowledgement_text_required,
|
|
1271
|
-
...claimMarker(response),
|
|
1272
|
-
};
|
|
1273
|
-
});
|
|
1274
|
-
const accepted = stageAcceptedAnswers(ctx, sessionId, authoritative, pending.length - authoritative.length - permanentlyRejected.length);
|
|
1275
|
-
for (const answer of authoritative)
|
|
1276
|
-
reportAnswer(ctx, notes, answer, true);
|
|
1277
|
-
// Anything still unasked rides the next Stop: the agent is being resumed
|
|
1278
|
-
// with answers right now, and may not even need the rest afterwards.
|
|
1279
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
|
|
1280
|
-
}
|
|
1281
|
-
const recoverableLive = live.filter((entry) => !permanentFailures.has(entry.request_id));
|
|
1282
|
-
// A spent owner has already reached the end of the answer window it owned.
|
|
1283
|
-
// Passing those questions back into `escalate` starts a fresh ceiling and
|
|
1284
|
-
// can monopolize the per-session claim after expiry. Independent questions
|
|
1285
|
-
// whose lease is still running keep their successor wait.
|
|
1286
|
-
const stillOwned = recoverableLive.filter((entry) => ownerLeaseActive(entry, ctx.now()));
|
|
1287
|
-
if (unasked.length === 0) {
|
|
1288
|
-
if (recoverableLive.length === 0)
|
|
1289
|
-
return { notes };
|
|
1290
|
-
if (stillOwned.length === 0) {
|
|
1291
|
-
const requestIdSummary = summarizeRequestIds(recoverableLive);
|
|
1292
|
-
ctx.log?.info('hook.answer', {
|
|
1293
|
-
answered: false,
|
|
1294
|
-
stage: 'queued',
|
|
1295
|
-
request_ids: requestIdSummary.ids,
|
|
1296
|
-
owner_spent: true,
|
|
1297
|
-
});
|
|
1298
|
-
notes.push(`${recoverableLive.length} question${recoverableLive.length === 1 ? ' is' : 's are'} still recorded after the previous answer owner ended; a later turn will reconcile ${recoverableLive.length === 1 ? 'it' : 'them'} without starting another full-window wait`);
|
|
1299
|
-
return { notes };
|
|
1300
|
-
}
|
|
1301
|
-
return await escalate(ctx, envelope, sessionId, notes, hardDeadlineAt, route, handoffToDetachedOwner);
|
|
1302
|
-
}
|
|
1303
|
-
// Questions registered after the earlier push still owe the user their
|
|
1304
|
-
// notification; fall through and escalate just those, plus any live
|
|
1305
|
-
// question whose owner lease is still running.
|
|
1306
|
-
}
|
|
1307
|
-
gate(ctx, 'proceeding', 'proceeding', {
|
|
1308
|
-
unasked: unasked.length,
|
|
1309
|
-
already_live: live.length,
|
|
1310
|
-
grace_seconds: ctx.config.ask_grace_seconds.value,
|
|
1311
|
-
});
|
|
1312
|
-
return await escalate(ctx, envelope, sessionId, notes, hardDeadlineAt, route, handoffToDetachedOwner);
|
|
1313
|
-
}
|
|
1314
|
-
/** Submit independently of the long-lived answer owner, preserving frozen identities. */
|
|
1315
|
-
export async function submitSessionQuestions(ctx, envelope, hardDeadlineAt = ctx.now() + QUESTION_WAITER_CEILING_SECONDS * 1000, notes = []) {
|
|
1316
|
-
const sessionId = envelope.session_id;
|
|
1317
|
-
if (sessionId === undefined || sessionHasEnded(sessionId, ctx.env))
|
|
1318
|
-
return [];
|
|
1319
|
-
if (!ctx.config.ask_notifications.value) {
|
|
1320
|
-
gate(ctx, 'held', 'notifications-off', { source: ctx.config.ask_notifications.source });
|
|
1321
|
-
return [];
|
|
1322
|
-
}
|
|
1323
|
-
const lock = `${sessionStatePath(sessionId, ctx.env)}.submission`;
|
|
1324
|
-
// This lock spans admission only, never reply polling or harness delivery.
|
|
1325
|
-
const lockDeadline = Math.min(hardDeadlineAt, ctx.now() + 60_000);
|
|
1326
|
-
let token = acquireClaimFile(lock, {}, Date.now());
|
|
1327
|
-
while (token === null && ctx.now() < lockDeadline && !sessionHasEnded(sessionId, ctx.env)) {
|
|
1328
|
-
await ctx.sleep(Math.min(50, lockDeadline - ctx.now()));
|
|
1329
|
-
token = acquireClaimFile(lock, {}, Date.now());
|
|
1330
|
-
}
|
|
1331
|
-
if (token === null) {
|
|
1332
|
-
notes.push('question submission is already owned; the durable registration remains available for recovery');
|
|
1333
|
-
return [];
|
|
1334
|
-
}
|
|
1335
|
-
try {
|
|
1336
|
-
const ceilingAt = hardDeadlineAt;
|
|
1337
|
-
const state = readSessionState(sessionId, ctx.env);
|
|
1338
|
-
const pending = pendingList(state);
|
|
1339
|
-
if (state.continuation !== undefined && state.continuation.count >= MAX_CONTINUATION_COUNT) {
|
|
1340
|
-
gate(ctx, 'held', 'continuation-limit', { count: state.continuation.count, limit: MAX_CONTINUATION_COUNT });
|
|
1341
|
-
notes.push(`answer continuation limit (${MAX_CONTINUATION_COUNT}) reached; leaving the question in the terminal`);
|
|
1342
|
-
return [];
|
|
1343
|
-
}
|
|
1344
|
-
if (pending.length > 0 && pending.every((entry) => entry.request_id === undefined)) {
|
|
1345
|
-
await awaitTerminalFirstWindow(ctx, Math.min(...pending.map((entry) => entry.asked_at ?? ctx.now())), ceilingAt);
|
|
1346
|
-
}
|
|
1347
|
-
// Phase one: every registered question reaches the user's devices, each as
|
|
1348
|
-
// its own notification — one ask never stands in for another.
|
|
1349
|
-
const submitted = [];
|
|
1350
|
-
for (const entry of pendingList(readSessionState(sessionId, ctx.env)).filter((entry) => entry.request_id === undefined)) {
|
|
1351
|
-
if (entry.service_identity !== undefined && (entry.service_identity.machine_id !== ctx.service_identity?.machine_id ||
|
|
1352
|
-
entry.service_identity.base_url !== ctx.service_identity?.base_url)) {
|
|
1353
|
-
notes.push('the registered question belongs to a different Approved Machine; preserving it without uploading');
|
|
1354
|
-
continue;
|
|
1355
|
-
}
|
|
1356
|
-
if (!queuedQuestionStillEligible(sessionId, ctx.env, entry)) {
|
|
1357
|
-
notes.push('the question was retired before submission; not uploading it');
|
|
1358
|
-
continue;
|
|
1359
|
-
}
|
|
1360
|
-
// The service owns how long the answer is accepted. This process begins
|
|
1361
|
-
// before submission, so its larger maximum-window budget includes startup
|
|
1362
|
-
// headroom and remains alive through the complete committed window.
|
|
1363
|
-
const replyWindowSeconds = ctx.config.reply_window_seconds.value;
|
|
1364
|
-
if (ceilingAt - ctx.now() <
|
|
1365
|
-
(replyWindowSeconds + QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS) * 1000) {
|
|
1366
|
-
notes.push('too little owner lifetime remains for the complete configured answer window; leaving this question frozen for a successor owner');
|
|
1367
|
-
continue;
|
|
1368
|
-
}
|
|
1369
|
-
const ownerDeadlineAt = ceilingAt;
|
|
1370
|
-
const questions = pendingQuestions(entry);
|
|
1371
|
-
const eventSource = sourceContextAtHookEvent(entry.source, envelope.cwd);
|
|
1372
|
-
let intent = entry.submission;
|
|
1373
|
-
if (intent === undefined) {
|
|
1374
|
-
const prepared = await prepareQuestionSubmission(ctx, {
|
|
1375
|
-
summary: entry.summary,
|
|
1376
|
-
...(entry.body !== undefined ? { body: entry.body } : {}),
|
|
1377
|
-
questions,
|
|
1378
|
-
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
1379
|
-
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
1380
|
-
...(eventSource !== undefined ? { source: eventSource } : {}),
|
|
1381
|
-
windowSeconds: replyWindowSeconds,
|
|
1382
|
-
ownerDeadlineAt,
|
|
1383
|
-
});
|
|
1384
|
-
if ('error' in prepared) {
|
|
1385
|
-
ctx.log?.error('hook.pushed', { ok: false, message: prepared.error });
|
|
1386
|
-
notes.push(prepared.error);
|
|
1387
|
-
continue;
|
|
1388
|
-
}
|
|
1389
|
-
if (entry.service_identity !== undefined)
|
|
1390
|
-
prepared.service_identity = entry.service_identity;
|
|
1391
|
-
intent = prepared;
|
|
1392
|
-
// Durable before submit. If the server commits and the response is lost,
|
|
1393
|
-
// the reserved request id still lets this owner poll and finalize the
|
|
1394
|
-
// exact card; a successor can also replay the frozen draft/key.
|
|
1395
|
-
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1396
|
-
const list = pendingList(current);
|
|
1397
|
-
const index = list.findIndex((candidate) => isSamePending(candidate, entry));
|
|
1398
|
-
if (index < 0)
|
|
1399
|
-
return current;
|
|
1400
|
-
const next = [...list];
|
|
1401
|
-
next[index] = { ...next[index], submission: prepared };
|
|
1402
|
-
return { ...current, pending: next };
|
|
1403
|
-
});
|
|
1404
|
-
}
|
|
1405
|
-
else if (intent.owner_deadline_at <= ctx.now()) {
|
|
1406
|
-
// The frozen wire identity survives a crashed owner, but its local owner
|
|
1407
|
-
// lease does not. Re-arm only the local deadline; request id, key,
|
|
1408
|
-
// targets, and draft remain byte-identical for idempotent replay.
|
|
1409
|
-
intent = { ...intent, owner_deadline_at: ceilingAt };
|
|
1410
|
-
const rearmed = intent;
|
|
1411
|
-
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1412
|
-
const list = pendingList(current);
|
|
1413
|
-
const index = list.findIndex((candidate) => isSamePending(candidate, entry));
|
|
1414
|
-
if (index < 0)
|
|
1415
|
-
return current;
|
|
1416
|
-
const next = [...list];
|
|
1417
|
-
next[index] = { ...next[index], submission: rearmed };
|
|
1418
|
-
return { ...current, pending: next };
|
|
1419
|
-
});
|
|
1420
|
-
}
|
|
1421
|
-
if (ctx.now() >= intent.owner_deadline_at) {
|
|
1422
|
-
notes.push('the owner lifetime ended before submission; preserving the frozen intent');
|
|
1423
|
-
continue;
|
|
1424
|
-
}
|
|
1425
|
-
if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
|
|
1426
|
-
notes.push('setup consumed the admission allowance; preserving the frozen intent instead of publishing an answer window this owner cannot observe completely');
|
|
1427
|
-
continue;
|
|
1428
|
-
}
|
|
1429
|
-
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
1430
|
-
notes.push('the Agent Session ended before submission; preserving no live observer');
|
|
1431
|
-
continue;
|
|
1432
|
-
}
|
|
1433
|
-
let receipt;
|
|
1434
|
-
let admissionConfirmed = false;
|
|
1435
|
-
try {
|
|
1436
|
-
const attempt = submitQuestion(ctx, sessionId, entry, intent, notes);
|
|
1437
|
-
if (attempt === null)
|
|
1438
|
-
continue;
|
|
1439
|
-
receipt = await attempt;
|
|
1440
|
-
admissionConfirmed = true;
|
|
1441
|
-
}
|
|
1442
|
-
catch (caught) {
|
|
1443
|
-
let err = caught;
|
|
1444
|
-
if (err instanceof ApiCallError && isTerminalDraftRejection(err)) {
|
|
1445
|
-
ctx.log?.error('hook.pushed', {
|
|
1446
|
-
ok: false,
|
|
1447
|
-
request_id: intent.request_id,
|
|
1448
|
-
status: err.status,
|
|
1449
|
-
code: err.code,
|
|
1450
|
-
message: err.message,
|
|
1451
|
-
});
|
|
1452
|
-
if (entry.submission !== undefined) {
|
|
1453
|
-
if (readSessionState(sessionId, ctx.env).native_answer_operations?.some(op => op.question_id === entry.question_id)) {
|
|
1454
|
-
notes.push('the original submission was rejected; preserving its native answer operation without minting a replacement question');
|
|
1455
|
-
continue;
|
|
1456
|
-
}
|
|
1457
|
-
notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); reminting the draft in the current contract instead of replaying the frozen one`);
|
|
1458
|
-
const reminted = await prepareQuestionSubmission(ctx, {
|
|
1459
|
-
summary: entry.summary,
|
|
1460
|
-
...(entry.body !== undefined ? { body: entry.body } : {}),
|
|
1461
|
-
questions,
|
|
1462
|
-
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
1463
|
-
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
1464
|
-
...(eventSource !== undefined ? { source: eventSource } : {}),
|
|
1465
|
-
windowSeconds: replyWindowSeconds,
|
|
1466
|
-
ownerDeadlineAt,
|
|
1467
|
-
});
|
|
1468
|
-
if ('error' in reminted) {
|
|
1469
|
-
// Preserve the original intent; preparation can race an early
|
|
1470
|
-
// native answer and is not proof that its obligation disappeared.
|
|
1471
|
-
notes.push(reminted.error);
|
|
1472
|
-
continue;
|
|
1473
|
-
}
|
|
1474
|
-
if (entry.service_identity !== undefined)
|
|
1475
|
-
reminted.service_identity = entry.service_identity;
|
|
1476
|
-
if (!replaceFrozenSubmission(sessionId, ctx.env, entry, intent, reminted)) {
|
|
1477
|
-
notes.push('the original submission changed or has a native answer obligation; no replacement was admitted');
|
|
1478
|
-
continue;
|
|
1479
|
-
}
|
|
1480
|
-
intent = reminted;
|
|
1481
|
-
try {
|
|
1482
|
-
if (!canSubmitCompleteWindow(ctx, intent, replyWindowSeconds)) {
|
|
1483
|
-
notes.push('draft recovery consumed the admission allowance; preserving the frozen intent for a successor owner');
|
|
1484
|
-
continue;
|
|
1485
|
-
}
|
|
1486
|
-
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
1487
|
-
notes.push('the Agent Session ended before recovered submission');
|
|
1488
|
-
continue;
|
|
1489
|
-
}
|
|
1490
|
-
const attempt = submitQuestion(ctx, sessionId, entry, intent, notes);
|
|
1491
|
-
if (attempt === null)
|
|
1492
|
-
continue;
|
|
1493
|
-
receipt = await attempt;
|
|
1494
|
-
admissionConfirmed = true;
|
|
1495
|
-
}
|
|
1496
|
-
catch (retryErr) {
|
|
1497
|
-
if (retryErr instanceof ApiCallError && isTerminalDraftRejection(retryErr)) {
|
|
1498
|
-
dropPendingQuestion(sessionId, ctx.env, entry);
|
|
1499
|
-
notes.push(`reminted draft was also rejected (${retryErr.code}, HTTP ${retryErr.status}); retiring the question so it is not retried forever`);
|
|
1500
|
-
ctx.log?.error('hook.pushed', {
|
|
1501
|
-
ok: false,
|
|
1502
|
-
request_id: intent.request_id,
|
|
1503
|
-
status: retryErr.status,
|
|
1504
|
-
code: retryErr.code,
|
|
1505
|
-
message: retryErr.message,
|
|
1506
|
-
});
|
|
1507
|
-
continue;
|
|
1508
|
-
}
|
|
1509
|
-
err = retryErr;
|
|
1510
|
-
}
|
|
1511
|
-
}
|
|
1512
|
-
else {
|
|
1513
|
-
dropPendingQuestion(sessionId, ctx.env, entry);
|
|
1514
|
-
notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); retiring it because the current draft will never be accepted`);
|
|
1515
|
-
continue;
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
if (!admissionConfirmed && err instanceof ApiCallError && err.status < 500 && err.status !== 408) {
|
|
1519
|
-
notes.push(`question submission was rejected (${err.code}, HTTP ${err.status}); preserving it for recovery`);
|
|
1520
|
-
ctx.log?.error('hook.pushed', {
|
|
1521
|
-
ok: false,
|
|
1522
|
-
request_id: intent.request_id,
|
|
1523
|
-
status: err.status,
|
|
1524
|
-
code: err.code,
|
|
1525
|
-
message: err.message,
|
|
1526
|
-
});
|
|
1527
|
-
continue;
|
|
1528
|
-
}
|
|
1529
|
-
if (!admissionConfirmed) {
|
|
1530
|
-
notes.push(`question submission response was ambiguous; recovering ${intent.request_id}`);
|
|
1531
|
-
ctx.log?.error('hook.pushed', { ok: false, request_id: intent.request_id, message: String(err) });
|
|
1532
|
-
}
|
|
1533
|
-
}
|
|
1534
|
-
if (admissionConfirmed) {
|
|
1535
|
-
ctx.log?.info('hook.pushed', {
|
|
1536
|
-
ok: true,
|
|
1537
|
-
request_id: intent.request_id,
|
|
1538
|
-
devices: intent.device_ids.length,
|
|
1539
|
-
questions: questions.length,
|
|
1540
|
-
text_chars: questions[0].text.length,
|
|
1541
|
-
});
|
|
1542
|
-
}
|
|
1543
|
-
const committedReplyDeadline = receipt?.reply_expires_at === null || receipt?.reply_expires_at === undefined
|
|
1544
|
-
? intent.owner_deadline_at
|
|
1545
|
-
: Date.parse(receipt.reply_expires_at);
|
|
1546
|
-
const live = {
|
|
1547
|
-
...entry,
|
|
1548
|
-
...(intent.draft.source !== undefined ? { source: intent.draft.source } : {}),
|
|
1549
|
-
request_id: intent.request_id,
|
|
1550
|
-
collapse_key: intent.collapse_key,
|
|
1551
|
-
device_ids: intent.device_ids,
|
|
1552
|
-
// The server's committed answer deadline is authoritative. The local
|
|
1553
|
-
// owner deadline starts earlier and includes startup headroom, so it may
|
|
1554
|
-
// be later but must never be earlier.
|
|
1555
|
-
reply_deadline_at: Number.isFinite(committedReplyDeadline)
|
|
1556
|
-
? committedReplyDeadline
|
|
1557
|
-
: intent.owner_deadline_at,
|
|
1558
|
-
owner_deadline_at: intent.owner_deadline_at,
|
|
1559
|
-
};
|
|
1560
|
-
if (intent.draft.source === undefined)
|
|
1561
|
-
delete live.source;
|
|
1562
|
-
delete live.submission;
|
|
1563
|
-
// Record what is now live on the user's devices BEFORE any wait. If we
|
|
1564
|
-
// only learned these ids afterwards, a question that timed out would
|
|
1565
|
-
// leave no trace, and the user returning to the terminal could never
|
|
1566
|
-
// retire it — the notification would stay answerable for an hour with
|
|
1567
|
-
// nobody listening.
|
|
1568
|
-
if (admissionConfirmed) {
|
|
1569
|
-
updateSessionState(sessionId, ctx.env, (current) => {
|
|
1570
|
-
current = confirmNativeAnswerTarget(current, live);
|
|
1571
|
-
const list = pendingList(current);
|
|
1572
|
-
const index = list.findIndex((candidate) => isSamePending(candidate, entry) && candidate.request_id === undefined &&
|
|
1573
|
-
(candidate.service_identity === undefined || (candidate.service_identity.machine_id === ctx.service_identity?.machine_id &&
|
|
1574
|
-
candidate.service_identity.base_url === ctx.service_identity?.base_url)));
|
|
1575
|
-
if (index >= 0) {
|
|
1576
|
-
const next = [...list];
|
|
1577
|
-
next[index] = live;
|
|
1578
|
-
return { ...current, pending: next };
|
|
1579
|
-
}
|
|
1580
|
-
// The entry vanished while the submit was in flight (the user's prompt
|
|
1581
|
-
// wiped the queue). The delivered notification must still be retirable,
|
|
1582
|
-
// so park it rather than lose its only identifiers.
|
|
1583
|
-
const retirement = retiringQuestion(live, 'answered_elsewhere', envelope.cwd);
|
|
1584
|
-
const retiring = [...(current.retiring ?? [])];
|
|
1585
|
-
if (!retiring.some((parked) => parked.request_id === retirement.request_id)) {
|
|
1586
|
-
retiring.push(retirement);
|
|
1587
|
-
}
|
|
1588
|
-
return rememberQuestionState({ ...current, retiring }, live, 'retired');
|
|
1589
|
-
});
|
|
1590
|
-
}
|
|
1591
|
-
if (sessionHasEnded(sessionId, ctx.env)) {
|
|
1592
|
-
// SessionEnd may have raced either the submit or promotion. Its snapshot
|
|
1593
|
-
// usually already queued the frozen intent; this idempotent add closes
|
|
1594
|
-
// the other ordering where the request committed just after cleanup.
|
|
1595
|
-
orphanRetirements(ctx.env, [retiringQuestion(live, 'expired', envelope.cwd)], ctx.now());
|
|
1596
|
-
notes.push('the Agent Session ended during submission; queued the question for retirement');
|
|
1597
|
-
continue;
|
|
1598
|
-
}
|
|
1599
|
-
if (!pendingList(readSessionState(sessionId, ctx.env)).some((candidate) => isSamePending(candidate, entry))) {
|
|
1600
|
-
// A local close won after admission. Never resurrect the queue or wait
|
|
1601
|
-
// for an answer to a retired question; settle its reserved remote id.
|
|
1602
|
-
await drainRetirements(ctx, sessionId, ctx.env);
|
|
1603
|
-
continue;
|
|
1604
|
-
}
|
|
1605
|
-
submitted.push(live);
|
|
1606
|
-
}
|
|
1607
|
-
return submitted;
|
|
1608
|
-
}
|
|
1609
|
-
finally {
|
|
1610
|
-
releaseClaimFile(lock, token);
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
/** Merge this admission's uncertain receipts with fresh confirmed state. */
|
|
1614
|
-
function observableQuestions(state, submitted, now) {
|
|
1615
|
-
return pendingList(state).flatMap((entry) => {
|
|
1616
|
-
if (entry.request_id !== undefined)
|
|
1617
|
-
return ownerLeaseActive(entry, now) ? [entry] : [];
|
|
1618
|
-
const attempted = submitted.findLast((candidate) => isSamePending(candidate, entry) && candidate.request_id === entry.submission?.request_id);
|
|
1619
|
-
return attempted === undefined ? [] : [attempted];
|
|
1620
|
-
});
|
|
1621
|
-
}
|
|
1622
|
-
/** The escalation itself, split out so the claim is released on every path. */
|
|
1623
|
-
async function escalate(ctx, envelope, sessionId, notes, hardDeadlineAt, route, handoffToDetachedOwner) {
|
|
1624
|
-
const ceilingAt = hardDeadlineAt;
|
|
1625
|
-
const attemptedLocal = new Set(pendingList(readSessionState(sessionId, ctx.env))
|
|
1626
|
-
.filter((entry) => entry.request_id === undefined).map((entry) => entry.question_id ?? entry.question));
|
|
1627
|
-
const submitted = await submitSessionQuestions(ctx, envelope, hardDeadlineAt, notes);
|
|
1628
|
-
// Another submitter may have promoted a question while this owner waited.
|
|
1629
|
-
// The durable queue, not either caller's earlier snapshot, is authoritative.
|
|
1630
|
-
const live = observableQuestions(readSessionState(sessionId, ctx.env), submitted, ctx.now());
|
|
1631
|
-
const anomalous = live.filter((entry) => entry.reply_deadline_at !== undefined &&
|
|
1632
|
-
entry.reply_deadline_at > ceilingAt);
|
|
1633
|
-
if (anomalous.length > 0)
|
|
1634
|
-
notes.push('server committed an answer deadline beyond this process owner; reconciling the anomalous window');
|
|
1635
|
-
const staleLive = live.filter((entry) => entry.reply_deadline_at === undefined || entry.reply_deadline_at <= ctx.now() || anomalous.includes(entry));
|
|
1636
|
-
const finalizedStale = await finalizePendings(ctx, sessionId, staleLive, answerCloseDisposition(ctx, route));
|
|
1637
|
-
const staleAnswers = finalizedStale
|
|
1638
|
-
.map(finalizedAnswer)
|
|
1639
|
-
.filter((entry) => entry !== null);
|
|
1640
|
-
const staleSilence = finalizedStale
|
|
1641
|
-
.filter((entry) => entry.response !== null && entry.response.replies.length === 0)
|
|
1642
|
-
.map((entry) => entry.pending);
|
|
1643
|
-
const staleUnproven = finalizedStale.filter((entry) => entry.response === null);
|
|
1644
|
-
if (staleSilence.length > 0) {
|
|
1645
|
-
await retirePendings(ctx, envelope, sessionId, staleSilence, 'expired');
|
|
1646
|
-
notes.push(`expired ${staleSilence.length} question${staleSilence.length === 1 ? '' : 's'} whose continuation owner had ended`);
|
|
1647
|
-
}
|
|
1648
|
-
if (staleUnproven.length > 0) {
|
|
1649
|
-
notes.push(`could not finalize ${staleUnproven.length} question${staleUnproven.length === 1 ? '' : 's'}; preserving the live question and observing for recovery`);
|
|
1650
|
-
}
|
|
1651
|
-
if (staleAnswers.length > 0) {
|
|
1652
|
-
const remaining = Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - staleAnswers.length);
|
|
1653
|
-
const accepted = stageAcceptedAnswers(ctx, sessionId, staleAnswers, remaining);
|
|
1654
|
-
for (const answer of staleAnswers)
|
|
1655
|
-
reportAnswer(ctx, notes, answer, true);
|
|
1656
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
|
|
1657
|
-
}
|
|
1658
|
-
// A terminal close can race the remote close fence. Retain its remote identity
|
|
1659
|
-
// for retirement without resurrecting a removed local question.
|
|
1660
|
-
const remaining = pendingList(readSessionState(sessionId, ctx.env));
|
|
1661
|
-
const removed = staleUnproven.filter(({ pending }) => !remaining.some((entry) => isSamePending(entry, pending)));
|
|
1662
|
-
if (removed.length > 0)
|
|
1663
|
-
await retirePendings(ctx, envelope, sessionId, removed.map(({ pending }) => pending), 'answered_elsewhere');
|
|
1664
|
-
const waitingState = readSessionState(sessionId, ctx.env);
|
|
1665
|
-
const waitingOn = observableQuestions(waitingState, submitted, ctx.now()).filter((entry) => entry.reply_deadline_at !== undefined && entry.reply_deadline_at > ctx.now());
|
|
1666
|
-
if (waitingOn.length === 0)
|
|
1667
|
-
return { notes };
|
|
1668
|
-
// Codex has a durable exact-thread queue route. Release the short Stop hook
|
|
1669
|
-
// after admission so a detached owner, independent of the TUI process, can
|
|
1670
|
-
// observe the entire committed answer window. The owner lease and pending
|
|
1671
|
-
// request identities remain in session state for that successor.
|
|
1672
|
-
if (handoffToDetachedOwner) {
|
|
1673
|
-
return { notes, settlementRequired: true };
|
|
1674
|
-
}
|
|
1675
|
-
// Phase two: keep the same owner alive across every committed answer window.
|
|
1676
|
-
// Different questions may expire at different times, so finalize each one at
|
|
1677
|
-
// its own server deadline and continue waiting on the rest. Reserving time by
|
|
1678
|
-
// closing early would make the advertised reply window untrue.
|
|
1679
|
-
let activeWaiting = waitingOn;
|
|
1680
|
-
const knownRequestIds = new Set([
|
|
1681
|
-
...pendingList(waitingState).flatMap((entry) => entry.request_id === undefined ? [] : [entry.request_id]),
|
|
1682
|
-
...waitingOn.map((entry) => entry.request_id),
|
|
1683
|
-
]);
|
|
1684
|
-
let timeoutSeconds = 0;
|
|
1685
|
-
let waited;
|
|
1686
|
-
for (;;) {
|
|
1687
|
-
const nextReplyDeadline = Math.min(...activeWaiting.map((entry) => entry.reply_deadline_at));
|
|
1688
|
-
const waitDeadline = Math.min(ceilingAt, nextReplyDeadline);
|
|
1689
|
-
timeoutSeconds = Math.max(0, Math.ceil((waitDeadline - ctx.now()) / 1000));
|
|
1690
|
-
waited = await waitForAnyReply(ctx, activeWaiting.map((entry) => entry.request_id), timeoutSeconds, () => waiterInterruption(sessionId, ctx.env, [...knownRequestIds], attemptedLocal));
|
|
1691
|
-
if (waited.interrupted === 'new-question') {
|
|
1692
|
-
// Native observers can renew their process ceiling for a later question.
|
|
1693
|
-
// A held Stop keeps its actual stdout owner and includes the new live ID.
|
|
1694
|
-
if (route.kind === 'session-queue' || route.defer !== undefined) {
|
|
1695
|
-
notes.push('yielding the answer owner to include the newly registered question');
|
|
1696
|
-
return { notes, settlementRequired: true };
|
|
1697
|
-
}
|
|
1698
|
-
for (const entry of pendingList(readSessionState(sessionId, ctx.env))) {
|
|
1699
|
-
if (entry.request_id === undefined)
|
|
1700
|
-
attemptedLocal.add(entry.question_id ?? entry.question);
|
|
1701
|
-
}
|
|
1702
|
-
submitted.push(...await submitSessionQuestions(ctx, envelope, hardDeadlineAt, notes));
|
|
1703
|
-
activeWaiting = observableQuestions(readSessionState(sessionId, ctx.env), submitted, ctx.now());
|
|
1704
|
-
for (const entry of activeWaiting)
|
|
1705
|
-
knownRequestIds.add(entry.request_id);
|
|
1706
|
-
if (activeWaiting.length === 0)
|
|
1707
|
-
return { notes };
|
|
1708
|
-
continue;
|
|
1709
|
-
}
|
|
1710
|
-
if (waited.interrupted === 'ownership-ended') {
|
|
1711
|
-
notes.push('answer ownership ended locally; stopping this observer without delivering into a closed session');
|
|
1712
|
-
return { notes };
|
|
1713
|
-
}
|
|
1714
|
-
const currentRequestIds = new Set(pendingList(readSessionState(sessionId, ctx.env)).flatMap((entry) => [entry.request_id, entry.submission?.request_id].filter((requestId) => requestId !== undefined)));
|
|
1715
|
-
const removedDuringPoll = activeWaiting.filter((entry) => !currentRequestIds.has(entry.request_id));
|
|
1716
|
-
for (const entry of removedDuringPoll) {
|
|
1717
|
-
waited.byRequest.delete(entry.request_id);
|
|
1718
|
-
waited.permanentFailures.delete(entry.request_id);
|
|
1719
|
-
}
|
|
1720
|
-
if (removedDuringPoll.length > 0) {
|
|
1721
|
-
notes.push(`${removedDuringPoll.length} question${removedDuringPoll.length === 1 ? ' changed' : 's changed'} outside this observer; continuing with the independently live questions`);
|
|
1722
|
-
activeWaiting = activeWaiting.filter((entry) => currentRequestIds.has(entry.request_id));
|
|
1723
|
-
if (activeWaiting.length === 0)
|
|
1724
|
-
return { notes };
|
|
1725
|
-
}
|
|
1726
|
-
const permanentFailure = permanentReplyFailureNote(waited.permanentFailures);
|
|
1727
|
-
if (permanentFailure !== null)
|
|
1728
|
-
notes.push(permanentFailure);
|
|
1729
|
-
if (waited.byRequest.size > 0)
|
|
1730
|
-
break;
|
|
1731
|
-
// A permanent rejection is authoritative immediately. Time-based expiry is
|
|
1732
|
-
// authoritative only when this owner reached that question's committed
|
|
1733
|
-
// server deadline; reaching the process ceiling first preserves the record.
|
|
1734
|
-
const expired = activeWaiting.filter((entry) => entry.reply_deadline_at === undefined ||
|
|
1735
|
-
entry.reply_deadline_at <= waitDeadline ||
|
|
1736
|
-
waited.permanentFailures.has(entry.request_id));
|
|
1737
|
-
const stillAnswerable = activeWaiting.filter((entry) => !expired.includes(entry));
|
|
1738
|
-
const finalized = await finalizePendings(ctx, sessionId, expired, answerCloseDisposition(ctx, route));
|
|
1739
|
-
const finalAnswers = finalized
|
|
1740
|
-
.map(finalizedAnswer)
|
|
1741
|
-
.filter((entry) => entry !== null);
|
|
1742
|
-
const confirmedSilent = finalized
|
|
1743
|
-
.filter((entry) => entry.response !== null && entry.response.replies.length === 0)
|
|
1744
|
-
.map((entry) => entry.pending);
|
|
1745
|
-
const unproven = finalized.filter((entry) => entry.response === null);
|
|
1746
|
-
if (confirmedSilent.length > 0) {
|
|
1747
|
-
await retirePendings(ctx, envelope, sessionId, confirmedSilent, 'expired');
|
|
1748
|
-
}
|
|
1749
|
-
if (unproven.length > 0) {
|
|
1750
|
-
notes.push(`could not finalize ${unproven.length} question${unproven.length === 1 ? '' : 's'}; preserving ownership rather than risking a lost late answer`);
|
|
1751
|
-
}
|
|
1752
|
-
if (finalAnswers.length > 0) {
|
|
1753
|
-
const remaining = Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - finalAnswers.length);
|
|
1754
|
-
const accepted = stageAcceptedAnswers(ctx, sessionId, finalAnswers, remaining);
|
|
1755
|
-
for (const answer of finalAnswers)
|
|
1756
|
-
reportAnswer(ctx, notes, answer, false);
|
|
1757
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
|
|
1758
|
-
}
|
|
1759
|
-
if (stillAnswerable.length > 0 && waitDeadline < ceilingAt) {
|
|
1760
|
-
activeWaiting = stillAnswerable;
|
|
1761
|
-
continue;
|
|
1762
|
-
}
|
|
1763
|
-
const requestIdSummary = summarizeRequestIds(waitingOn);
|
|
1764
|
-
ctx.log?.info('hook.answer', {
|
|
1765
|
-
answered: false,
|
|
1766
|
-
stage: 'queued',
|
|
1767
|
-
request_ids: requestIdSummary.ids,
|
|
1768
|
-
degraded: waited.degraded,
|
|
1769
|
-
permanent_failures: waited.permanentFailures.size,
|
|
1770
|
-
waited_seconds: timeoutSeconds,
|
|
1771
|
-
});
|
|
1772
|
-
if (stillAnswerable.length > 0) {
|
|
1773
|
-
notes.push(`${stillAnswerable.length} question${stillAnswerable.length === 1 ? ' remains' : 's remain'} answerable after the process owner ended; preserving ${stillAnswerable.length === 1 ? 'it' : 'them'} for recovery`);
|
|
1774
|
-
}
|
|
1775
|
-
else if (waited.permanentFailures.size === 0 && confirmedSilent.length > 0) {
|
|
1776
|
-
notes.push(waited.degraded
|
|
1777
|
-
? 'could not reach the server before the answer deadline; expired questions were retired so no answer can be lost later'
|
|
1778
|
-
: 'no answer in time; the question expired with its continuation owner');
|
|
1779
|
-
}
|
|
1780
|
-
return { notes };
|
|
1781
|
-
}
|
|
1782
|
-
const polledAnswered = activeWaiting.filter((entry) => waited.byRequest.has(entry.request_id));
|
|
1783
|
-
const finalizedAnswered = await finalizePendings(ctx, sessionId, polledAnswered, answerCloseDisposition(ctx, route));
|
|
1784
|
-
const answered = [];
|
|
1785
|
-
for (const finalized of finalizedAnswered) {
|
|
1786
|
-
const entry = finalized.pending;
|
|
1787
|
-
const replies = waited.byRequest.get(entry.request_id);
|
|
1788
|
-
if (replies === undefined)
|
|
1789
|
-
continue;
|
|
1790
|
-
// The fenced set wins because it includes corrections that committed
|
|
1791
|
-
// while close was waiting for earlier writers. If finalization itself was
|
|
1792
|
-
// unreachable, never discard the durable reply already observed.
|
|
1793
|
-
const finalReplies = finalized.response?.replies ?? [];
|
|
1794
|
-
const authoritative = finalReplies.length > 0 ? finalReplies : replies;
|
|
1795
|
-
answered.push({
|
|
1796
|
-
pending: entry,
|
|
1797
|
-
reply: authoritative.at(-1),
|
|
1798
|
-
replies: authoritative,
|
|
1799
|
-
agent_acknowledgement_required: finalized.response?.agent_acknowledgement_required,
|
|
1800
|
-
agent_acknowledgement_text_required: finalized.response?.agent_acknowledgement_text_required,
|
|
1801
|
-
...claimMarker(finalized.response),
|
|
1802
|
-
});
|
|
1803
|
-
if (finalized.response === null) {
|
|
1804
|
-
notes.push(`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`);
|
|
1805
|
-
}
|
|
1806
|
-
}
|
|
1807
|
-
const accepted = stageAcceptedAnswers(ctx, sessionId, answered, Math.max(0, pendingList(readSessionState(sessionId, ctx.env)).length - answered.length));
|
|
1808
|
-
for (const answer of answered)
|
|
1809
|
-
reportAnswer(ctx, notes, answer, false);
|
|
1810
|
-
return deliverAcceptedAnswers(ctx, sessionId, route, accepted, notes, envelope.cwd);
|
|
1811
|
-
}
|
|
1812
|
-
// ---------------------------------------------------------------------------
|
|
1813
|
-
// SessionEnd — local cleanup only
|
|
1814
|
-
// ---------------------------------------------------------------------------
|
|
1815
|
-
/**
|
|
1816
|
-
* Claude Code gives SessionEnd hooks a 1.5-second shared budget and Codex 1
|
|
1817
|
-
* second, so this cannot make a network call. It drops the local marker — but
|
|
1818
|
-
* first moves anything still live on the user's devices into the global
|
|
1819
|
-
* retirement queue, because this file was the only record of those ids and no
|
|
1820
|
-
* hook for this session will ever run again. A question whose
|
|
1821
|
-
* agent just exited can receive no answer, so it is orphaned as `expired`.
|
|
1822
|
-
*/
|
|
1823
|
-
export function handleSessionEnd(env, envelope, now = Date.now(), preserveAccepted = true) {
|
|
1824
|
-
const notes = [];
|
|
1825
|
-
const sessionId = envelope.session_id;
|
|
1826
|
-
if (!sessionId)
|
|
1827
|
-
return { notes, log: { outcome: 'ignored', reason: 'missing-session-id' } };
|
|
1828
|
-
// Publish cancellation before reading or clearing anything. In-flight Stop
|
|
1829
|
-
// writers use the same session lock, so none can recreate state after this.
|
|
1830
|
-
const ending = markSessionEnded(sessionId, env, now);
|
|
1831
|
-
if (envelope.cwd !== undefined)
|
|
1832
|
-
clearMatchingProjectSession(envelope.cwd, env, sessionId);
|
|
1833
|
-
const state = readSessionState(sessionId, env);
|
|
1834
|
-
let stateWithHistory = state;
|
|
1835
|
-
for (const entry of pendingList(state)) {
|
|
1836
|
-
stateWithHistory = rememberQuestionState(stateWithHistory, entry, entry.request_id === undefined && entry.submission === undefined ? 'withdrawn' : 'retired');
|
|
1837
|
-
}
|
|
1838
|
-
for (const answer of [...(state.accepted?.answers ?? []), ...(state.delivered_answers ?? []), ...(state.waiting_answers ?? [])]) {
|
|
1839
|
-
stateWithHistory = rememberQuestionState(stateWithHistory, answer.pending, 'answered');
|
|
1840
|
-
}
|
|
1841
|
-
for (const retirement of state.retiring ?? []) {
|
|
1842
|
-
stateWithHistory = rememberQuestionState(stateWithHistory, retirement, retirement.state === 'answered' ? 'answered' : 'retired');
|
|
1843
|
-
}
|
|
1844
|
-
const orphans = [...(state.retiring ?? [])];
|
|
1845
|
-
const retirementCandidates = [
|
|
1846
|
-
...pendingList(state),
|
|
1847
|
-
...(state.accepted?.answers.map((entry) => entry.pending) ?? []),
|
|
1848
|
-
...(state.delivered_answers?.map((entry) => entry.pending) ?? []),
|
|
1849
|
-
...(state.waiting_answers?.map((entry) => entry.pending) ?? []),
|
|
1850
|
-
];
|
|
1851
|
-
for (const entry of retirementCandidates) {
|
|
1852
|
-
try {
|
|
1853
|
-
const orphan = retiringQuestion(entry, 'expired', envelope.cwd);
|
|
1854
|
-
if (orphan !== null &&
|
|
1855
|
-
!orphans.some((candidate) => candidate.request_id === orphan.request_id)) {
|
|
1856
|
-
orphans.push(orphan);
|
|
1857
|
-
}
|
|
1858
|
-
}
|
|
1859
|
-
catch (err) {
|
|
1860
|
-
notes.push(err instanceof Error ? err.message : String(err));
|
|
1861
|
-
// Preserve the only identifiers instead of turning an explicit corrupt
|
|
1862
|
-
// state into an unretirable question during SessionEnd cleanup.
|
|
1863
|
-
return {
|
|
1864
|
-
notes,
|
|
1865
|
-
log: { outcome: 'preserved', reason: 'incomplete-question-identifiers' },
|
|
1866
|
-
};
|
|
1867
|
-
}
|
|
1868
|
-
}
|
|
1869
|
-
if (orphans.length > 0) {
|
|
1870
|
-
orphanRetirements(env, orphans, now);
|
|
1871
|
-
notes.push(`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`);
|
|
1872
|
-
}
|
|
1873
|
-
return withFileLock(`${sessionStatePath(sessionId, env)}.lock`, () => {
|
|
1874
|
-
const marker = readSessionEndMarker(sessionId, env);
|
|
1875
|
-
if (marker?.stamp?.mono !== ending.stamp?.mono ||
|
|
1876
|
-
(readSessionIncarnation(sessionId, env)?.key ?? null) !== ending.ends) {
|
|
1877
|
-
return { notes, log: { outcome: 'preserved', reason: 'session-resumed-during-cleanup' } };
|
|
1878
|
-
}
|
|
1879
|
-
// A committed receipt can arrive after the end marker. Preserve the fresh
|
|
1880
|
-
// obligation state rather than restoring the earlier cleanup snapshot.
|
|
1881
|
-
const state = readSessionState(sessionId, env);
|
|
1882
|
-
stateWithHistory = { ...state, ...(stateWithHistory.question_history === undefined ? {} : { question_history: stateWithHistory.question_history }) };
|
|
1883
|
-
if ((preserveAccepted && state.accepted !== undefined) ||
|
|
1884
|
-
(state.acknowledgement_due?.length ?? 0) > 0 ||
|
|
1885
|
-
(state.message_acknowledgement_due?.length ?? 0) > 0 ||
|
|
1886
|
-
(state.native_answer_operations?.length ?? 0) > 0 ||
|
|
1887
|
-
(state.codex_question_bindings?.length ?? 0) > 0) {
|
|
1888
|
-
const preserved = { ...stateWithHistory };
|
|
1889
|
-
if (!preserveAccepted)
|
|
1890
|
-
delete preserved.accepted;
|
|
1891
|
-
if (!preserveAccepted)
|
|
1892
|
-
delete preserved.waiting_answers;
|
|
1893
|
-
delete preserved.input_wake;
|
|
1894
|
-
delete preserved.pending;
|
|
1895
|
-
delete preserved.retiring;
|
|
1896
|
-
delete preserved.acknowledgement_blocks;
|
|
1897
|
-
if ((preserved.acknowledgement_due?.length ?? 0) === 0) {
|
|
1898
|
-
delete preserved.acknowledgement_due;
|
|
1899
|
-
}
|
|
1900
|
-
if ((preserved.message_acknowledgement_due?.length ?? 0) === 0) {
|
|
1901
|
-
delete preserved.message_acknowledgement_due;
|
|
1902
|
-
}
|
|
1903
|
-
writeSessionStateUnlocked(sessionStatePath(sessionId, env), sessionId, preserved);
|
|
1904
|
-
notes.push(preserveAccepted && state.accepted !== undefined
|
|
1905
|
-
? 'preserved an accepted device answer for this exact session to resume'
|
|
1906
|
-
: 'preserved required Agent Acknowledgement obligations for this exact session');
|
|
1907
|
-
return {
|
|
1908
|
-
notes,
|
|
1909
|
-
log: {
|
|
1910
|
-
outcome: preserveAccepted && state.accepted !== undefined ? 'answer-preserved' : 'acknowledgement-preserved',
|
|
1911
|
-
queued_retirements: orphans.length,
|
|
1912
|
-
accepted_answers: state.accepted?.answers.length ?? 0,
|
|
1913
|
-
acknowledgement_due: state.acknowledgement_due?.length ?? 0,
|
|
1914
|
-
...(state.message_acknowledgement_due === undefined
|
|
1915
|
-
? {}
|
|
1916
|
-
: { message_acknowledgement_due: state.message_acknowledgement_due.length }),
|
|
1917
|
-
},
|
|
1918
|
-
};
|
|
1919
|
-
}
|
|
1920
|
-
const history = stateWithHistory.question_history;
|
|
1921
|
-
clearSessionStateUnlocked(sessionId, env);
|
|
1922
|
-
if ((history?.length ?? 0) > 0) {
|
|
1923
|
-
writeSessionStateUnlocked(sessionStatePath(sessionId, env), sessionId, { question_history: history });
|
|
1924
|
-
}
|
|
1925
|
-
return { notes, log: { outcome: 'cleaned', queued_retirements: orphans.length } };
|
|
1926
|
-
});
|
|
1927
|
-
}
|
|
1928
|
-
// ---------------------------------------------------------------------------
|
|
1929
|
-
// ask — register a question for the turn's end
|
|
1930
|
-
// ---------------------------------------------------------------------------
|
|
1931
|
-
/**
|
|
1932
|
-
* Registrations still waiting for their first push. More than this in one turn
|
|
1933
|
-
* means an agent is looping, not asking, and related questions belong in one
|
|
1934
|
-
* `ask --form` instead.
|
|
1935
|
-
*
|
|
1936
|
-
* Deliberately counts only unasked entries. A question already on the user's
|
|
1937
|
-
* devices is waiting on a person, not on the agent, and an answer stays
|
|
1938
|
-
* accepted for a day — so counting live questions here would have turned a
|
|
1939
|
-
* patient user into a reason the agent could no longer ask anything.
|
|
1940
|
-
*/
|
|
1941
|
-
export const MAX_PENDING_QUESTIONS = 4;
|
|
1942
|
-
/**
|
|
1943
|
-
* Everything this session has open at once, asked or not. The lock screen is
|
|
1944
|
-
* the shared resource being protected here, not the agent's turn.
|
|
1945
|
-
*/
|
|
1946
|
-
export const MAX_LIVE_QUESTIONS = 10;
|
|
1947
|
-
export class QuestionRegistrationChanged extends Error {
|
|
1948
|
-
reason;
|
|
1949
|
-
constructor(reason) {
|
|
1950
|
-
super(reason === 'credential'
|
|
1951
|
-
? 'The Approved Machine changed before question registration completed.'
|
|
1952
|
-
: 'The Agent Session ended or changed before question registration completed.');
|
|
1953
|
-
this.reason = reason;
|
|
1954
|
-
}
|
|
1955
|
-
}
|
|
1956
|
-
/**
|
|
1957
|
-
* A session may hold several registered questions at once: a new `ask` never
|
|
1958
|
-
* ends an earlier one. Each reaches the user as its own notification and is
|
|
1959
|
-
* answerable independently. Superseding is reply semantics — a later reply
|
|
1960
|
-
* corrects an earlier reply to the same question (that correction model lives
|
|
1961
|
-
* server-side) — never question semantics: the old single-slot model silently
|
|
1962
|
-
* discarded a question the moment a second was registered, and the user was
|
|
1963
|
-
* pointed at a notification that never existed (2026-08-09).
|
|
1964
|
-
*
|
|
1965
|
-
* The queue is keyed on the session, not the project: several agents may be
|
|
1966
|
-
* running in one project at once, and their questions must never interfere.
|
|
1967
|
-
*/
|
|
1968
|
-
export function registerQuestion(sessionId, env, question, now = Date.now(), nativeAdmission, guard) {
|
|
1969
|
-
let full = null;
|
|
1970
|
-
let stored = false;
|
|
1971
|
-
const questionId = question.question_id ?? `q_${randomBytes(12).toString('base64url')}`;
|
|
1972
|
-
updateSessionState(sessionId, env, (state) => {
|
|
1973
|
-
// Revalidate after acquiring the lock: discovery and lock contention can
|
|
1974
|
-
// outlive the owner or its Approved Machine. Neither may adopt this ask.
|
|
1975
|
-
if (guard !== undefined) {
|
|
1976
|
-
if (!guard.machineStillMatches())
|
|
1977
|
-
throw new QuestionRegistrationChanged('credential');
|
|
1978
|
-
if (guard.owner_key !== undefined) {
|
|
1979
|
-
const owner = readSessionIncarnation(sessionId, env);
|
|
1980
|
-
if (owner?.key !== guard.owner_key || sessionHasEnded(sessionId, env) ||
|
|
1981
|
-
owner.harness_process === undefined || processIdentityLiveness(owner.harness_process) !== 'alive') {
|
|
1982
|
-
throw new QuestionRegistrationChanged('owner');
|
|
1983
|
-
}
|
|
1984
|
-
}
|
|
1985
|
-
}
|
|
1986
|
-
const pending = pendingList(state);
|
|
1987
|
-
const unasked = pending.filter((entry) => entry.request_id === undefined);
|
|
1988
|
-
if (unasked.length >= MAX_PENDING_QUESTIONS) {
|
|
1989
|
-
full = 'unasked';
|
|
1990
|
-
return state;
|
|
1991
|
-
}
|
|
1992
|
-
if (pending.length >= MAX_LIVE_QUESTIONS) {
|
|
1993
|
-
full = 'live';
|
|
1994
|
-
return state;
|
|
1995
|
-
}
|
|
1996
|
-
stored = true;
|
|
1997
|
-
const registered = { asked_at: now, ...question, question_id: questionId, question: question.question.slice(0, MAX_STORED_QUESTION_CHARS) };
|
|
1998
|
-
return reserveCurrentCodexQuestion({
|
|
1999
|
-
...state,
|
|
2000
|
-
pending: [
|
|
2001
|
-
...pending,
|
|
2002
|
-
registered,
|
|
2003
|
-
],
|
|
2004
|
-
}, registered, sessionId, env, nativeAdmission);
|
|
2005
|
-
});
|
|
2006
|
-
if (full === 'unasked') {
|
|
2007
|
-
throw new Error(`${MAX_PENDING_QUESTIONS} questions are already waiting to be asked. ` +
|
|
2008
|
-
'Combine related questions into one `notifai ask --form` instead of registering more.');
|
|
2009
|
-
}
|
|
2010
|
-
if (full === 'live') {
|
|
2011
|
-
throw new Error(`${MAX_LIVE_QUESTIONS} questions from this session are already open. ` +
|
|
2012
|
-
'Retire the ones you no longer need with `notifai close <question_id>` or `notifai close --pending` before asking another.');
|
|
2013
|
-
}
|
|
2014
|
-
// updateSessionState skips its callback when SessionEnd won the lock.
|
|
2015
|
-
if (!stored)
|
|
2016
|
-
throw new QuestionRegistrationChanged('owner');
|
|
2017
|
-
return questionId;
|
|
2018
|
-
}
|
|
2019
|
-
/** Parses hook JSON from stdin, tolerating an empty or malformed body. */
|
|
2020
|
-
export function parseHookInput(raw) {
|
|
2021
|
-
if (raw.trim() === '')
|
|
2022
|
-
return {};
|
|
2023
|
-
try {
|
|
2024
|
-
const parsed = JSON.parse(raw);
|
|
2025
|
-
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
|
2026
|
-
}
|
|
2027
|
-
catch {
|
|
2028
|
-
return {};
|
|
2029
|
-
}
|
|
2030
|
-
}
|
|
2031
|
-
//# sourceMappingURL=hook-lifecycle.js.map
|