@phyzess/pappra 0.1.0-beta.13 → 0.1.0-beta.18
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/README.md +134 -15
- package/assets/integrations/skills/pappra/SKILL.md +28 -7
- package/assets/integrations/skills/pappra/references/collaboration.md +633 -0
- package/assets/integrations/skills/pappra/references/environment.md +360 -0
- package/assets/integrations/skills/pappra/references/hosts.md +20 -0
- package/assets/integrations/skills/pappra/references/operations.md +36 -5
- package/dist/adapters/pi/recovery-extension.d.ts +65 -0
- package/dist/adapters/pi/recovery-extension.js +238 -0
- package/dist/adapters/pi/session-transition-extension.d.ts +30 -0
- package/dist/adapters/pi/session-transition-extension.js +265 -0
- package/dist/app/automatic-handoff-events.d.ts +26 -0
- package/dist/app/automatic-handoff-events.js +134 -0
- package/dist/app/bounded-file.d.ts +2 -0
- package/dist/app/bounded-file.js +2 -0
- package/dist/app/capability-evidence/claude.d.ts +37 -0
- package/dist/app/capability-evidence/claude.js +206 -0
- package/dist/app/capability-evidence/codex.d.ts +35 -0
- package/dist/app/capability-evidence/codex.js +193 -0
- package/dist/app/capability-evidence/index.d.ts +33 -0
- package/dist/app/capability-evidence/index.js +117 -0
- package/dist/app/capability-evidence/pi.d.ts +73 -0
- package/dist/app/capability-evidence/pi.js +669 -0
- package/dist/app/capability-evidence/shared.d.ts +69 -0
- package/dist/app/capability-evidence/shared.js +195 -0
- package/dist/app/collab-auto-handoff.d.ts +55 -0
- package/dist/app/collab-auto-handoff.js +384 -0
- package/dist/app/collab-bindings.d.ts +57 -0
- package/dist/app/collab-bindings.js +1006 -0
- package/dist/app/collab-bootstrap-transport.d.ts +163 -0
- package/dist/app/collab-bootstrap-transport.js +769 -0
- package/dist/app/collab-bootstrap.d.ts +137 -0
- package/dist/app/collab-bootstrap.js +3455 -0
- package/dist/app/collab-handoff.d.ts +12 -0
- package/dist/app/collab-handoff.js +511 -0
- package/dist/app/collab-messages.d.ts +2 -0
- package/dist/app/collab-messages.js +2 -0
- package/dist/app/collab-notify.d.ts +156 -0
- package/dist/app/collab-notify.js +1208 -0
- package/dist/app/collab-preflight.d.ts +34 -0
- package/dist/app/collab-preflight.js +96 -0
- package/dist/app/collab-recovery-executor.d.ts +161 -0
- package/dist/app/collab-recovery-executor.js +1153 -0
- package/dist/app/collab-recovery-projection.d.ts +103 -0
- package/dist/app/collab-recovery-projection.js +659 -0
- package/dist/app/collab-recovery.d.ts +25 -0
- package/dist/app/collab-recovery.js +429 -0
- package/dist/app/collab-resources.d.ts +16 -0
- package/dist/app/collab-resources.js +278 -0
- package/dist/app/collab-role-events.d.ts +22 -0
- package/dist/app/collab-role-events.js +329 -0
- package/dist/app/collab-role-orchestration.d.ts +53 -0
- package/dist/app/collab-role-orchestration.js +601 -0
- package/dist/app/collab-role-plan.d.ts +81 -0
- package/dist/app/collab-role-plan.js +504 -0
- package/dist/app/collab-role-repair.d.ts +109 -0
- package/dist/app/collab-role-repair.js +821 -0
- package/dist/app/collab-rollover.d.ts +263 -0
- package/dist/app/collab-rollover.js +1112 -0
- package/dist/app/collab-route.d.ts +64 -0
- package/dist/app/collab-route.js +240 -0
- package/dist/app/collab-session-transition.d.ts +221 -0
- package/dist/app/collab-session-transition.js +3334 -0
- package/dist/app/collab-state.d.ts +7 -0
- package/dist/app/collab-state.js +162 -0
- package/dist/app/collab-task-store.d.ts +22 -0
- package/dist/app/collab-task-store.js +204 -0
- package/dist/app/collab-work-execution.d.ts +127 -0
- package/dist/app/collab-work-execution.js +1027 -0
- package/dist/app/collab-work-plan.d.ts +87 -0
- package/dist/app/collab-work-plan.js +437 -0
- package/dist/app/collaboration.d.ts +8 -2
- package/dist/app/collaboration.js +101 -12
- package/dist/app/doctor.js +108 -5
- package/dist/app/host-mcp.js +34 -8
- package/dist/app/host-probe.js +4 -4
- package/dist/app/init.d.ts +58 -4
- package/dist/app/init.js +768 -155
- package/dist/app/managed-pi-bootstrap-admission.d.ts +14 -0
- package/dist/app/managed-pi-bootstrap-admission.js +104 -0
- package/dist/app/managed-pi-bootstrap-refresh.d.ts +10 -0
- package/dist/app/managed-pi-bootstrap-refresh.js +78 -0
- package/dist/app/managed-pi-delivery.d.ts +8 -0
- package/dist/app/managed-pi-delivery.js +110 -0
- package/dist/app/managed-pi-handoff-receiver.d.ts +15 -0
- package/dist/app/managed-pi-handoff-receiver.js +263 -0
- package/dist/app/managed-pi-handoff-reference.d.ts +3 -0
- package/dist/app/managed-pi-handoff-reference.js +60 -0
- package/dist/app/managed-pi-herdr.d.ts +21 -0
- package/dist/app/managed-pi-herdr.js +185 -0
- package/dist/app/managed-pi-initial-admission.d.ts +28 -0
- package/dist/app/managed-pi-initial-admission.js +416 -0
- package/dist/app/managed-pi-initial-launch.d.ts +5 -0
- package/dist/app/managed-pi-initial-launch.js +61 -0
- package/dist/app/managed-pi-native-observation.d.ts +36 -0
- package/dist/app/managed-pi-native-observation.js +191 -0
- package/dist/app/managed-pi-readiness.d.ts +29 -0
- package/dist/app/managed-pi-readiness.js +288 -0
- package/dist/app/managed-pi-repair-stop.d.ts +21 -0
- package/dist/app/managed-pi-repair-stop.js +371 -0
- package/dist/app/managed-pi-resume-admission.d.ts +32 -0
- package/dist/app/managed-pi-resume-admission.js +618 -0
- package/dist/app/managed-pi-retirement.d.ts +28 -0
- package/dist/app/managed-pi-retirement.js +112 -0
- package/dist/app/managed-pi-sdk-control.d.ts +7 -0
- package/dist/app/managed-pi-sdk-control.js +169 -0
- package/dist/app/managed-pi-sdk-runtime.d.ts +3 -0
- package/dist/app/managed-pi-sdk-runtime.js +483 -0
- package/dist/app/managed-pi-source-admission.d.ts +14 -0
- package/dist/app/managed-pi-source-admission.js +323 -0
- package/dist/app/managed-pi-transition-host.d.ts +36 -0
- package/dist/app/managed-pi-transition-host.js +292 -0
- package/dist/app/managed-pi.d.ts +54 -0
- package/dist/app/managed-pi.js +248 -0
- package/dist/app/shell-readiness.d.ts +66 -0
- package/dist/app/shell-readiness.js +368 -0
- package/dist/app/task-pin.d.ts +2 -23
- package/dist/app/task-pin.js +2 -113
- package/dist/cli/commands/collaboration-binding.d.ts +13 -0
- package/dist/cli/commands/collaboration-binding.js +118 -0
- package/dist/cli/commands/collaboration-bootstrap.d.ts +10 -0
- package/dist/cli/commands/collaboration-bootstrap.js +84 -0
- package/dist/cli/commands/collaboration-capabilities.d.ts +13 -0
- package/dist/cli/commands/collaboration-capabilities.js +127 -0
- package/dist/cli/commands/collaboration-handoff.d.ts +13 -0
- package/dist/cli/commands/collaboration-handoff.js +102 -0
- package/dist/cli/commands/collaboration-message.d.ts +17 -0
- package/dist/cli/commands/collaboration-message.js +177 -0
- package/dist/cli/commands/collaboration-plan.d.ts +8 -0
- package/dist/cli/commands/collaboration-plan.js +108 -0
- package/dist/cli/commands/collaboration-preflight.d.ts +10 -0
- package/dist/cli/commands/collaboration-preflight.js +167 -0
- package/dist/cli/commands/collaboration-recovery-run.d.ts +46 -0
- package/dist/cli/commands/collaboration-recovery-run.js +224 -0
- package/dist/cli/commands/collaboration-recovery.d.ts +11 -0
- package/dist/cli/commands/collaboration-recovery.js +104 -0
- package/dist/cli/commands/collaboration-resources.d.ts +11 -0
- package/dist/cli/commands/collaboration-resources.js +96 -0
- package/dist/cli/commands/collaboration-role-plan.d.ts +9 -0
- package/dist/cli/commands/collaboration-role-plan.js +69 -0
- package/dist/cli/commands/collaboration-role-run.d.ts +11 -0
- package/dist/cli/commands/collaboration-role-run.js +128 -0
- package/dist/cli/commands/collaboration-rollover.d.ts +10 -0
- package/dist/cli/commands/collaboration-rollover.js +103 -0
- package/dist/cli/commands/collaboration-state.d.ts +10 -0
- package/dist/cli/commands/collaboration-state.js +96 -0
- package/dist/cli/commands/collaboration-transition.d.ts +10 -0
- package/dist/cli/commands/collaboration-transition.js +75 -0
- package/dist/cli/commands/collaboration-work-run.d.ts +34 -0
- package/dist/cli/commands/collaboration-work-run.js +178 -0
- package/dist/cli/commands/collaboration.d.ts +4 -0
- package/dist/cli/commands/collaboration.js +39 -4
- package/dist/cli/commands/continuity.js +2 -6
- package/dist/cli/commands/docs.d.ts +2 -0
- package/dist/cli/commands/docs.js +89 -15
- package/dist/cli/commands/host.d.ts +7 -1
- package/dist/cli/commands/host.js +122 -7
- package/dist/cli/commands/init.d.ts +20 -3
- package/dist/cli/commands/init.js +897 -232
- package/dist/cli/commands/mcp.d.ts +1 -0
- package/dist/cli/commands/mcp.js +11 -5
- package/dist/cli/commands/repair.d.ts +12 -0
- package/dist/cli/commands/repair.js +41 -0
- package/dist/cli/commands/runtime.d.ts +1 -1
- package/dist/cli/commands/runtime.js +139 -38
- package/dist/cli/commands/session.d.ts +7 -0
- package/dist/cli/commands/session.js +82 -0
- package/dist/cli/commands/task.js +19 -5
- package/dist/cli/run.js +866 -24
- package/dist/code/identity.d.ts +7 -0
- package/dist/code/identity.js +7 -3
- package/dist/code/registration.js +7 -0
- package/dist/code/service.d.ts +9 -0
- package/dist/code/service.js +16 -3
- package/dist/code/text.js +19 -7
- package/dist/collab/admission.d.ts +91 -66
- package/dist/collab/admission.js +21 -6
- package/dist/collab/canonical.d.ts +1 -1
- package/dist/collab/canonical.js +44 -3
- package/dist/collab/envelope.d.ts +2 -2
- package/dist/collab/envelope.js +12 -12
- package/dist/collab/first-work.d.ts +3 -0
- package/dist/collab/first-work.js +15 -0
- package/dist/collab/handoff.d.ts +16 -0
- package/dist/collab/handoff.js +39 -5
- package/dist/collab/index.d.ts +97 -1
- package/dist/collab/index.js +9 -1
- package/dist/collab/messages.js +126 -3
- package/dist/collab/persisted-messages.d.ts +123 -0
- package/dist/collab/persisted-messages.js +496 -0
- package/dist/collab/preflight.d.ts +1 -1
- package/dist/collab/resources.d.ts +13 -3
- package/dist/collab/resources.js +37 -6
- package/dist/collab/rollover.d.ts +311 -0
- package/dist/collab/rollover.js +1024 -0
- package/dist/collab/store.d.ts +1 -0
- package/dist/collab/store.js +1 -4
- package/dist/collab/types.d.ts +2 -354
- package/dist/collab/validation.d.ts +2 -10
- package/dist/collab/validation.js +2 -303
- package/dist/collab/work-plan.d.ts +105 -0
- package/dist/collab/work-plan.js +843 -0
- package/dist/continuity/context.d.ts +11 -2
- package/dist/continuity/context.js +100 -13
- package/dist/continuity/handoff-bundle.d.ts +296 -0
- package/dist/continuity/handoff-bundle.js +477 -0
- package/dist/continuity/index.d.ts +2 -0
- package/dist/continuity/index.js +1 -0
- package/dist/continuity/paths.js +10 -2
- package/dist/continuity/service.js +91 -15
- package/dist/continuity/task-pin.d.ts +24 -0
- package/dist/continuity/task-pin.js +590 -0
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +6 -0
- package/dist/experimental/index.d.ts +13 -0
- package/dist/experimental/index.js +25 -0
- package/dist/experimental/pi.d.ts +4 -0
- package/dist/experimental/pi.js +12 -0
- package/dist/hosts/claude/native.js +1 -1
- package/dist/hosts/codex/native.js +1 -1
- package/dist/hosts/codex/protocol.js +1 -1
- package/dist/hosts/integration/bootstrap-config.d.ts +58 -0
- package/dist/hosts/integration/bootstrap-config.js +254 -0
- package/dist/hosts/integration/bundle-export.d.ts +44 -0
- package/dist/hosts/integration/bundle-export.js +366 -3
- package/dist/hosts/integration/bundle.js +3 -0
- package/dist/hosts/integration/hook.js +36 -9
- package/dist/hosts/integration/pappra-bootstrap-extension.mjs +80 -0
- package/dist/hosts/integration/pappra-extension-runtime.mjs +59 -24631
- package/dist/hosts/integration/pappra-hook.mjs +61 -24749
- package/dist/hosts/integration/pappra-recovery-extension.mjs +259 -0
- package/dist/hosts/integration/pappra-session-transition-extension.mjs +276 -0
- package/dist/hosts/integration/pi-bootstrap-extension.d.ts +36 -0
- package/dist/hosts/integration/pi-bootstrap-extension.js +101 -0
- package/dist/hosts/integration/pi-bootstrap-guard.d.ts +125 -0
- package/dist/hosts/integration/pi-bootstrap-guard.js +438 -0
- package/dist/hosts/integration/pi-bootstrap-runtime.d.ts +63 -0
- package/dist/hosts/integration/pi-bootstrap-runtime.js +223 -0
- package/dist/hosts/integration/pi-native-events.d.ts +112 -0
- package/dist/hosts/integration/pi-native-events.js +348 -0
- package/dist/hosts/integration/pi-rollover-extension.d.ts +166 -0
- package/dist/hosts/integration/pi-rollover-extension.js +431 -0
- package/dist/hosts/integration/recovery-config.d.ts +48 -0
- package/dist/hosts/integration/recovery-config.js +117 -0
- package/dist/hosts/integration/service.d.ts +6 -1
- package/dist/hosts/integration/service.js +485 -35
- package/dist/hosts/integration/support.d.ts +7 -0
- package/dist/hosts/integration/support.js +9 -1
- package/dist/hosts/integration/templates.d.ts +3 -0
- package/dist/hosts/integration/templates.js +26 -3
- package/dist/hosts/pi/managed-host-identity.d.ts +2 -0
- package/dist/hosts/pi/managed-host-identity.js +40 -0
- package/dist/hosts/pi/managed-provider-diagnostics.d.ts +6 -0
- package/dist/hosts/pi/managed-provider-diagnostics.js +184 -0
- package/dist/hosts/pi/managed-sdk-session.d.ts +335 -0
- package/dist/hosts/pi/managed-sdk-session.js +1639 -0
- package/dist/hosts/pi/managed-session-extension.d.ts +20 -0
- package/dist/hosts/pi/managed-session-extension.js +84 -0
- package/dist/hosts/pi/managed-session.d.ts +177 -0
- package/dist/hosts/pi/managed-session.js +1557 -0
- package/dist/hosts/pi/protocol.js +1 -1
- package/dist/hosts/pi/query-result.js +1 -1
- package/dist/hosts/pi/resume-capability.d.ts +41 -0
- package/dist/hosts/pi/resume-capability.js +33 -0
- package/dist/hosts/pi/rpc.d.ts +182 -134
- package/dist/hosts/pi/rpc.js +230 -37
- package/dist/hosts/pilot.d.ts +97 -74
- package/dist/hosts/pilot.js +5 -1
- package/dist/hosts/version.d.ts +3 -0
- package/dist/hosts/version.js +18 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/infra/bounded-file.d.ts +19 -0
- package/dist/infra/bounded-file.js +66 -0
- package/dist/infra/fs.js +10 -2
- package/dist/infra/git.d.ts +29 -0
- package/dist/infra/git.js +45 -9
- package/dist/infra/json-property.d.ts +2 -0
- package/dist/infra/json-property.js +69 -0
- package/dist/infra/json.d.ts +4 -0
- package/dist/infra/json.js +24 -0
- package/dist/infra/owned-process-group.js +1 -1
- package/dist/infra/private-lock.d.ts +16 -0
- package/dist/infra/private-lock.js +148 -0
- package/dist/infra/private-state.d.ts +7 -0
- package/dist/infra/private-state.js +146 -0
- package/dist/infra/process-identity.d.ts +12 -0
- package/dist/infra/process-identity.js +142 -0
- package/dist/infra/validation.d.ts +5 -0
- package/dist/infra/validation.js +15 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +6 -0
- package/dist/install/apply.d.ts +9 -1
- package/dist/install/apply.js +176 -12
- package/dist/install/dependencies.d.ts +38 -0
- package/dist/install/dependencies.js +282 -0
- package/dist/install/ownership.js +30 -1
- package/dist/install/references.d.ts +31 -0
- package/dist/install/references.js +208 -0
- package/dist/install/repair.d.ts +37 -0
- package/dist/install/repair.js +798 -0
- package/dist/install/reset-local.d.ts +16 -0
- package/dist/install/reset-local.js +304 -0
- package/dist/install/rollback.d.ts +3 -0
- package/dist/install/rollback.js +9 -3
- package/dist/install/runtime.d.ts +10 -6
- package/dist/install/runtime.js +299 -59
- package/dist/install/source-fingerprint.d.ts +2 -0
- package/dist/install/source-fingerprint.js +103 -0
- package/dist/knowledge/catalog.d.ts +1 -0
- package/dist/knowledge/catalog.js +143 -10
- package/dist/knowledge/journal.js +17 -7
- package/dist/knowledge/paths.js +2 -1
- package/dist/knowledge/refs.js +4 -0
- package/dist/knowledge/register-batch.d.ts +4 -0
- package/dist/knowledge/register-batch.js +225 -0
- package/dist/knowledge/relocate.d.ts +5 -0
- package/dist/knowledge/relocate.js +92 -0
- package/dist/knowledge/scan.js +103 -47
- package/dist/knowledge/service.d.ts +3 -0
- package/dist/knowledge/service.js +33 -1
- package/dist/knowledge/write.d.ts +4 -1
- package/dist/knowledge/write.js +1 -1
- package/dist/mcp/server.d.ts +4 -2
- package/dist/mcp/server.js +87 -136
- package/dist/schemas/collaboration-state-validation.d.ts +6 -0
- package/dist/schemas/collaboration-state-validation.js +342 -0
- package/dist/schemas/collaboration-state.d.ts +436 -0
- package/dist/schemas/collaboration-state.js +1 -0
- package/dist/schemas/collaboration.d.ts +335 -0
- package/dist/schemas/collaboration.js +272 -0
- package/dist/schemas/continuity.d.ts +8 -0
- package/dist/schemas/continuity.js +13 -3
- package/dist/schemas/doctor.d.ts +1 -0
- package/dist/schemas/doctor.js +1 -0
- package/dist/schemas/document.d.ts +61 -0
- package/dist/schemas/document.js +9 -0
- package/dist/schemas/errors.d.ts +1 -0
- package/dist/schemas/errors.js +1 -0
- package/dist/schemas/host.d.ts +24 -0
- package/dist/schemas/host.js +19 -0
- package/dist/schemas/install.d.ts +8 -2
- package/dist/schemas/install.js +11 -1
- package/dist/schemas/managed-pi-execution.d.ts +16 -0
- package/dist/schemas/managed-pi-execution.js +2 -0
- package/dist/schemas/outcome.d.ts +9 -5
- package/dist/workspace/config-file.js +1 -3
- package/dist/workspace/documents.js +12 -2
- package/dist/workspace/repositories.d.ts +7 -0
- package/dist/workspace/repositories.js +14 -5
- package/package.json +35 -35
- package/skills/pappra/SKILL.md +28 -7
- package/skills/pappra/references/collaboration.md +633 -0
- package/skills/pappra/references/environment.md +360 -0
- package/skills/pappra/references/hosts.md +20 -0
- package/skills/pappra/references/operations.md +36 -5
- package/dist/app/collab-from.d.ts +0 -35
- package/dist/app/collab-from.js +0 -84
|
@@ -0,0 +1,3455 @@
|
|
|
1
|
+
import { object as isRecord } from '../infra/validation.js';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { captureProcessIdentity, observeProcessIdentity, } from '../infra/process-identity.js';
|
|
4
|
+
import { readManagedSourceAssignment } from './managed-pi-source-admission.js';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { isAssignment, isCard, } from '../collab/contracts.js';
|
|
9
|
+
import { hasPendingCancellation } from '../collab/admission.js';
|
|
10
|
+
import { findAttempt } from '../collab/lifecycle.js';
|
|
11
|
+
import { transact } from '../collab/store.js';
|
|
12
|
+
import { collabBindingSchema, } from '../schemas/collaboration.js';
|
|
13
|
+
import { isPathInside } from '../schemas/paths.js';
|
|
14
|
+
import { getPackageRoot } from '../package-info.js';
|
|
15
|
+
import { readPiBootstrapConfig, } from '../hosts/integration/bootstrap-config.js';
|
|
16
|
+
import { readBoundedFile } from './bounded-file.js';
|
|
17
|
+
import { runCollaborationPreflight } from './collab-preflight.js';
|
|
18
|
+
import { MANAGED_PI_REQUEST_SCHEMA } from './managed-pi.js';
|
|
19
|
+
import { assertMessageTaskCurrent, openMessageTask, readRegisteredMessageSafe, } from './collab-messages.js';
|
|
20
|
+
import { notifyRegisteredMessage, reportedSessionIdentity, } from './collab-notify.js';
|
|
21
|
+
import { acceptedManagedSdkStartReceipt, managedSdkStartArgs, createHerdrBootstrapOps, } from './collab-bootstrap-transport.js';
|
|
22
|
+
import { verifyAvailableShell, } from './shell-readiness.js';
|
|
23
|
+
export const COLLAB_BOOTSTRAP_REQUEST_SCHEMA = 'pappra/collab-bootstrap-request/1';
|
|
24
|
+
export const COLLAB_BOOTSTRAP_REPORT_SCHEMA = 'pappra/collab-bootstrap-report/1';
|
|
25
|
+
const MAX_REQUEST_BYTES = 64 * 1024;
|
|
26
|
+
const MAX_ASSIGNMENT_BYTES = 1024 * 1024;
|
|
27
|
+
const MAX_BINDING_BYTES = 64 * 1024;
|
|
28
|
+
const MAX_CARD_BYTES = 1024 * 1024;
|
|
29
|
+
const MAX_BUNDLE_FILES = 512;
|
|
30
|
+
const MAX_BUNDLE_FILE_BYTES = 8 * 1024 * 1024;
|
|
31
|
+
const MAX_BUNDLE_TOTAL_BYTES = 32 * 1024 * 1024;
|
|
32
|
+
const MAX_RECEIPTS = 12;
|
|
33
|
+
const READ_TIMEOUT_MS = 10_000;
|
|
34
|
+
const START_TIMEOUT_MS = 180_000;
|
|
35
|
+
const MAX_START_TIMEOUT_MS = 600_000;
|
|
36
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
37
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
38
|
+
const PANE_ID = /^[A-Za-z0-9]+:p[A-Za-z0-9]+$/;
|
|
39
|
+
const HERDR_AGENT_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
40
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
41
|
+
const SHELL_NAMES = new Set([
|
|
42
|
+
'sh',
|
|
43
|
+
'bash',
|
|
44
|
+
'dash',
|
|
45
|
+
'zsh',
|
|
46
|
+
'fish',
|
|
47
|
+
'ksh',
|
|
48
|
+
'mksh',
|
|
49
|
+
'csh',
|
|
50
|
+
'tcsh',
|
|
51
|
+
'elvish',
|
|
52
|
+
'xonsh',
|
|
53
|
+
'nu',
|
|
54
|
+
'pwsh',
|
|
55
|
+
'powershell',
|
|
56
|
+
'cmd',
|
|
57
|
+
]);
|
|
58
|
+
const BOOTSTRAP_ENTRY_SOURCE = `/**
|
|
59
|
+
* Generated Pappra Pi bootstrap extension. It registers the accepted default-deny
|
|
60
|
+
* successor gate from a private relative configuration; it installs, trusts, launches
|
|
61
|
+
* and dispatches nothing by itself.
|
|
62
|
+
*/
|
|
63
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
64
|
+
import bootstrapExtension from "./runtime.mjs";
|
|
65
|
+
|
|
66
|
+
export default function (pi: ExtensionAPI): void {
|
|
67
|
+
bootstrapExtension(pi);
|
|
68
|
+
}`;
|
|
69
|
+
function isText(value, max = 4096) {
|
|
70
|
+
return typeof value === 'string' && value.length > 0 && value.length <= max;
|
|
71
|
+
}
|
|
72
|
+
function hasOnlyKeys(value, keys) {
|
|
73
|
+
const allowed = new Set(keys);
|
|
74
|
+
return Object.keys(value).every((key) => allowed.has(key));
|
|
75
|
+
}
|
|
76
|
+
function digestText(value) {
|
|
77
|
+
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
|
78
|
+
}
|
|
79
|
+
function safePathEqual(left, right) {
|
|
80
|
+
try {
|
|
81
|
+
return fs.realpathSync(left) === fs.realpathSync(right);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function hasUnsafePathCharacters(value) {
|
|
88
|
+
return [...value].some((character) => {
|
|
89
|
+
const code = character.charCodeAt(0);
|
|
90
|
+
return code === 0 || code === 10 || code === 13;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function isPrivateRealDirectory(directory) {
|
|
94
|
+
try {
|
|
95
|
+
const stat = fs.lstatSync(directory);
|
|
96
|
+
return (stat.isDirectory() &&
|
|
97
|
+
!stat.isSymbolicLink() &&
|
|
98
|
+
fs.realpathSync(directory) === directory &&
|
|
99
|
+
(stat.mode & 0o077) === 0 &&
|
|
100
|
+
(typeof process.getuid !== 'function' || stat.uid === process.getuid()));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function isAbsentPath(candidate) {
|
|
107
|
+
try {
|
|
108
|
+
fs.lstatSync(candidate);
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
return isRecord(error) && error['code'] === 'ENOENT';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function managedRuntimePathsFor(request) {
|
|
116
|
+
const runtime = request.managed_runtime;
|
|
117
|
+
if (runtime === undefined)
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
code: 'managed_runtime_missing',
|
|
121
|
+
message: 'Managed runtime is not selected.',
|
|
122
|
+
};
|
|
123
|
+
const model = request.selected_model.openrouter_model;
|
|
124
|
+
if (request.target.choice !== 'sibling' ||
|
|
125
|
+
typeof model !== 'string' ||
|
|
126
|
+
!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(model))
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
code: 'managed_runtime_invalid',
|
|
130
|
+
message: 'Managed SDK launch requires a fresh sibling and an explicit OpenRouter model.',
|
|
131
|
+
};
|
|
132
|
+
const { sdk_module_path: sdkPath, journal_path: journalPath } = runtime;
|
|
133
|
+
if (!path.isAbsolute(sdkPath) ||
|
|
134
|
+
!path.isAbsolute(journalPath) ||
|
|
135
|
+
path.resolve(sdkPath) !== sdkPath ||
|
|
136
|
+
path.resolve(journalPath) !== journalPath ||
|
|
137
|
+
hasUnsafePathCharacters(sdkPath) ||
|
|
138
|
+
hasUnsafePathCharacters(journalPath))
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
code: 'managed_path_invalid',
|
|
142
|
+
message: 'SDK and journal paths must be normalized absolute paths.',
|
|
143
|
+
};
|
|
144
|
+
let sdkModulePath;
|
|
145
|
+
try {
|
|
146
|
+
const sdkStat = fs.lstatSync(sdkPath);
|
|
147
|
+
if (!sdkStat.isFile() || sdkStat.isSymbolicLink())
|
|
148
|
+
throw new Error('sdk_not_regular');
|
|
149
|
+
sdkModulePath = fs.realpathSync(sdkPath);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
code: 'managed_sdk_module_invalid',
|
|
155
|
+
message: 'Pinned SDK entry must resolve to a regular file before any native effect.',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const parent = path.dirname(journalPath);
|
|
159
|
+
if (!isPrivateRealDirectory(parent))
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
code: 'managed_journal_parent_not_private',
|
|
163
|
+
message: 'Journal parent must already be a private, real, current-user directory.',
|
|
164
|
+
};
|
|
165
|
+
const requestHash = crypto
|
|
166
|
+
.createHash('sha256')
|
|
167
|
+
.update(JSON.stringify([request.task_root, request.request_id]))
|
|
168
|
+
.digest('hex');
|
|
169
|
+
const configPath = path.join(parent, `.pappra-managed-sdk-${requestHash.slice(0, 20)}.json`);
|
|
170
|
+
let socketDirectory;
|
|
171
|
+
try {
|
|
172
|
+
socketDirectory = fs.realpathSync(process.platform === 'darwin' ? '/private/tmp' : os.tmpdir());
|
|
173
|
+
if (!fs.statSync(socketDirectory).isDirectory())
|
|
174
|
+
throw new Error('control_socket_directory_invalid');
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
code: 'managed_control_socket_directory_unavailable',
|
|
180
|
+
message: 'Managed SDK control socket directory is unavailable.',
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const controlSocketPath = path.join(socketDirectory, `.pappra-sdk-${requestHash.slice(0, 16)}.sock`);
|
|
184
|
+
if (Buffer.byteLength(controlSocketPath) > 100)
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
code: 'managed_control_socket_path_too_long',
|
|
188
|
+
message: 'Managed SDK control socket path exceeds the local Unix socket limit.',
|
|
189
|
+
};
|
|
190
|
+
const handoffContextFile = runtime.handoff_context_file;
|
|
191
|
+
const paths = [
|
|
192
|
+
journalPath,
|
|
193
|
+
`${journalPath}.pi-events.jsonl`,
|
|
194
|
+
`${journalPath}.control.json`,
|
|
195
|
+
configPath,
|
|
196
|
+
controlSocketPath,
|
|
197
|
+
];
|
|
198
|
+
if (paths.some((candidate) => !isAbsentPath(candidate)) ||
|
|
199
|
+
[configPath, controlSocketPath, handoffContextFile]
|
|
200
|
+
.filter((candidate) => candidate !== undefined)
|
|
201
|
+
.some((candidate) => candidate === sdkModulePath) ||
|
|
202
|
+
(runtime.automatic_handoff_file !== undefined &&
|
|
203
|
+
(!path.isAbsolute(runtime.automatic_handoff_file) ||
|
|
204
|
+
hasUnsafePathCharacters(runtime.automatic_handoff_file) ||
|
|
205
|
+
[
|
|
206
|
+
sdkModulePath,
|
|
207
|
+
journalPath,
|
|
208
|
+
`${journalPath}.pi-events.jsonl`,
|
|
209
|
+
`${journalPath}.control.json`,
|
|
210
|
+
configPath,
|
|
211
|
+
controlSocketPath,
|
|
212
|
+
handoffContextFile,
|
|
213
|
+
]
|
|
214
|
+
.filter((candidate) => candidate !== undefined)
|
|
215
|
+
.includes(path.resolve(runtime.automatic_handoff_file)))) ||
|
|
216
|
+
(handoffContextFile !== undefined &&
|
|
217
|
+
(!path.isAbsolute(handoffContextFile) ||
|
|
218
|
+
path.resolve(handoffContextFile) !== handoffContextFile ||
|
|
219
|
+
hasUnsafePathCharacters(handoffContextFile) ||
|
|
220
|
+
!isPrivateRealDirectory(path.dirname(handoffContextFile)) ||
|
|
221
|
+
[
|
|
222
|
+
sdkModulePath,
|
|
223
|
+
journalPath,
|
|
224
|
+
`${journalPath}.pi-events.jsonl`,
|
|
225
|
+
`${journalPath}.control.json`,
|
|
226
|
+
configPath,
|
|
227
|
+
controlSocketPath,
|
|
228
|
+
].includes(handoffContextFile))))
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
code: 'managed_output_path_unavailable',
|
|
232
|
+
message: 'Managed journal, config, metadata, handoff or control paths are not fresh/private.',
|
|
233
|
+
};
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
paths: {
|
|
237
|
+
sdkModulePath,
|
|
238
|
+
journalPath,
|
|
239
|
+
configPath,
|
|
240
|
+
controlSocketPath,
|
|
241
|
+
...(runtime.automatic_handoff_file === undefined
|
|
242
|
+
? {}
|
|
243
|
+
: { automaticHandoffFile: runtime.automatic_handoff_file }),
|
|
244
|
+
...(handoffContextFile === undefined ? {} : { handoffContextFile }),
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function writeManagedSdkConfig(request, context, paneId, paths) {
|
|
249
|
+
const current = managedRuntimePathsFor(request);
|
|
250
|
+
if (!current.ok || current.paths.configPath !== paths.configPath)
|
|
251
|
+
return {
|
|
252
|
+
ok: false,
|
|
253
|
+
code: current.ok ? 'managed_path_changed' : current.code,
|
|
254
|
+
};
|
|
255
|
+
const model = request.selected_model.openrouter_model;
|
|
256
|
+
if (model === undefined)
|
|
257
|
+
return { ok: false, code: 'managed_model_missing' };
|
|
258
|
+
const value = {
|
|
259
|
+
schema: MANAGED_PI_REQUEST_SCHEMA,
|
|
260
|
+
cwd: request.target.cwd,
|
|
261
|
+
model,
|
|
262
|
+
journalPath: paths.journalPath,
|
|
263
|
+
controlSocketPath: paths.controlSocketPath,
|
|
264
|
+
runtime: 'sdk',
|
|
265
|
+
sdkModulePath: paths.sdkModulePath,
|
|
266
|
+
sessionId: request.participant.native_session_selector,
|
|
267
|
+
bootstrapExtensionPath: request.bootstrap.extension_entry,
|
|
268
|
+
herdrAgentName: context.herdrAgent,
|
|
269
|
+
herdrPaneId: paneId,
|
|
270
|
+
...(paths.automaticHandoffFile === undefined
|
|
271
|
+
? {}
|
|
272
|
+
: { automaticHandoffFile: paths.automaticHandoffFile }),
|
|
273
|
+
...(paths.handoffContextFile === undefined
|
|
274
|
+
? {}
|
|
275
|
+
: { handoffContextFile: paths.handoffContextFile }),
|
|
276
|
+
};
|
|
277
|
+
const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
278
|
+
if (bytes.byteLength > MAX_REQUEST_BYTES)
|
|
279
|
+
return { ok: false, code: 'managed_config_too_large' };
|
|
280
|
+
let descriptor;
|
|
281
|
+
let identity;
|
|
282
|
+
try {
|
|
283
|
+
if (!isPrivateRealDirectory(path.dirname(paths.configPath)))
|
|
284
|
+
return { ok: false, code: 'managed_journal_parent_not_private' };
|
|
285
|
+
descriptor = fs.openSync(paths.configPath, 'wx', 0o600);
|
|
286
|
+
fs.fchmodSync(descriptor, 0o400);
|
|
287
|
+
const stat = fs.fstatSync(descriptor);
|
|
288
|
+
identity = { dev: stat.dev, ino: stat.ino };
|
|
289
|
+
fs.writeFileSync(descriptor, bytes);
|
|
290
|
+
fs.fsyncSync(descriptor);
|
|
291
|
+
fs.closeSync(descriptor);
|
|
292
|
+
descriptor = undefined;
|
|
293
|
+
const directory = fs.openSync(path.dirname(paths.configPath), 'r');
|
|
294
|
+
try {
|
|
295
|
+
fs.fsyncSync(directory);
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
fs.closeSync(directory);
|
|
299
|
+
}
|
|
300
|
+
const written = fs.lstatSync(paths.configPath);
|
|
301
|
+
const read = readBoundedFile(paths.configPath, MAX_REQUEST_BYTES);
|
|
302
|
+
if (written.isSymbolicLink() ||
|
|
303
|
+
!written.isFile() ||
|
|
304
|
+
(written.mode & 0o777) !== 0o400 ||
|
|
305
|
+
(typeof process.getuid === 'function' &&
|
|
306
|
+
written.uid !== process.getuid()) ||
|
|
307
|
+
written.dev !== identity.dev ||
|
|
308
|
+
written.ino !== identity.ino ||
|
|
309
|
+
!read.ok ||
|
|
310
|
+
!read.bytes.equals(bytes))
|
|
311
|
+
throw new Error('managed_config_verification_failed');
|
|
312
|
+
return { ok: true, digest: read.digest };
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
if (descriptor !== undefined) {
|
|
316
|
+
try {
|
|
317
|
+
fs.closeSync(descriptor);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
/* best effort */
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (identity !== undefined) {
|
|
324
|
+
try {
|
|
325
|
+
const current = fs.lstatSync(paths.configPath);
|
|
326
|
+
if (current.isFile() &&
|
|
327
|
+
!current.isSymbolicLink() &&
|
|
328
|
+
current.dev === identity.dev &&
|
|
329
|
+
current.ino === identity.ino)
|
|
330
|
+
fs.unlinkSync(paths.configPath);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
/* preserve unrelated replacements */
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return { ok: false, code: 'managed_config_write_failed' };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function sameDirectoryIdentity(expected, candidate) {
|
|
340
|
+
try {
|
|
341
|
+
const stat = fs.lstatSync(candidate);
|
|
342
|
+
return (!stat.isSymbolicLink() &&
|
|
343
|
+
stat.isDirectory() &&
|
|
344
|
+
fs.realpathSync(candidate) === expected.path &&
|
|
345
|
+
stat.dev === expected.dev &&
|
|
346
|
+
stat.ino === expected.ino);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function sameFileIdentity(expected) {
|
|
353
|
+
try {
|
|
354
|
+
const stat = fs.lstatSync(expected.path);
|
|
355
|
+
return (!stat.isSymbolicLink() &&
|
|
356
|
+
stat.isFile() &&
|
|
357
|
+
fs.realpathSync(expected.path) === expected.path &&
|
|
358
|
+
stat.dev === expected.dev &&
|
|
359
|
+
stat.ino === expected.ino);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function sameIdentity(left, right) {
|
|
366
|
+
return Boolean(left &&
|
|
367
|
+
right &&
|
|
368
|
+
left.agent_id === right.agent_id &&
|
|
369
|
+
left.session_id === right.session_id &&
|
|
370
|
+
left.harness === right.harness);
|
|
371
|
+
}
|
|
372
|
+
function generatedHerdrAgent(requestId) {
|
|
373
|
+
return `pappra-${crypto.createHash('sha256').update(requestId).digest('hex').slice(0, 8)}`;
|
|
374
|
+
}
|
|
375
|
+
function requestOf(value) {
|
|
376
|
+
return 'request' in value ? value.request : value;
|
|
377
|
+
}
|
|
378
|
+
function requestDigestOf(value) {
|
|
379
|
+
return 'request' in value ? value.digest : digestText(JSON.stringify(value));
|
|
380
|
+
}
|
|
381
|
+
function requestFileOf(value) {
|
|
382
|
+
return 'request' in value ? value.file : null;
|
|
383
|
+
}
|
|
384
|
+
function failureReport(action, request, requestDigest, status, code, message, nextAction = null, extra = {}) {
|
|
385
|
+
return {
|
|
386
|
+
schema: COLLAB_BOOTSTRAP_REPORT_SCHEMA,
|
|
387
|
+
action,
|
|
388
|
+
ok: false,
|
|
389
|
+
status,
|
|
390
|
+
code,
|
|
391
|
+
message,
|
|
392
|
+
next_action: nextAction,
|
|
393
|
+
request_id: request?.request_id ?? null,
|
|
394
|
+
request_digest: requestDigest,
|
|
395
|
+
task_id: request === null ? null : path.basename(request.task_root),
|
|
396
|
+
task_root: request?.task_root ?? null,
|
|
397
|
+
state_revision: null,
|
|
398
|
+
caller: null,
|
|
399
|
+
participant: request === null
|
|
400
|
+
? null
|
|
401
|
+
: {
|
|
402
|
+
agent_id: request.participant.agent_id,
|
|
403
|
+
harness: request.participant.harness,
|
|
404
|
+
session_id: request.participant.native_session_selector,
|
|
405
|
+
},
|
|
406
|
+
target: request === null ? null : { ...request.target },
|
|
407
|
+
bootstrap: null,
|
|
408
|
+
reservation: null,
|
|
409
|
+
readiness: null,
|
|
410
|
+
effects: [],
|
|
411
|
+
notification: null,
|
|
412
|
+
downstream: {
|
|
413
|
+
host_stop_proof: 'unavailable',
|
|
414
|
+
first_work: 'unverified',
|
|
415
|
+
routing_removal: 'pending',
|
|
416
|
+
native_archive: 'unavailable',
|
|
417
|
+
},
|
|
418
|
+
limits: [
|
|
419
|
+
'capability_cards_are_readiness_not_authority',
|
|
420
|
+
'notification_submission_is_not_consumption_or_task_acceptance',
|
|
421
|
+
'native_stop_proof_and_full_takeover_are_out_of_scope',
|
|
422
|
+
],
|
|
423
|
+
...extra,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
function successReport(action, request, requestDigest, status, extra = {}) {
|
|
427
|
+
return {
|
|
428
|
+
schema: COLLAB_BOOTSTRAP_REPORT_SCHEMA,
|
|
429
|
+
action,
|
|
430
|
+
ok: status === 'planned' ||
|
|
431
|
+
status === 'applied' ||
|
|
432
|
+
status === 'already_recorded',
|
|
433
|
+
status,
|
|
434
|
+
code: null,
|
|
435
|
+
message: null,
|
|
436
|
+
next_action: null,
|
|
437
|
+
request_id: request.request_id,
|
|
438
|
+
request_digest: requestDigest,
|
|
439
|
+
task_id: path.basename(request.task_root),
|
|
440
|
+
task_root: request.task_root,
|
|
441
|
+
state_revision: null,
|
|
442
|
+
caller: null,
|
|
443
|
+
participant: {
|
|
444
|
+
agent_id: request.participant.agent_id,
|
|
445
|
+
harness: request.participant.harness,
|
|
446
|
+
session_id: request.participant.native_session_selector,
|
|
447
|
+
},
|
|
448
|
+
target: { ...request.target },
|
|
449
|
+
bootstrap: null,
|
|
450
|
+
reservation: null,
|
|
451
|
+
readiness: null,
|
|
452
|
+
effects: [],
|
|
453
|
+
notification: null,
|
|
454
|
+
downstream: {
|
|
455
|
+
host_stop_proof: 'unavailable',
|
|
456
|
+
first_work: 'unverified',
|
|
457
|
+
routing_removal: 'pending',
|
|
458
|
+
native_archive: 'unavailable',
|
|
459
|
+
},
|
|
460
|
+
limits: [
|
|
461
|
+
'new-session-registration_and_capabilities_are_not_independently_proven',
|
|
462
|
+
'notification_submission_is_not_consumption_or_task_acceptance',
|
|
463
|
+
'host_stop_proof_and_full_takeover_are_out_of_scope',
|
|
464
|
+
],
|
|
465
|
+
...extra,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
export function validateVisibleBootstrapRequest(value) {
|
|
469
|
+
const topKeys = [
|
|
470
|
+
'schema',
|
|
471
|
+
'action',
|
|
472
|
+
'request_id',
|
|
473
|
+
'task_root',
|
|
474
|
+
'admission',
|
|
475
|
+
'caller',
|
|
476
|
+
'participant',
|
|
477
|
+
'target',
|
|
478
|
+
'selected_model',
|
|
479
|
+
'bootstrap',
|
|
480
|
+
'message_pointer',
|
|
481
|
+
'binding_ref',
|
|
482
|
+
'readiness',
|
|
483
|
+
'managed_runtime',
|
|
484
|
+
];
|
|
485
|
+
if (!isRecord(value) || !hasOnlyKeys(value, topKeys))
|
|
486
|
+
return {
|
|
487
|
+
ok: false,
|
|
488
|
+
code: 'request_shape_invalid',
|
|
489
|
+
message: 'Request must be an object with only the documented fields.',
|
|
490
|
+
};
|
|
491
|
+
if (value['schema'] !== COLLAB_BOOTSTRAP_REQUEST_SCHEMA)
|
|
492
|
+
return {
|
|
493
|
+
ok: false,
|
|
494
|
+
code: 'request_schema_invalid',
|
|
495
|
+
message: `request.schema must be ${COLLAB_BOOTSTRAP_REQUEST_SCHEMA}.`,
|
|
496
|
+
};
|
|
497
|
+
if (value['action'] !== undefined && value['action'] !== 'apply')
|
|
498
|
+
return {
|
|
499
|
+
ok: false,
|
|
500
|
+
code: 'request_action_invalid',
|
|
501
|
+
message: 'Request action, when present, must be apply; the CLI action is authoritative.',
|
|
502
|
+
};
|
|
503
|
+
const requestId = value['request_id'];
|
|
504
|
+
const taskRoot = value['task_root'];
|
|
505
|
+
if (!isText(requestId, 128) || !SAFE_ID.test(requestId))
|
|
506
|
+
return {
|
|
507
|
+
ok: false,
|
|
508
|
+
code: 'request_id_invalid',
|
|
509
|
+
message: 'request_id must be a safe short idempotency key.',
|
|
510
|
+
};
|
|
511
|
+
if (!isText(taskRoot, 4096) || !path.isAbsolute(taskRoot))
|
|
512
|
+
return {
|
|
513
|
+
ok: false,
|
|
514
|
+
code: 'task_root_invalid',
|
|
515
|
+
message: 'task_root must be an absolute path.',
|
|
516
|
+
};
|
|
517
|
+
const admission = value['admission'];
|
|
518
|
+
if (!isRecord(admission) ||
|
|
519
|
+
!hasOnlyKeys(admission, [
|
|
520
|
+
'assignment_id',
|
|
521
|
+
'subtask_id',
|
|
522
|
+
'attempt',
|
|
523
|
+
'input_revision',
|
|
524
|
+
'capability_card_ref',
|
|
525
|
+
'capability_card_digest',
|
|
526
|
+
]) ||
|
|
527
|
+
!isText(admission['assignment_id'], 128) ||
|
|
528
|
+
!SAFE_ID.test(admission['assignment_id']) ||
|
|
529
|
+
!isText(admission['subtask_id'], 128) ||
|
|
530
|
+
!SAFE_ID.test(admission['subtask_id']) ||
|
|
531
|
+
typeof admission['attempt'] !== 'number' ||
|
|
532
|
+
!Number.isSafeInteger(admission['attempt']) ||
|
|
533
|
+
admission['attempt'] < 1 ||
|
|
534
|
+
!isText(admission['input_revision'], 256) ||
|
|
535
|
+
!isText(admission['capability_card_ref'], 4096) ||
|
|
536
|
+
!path.isAbsolute(admission['capability_card_ref']) ||
|
|
537
|
+
!isText(admission['capability_card_digest'], 71) ||
|
|
538
|
+
!DIGEST.test(admission['capability_card_digest']))
|
|
539
|
+
return {
|
|
540
|
+
ok: false,
|
|
541
|
+
code: 'admission_invalid',
|
|
542
|
+
message: 'admission must pin one exact attempt, input revision and absolute capability-card file/digest.',
|
|
543
|
+
};
|
|
544
|
+
const caller = value['caller'];
|
|
545
|
+
if (!isRecord(caller) ||
|
|
546
|
+
!hasOnlyKeys(caller, ['require_herdr_env', 'pane_id']) ||
|
|
547
|
+
caller['require_herdr_env'] !== true ||
|
|
548
|
+
!(caller['pane_id'] === undefined ||
|
|
549
|
+
caller['pane_id'] === null ||
|
|
550
|
+
(isText(caller['pane_id'], 64) && PANE_ID.test(caller['pane_id']))))
|
|
551
|
+
return {
|
|
552
|
+
ok: false,
|
|
553
|
+
code: 'caller_invalid',
|
|
554
|
+
message: 'caller must require Herdr and may name only an explicit caller pane.',
|
|
555
|
+
};
|
|
556
|
+
const participant = value['participant'];
|
|
557
|
+
if (!isRecord(participant) ||
|
|
558
|
+
!hasOnlyKeys(participant, [
|
|
559
|
+
'agent_id',
|
|
560
|
+
'harness',
|
|
561
|
+
'native_session_selector',
|
|
562
|
+
'herdr_agent',
|
|
563
|
+
'capability_card_ref',
|
|
564
|
+
'capability_card_digest',
|
|
565
|
+
]) ||
|
|
566
|
+
!isText(participant['agent_id'], 128) ||
|
|
567
|
+
participant['harness'] !== 'pi' ||
|
|
568
|
+
!isText(participant['native_session_selector'], 256) ||
|
|
569
|
+
!UUID.test(participant['native_session_selector']) ||
|
|
570
|
+
(participant['herdr_agent'] !== undefined &&
|
|
571
|
+
(!isText(participant['herdr_agent'], 32) ||
|
|
572
|
+
!HERDR_AGENT_NAME.test(participant['herdr_agent']))) ||
|
|
573
|
+
(participant['capability_card_ref'] !== undefined) !==
|
|
574
|
+
(participant['capability_card_digest'] !== undefined) ||
|
|
575
|
+
(participant['capability_card_ref'] !== undefined &&
|
|
576
|
+
(!isText(participant['capability_card_ref'], 4096) ||
|
|
577
|
+
!path.isAbsolute(participant['capability_card_ref']) ||
|
|
578
|
+
!isText(participant['capability_card_digest'], 71) ||
|
|
579
|
+
!DIGEST.test(participant['capability_card_digest']))))
|
|
580
|
+
return {
|
|
581
|
+
ok: false,
|
|
582
|
+
code: 'participant_invalid',
|
|
583
|
+
message: 'participant must name a Pi identity with a fixed native session UUID.',
|
|
584
|
+
};
|
|
585
|
+
const target = value['target'];
|
|
586
|
+
if (!isRecord(target) ||
|
|
587
|
+
!hasOnlyKeys(target, [
|
|
588
|
+
'choice',
|
|
589
|
+
'cwd',
|
|
590
|
+
'direction',
|
|
591
|
+
'preserve_focus',
|
|
592
|
+
'start_timeout_ms',
|
|
593
|
+
]) ||
|
|
594
|
+
(target['choice'] !== 'reuse' && target['choice'] !== 'sibling') ||
|
|
595
|
+
!isText(target['cwd'], 4096) ||
|
|
596
|
+
!path.isAbsolute(target['cwd']) ||
|
|
597
|
+
target['preserve_focus'] !== true ||
|
|
598
|
+
(target['start_timeout_ms'] !== undefined &&
|
|
599
|
+
(!Number.isSafeInteger(target['start_timeout_ms']) ||
|
|
600
|
+
Number(target['start_timeout_ms']) < 1 ||
|
|
601
|
+
Number(target['start_timeout_ms']) > MAX_START_TIMEOUT_MS)) ||
|
|
602
|
+
(target['direction'] !== undefined &&
|
|
603
|
+
target['direction'] !== 'right' &&
|
|
604
|
+
target['direction'] !== 'down'))
|
|
605
|
+
return {
|
|
606
|
+
ok: false,
|
|
607
|
+
code: 'target_invalid',
|
|
608
|
+
message: 'target must explicitly choose reuse or sibling, provide an absolute cwd and preserve focus.',
|
|
609
|
+
};
|
|
610
|
+
const selectedModel = value['selected_model'];
|
|
611
|
+
if (!isRecord(selectedModel) ||
|
|
612
|
+
!hasOnlyKeys(selectedModel, ['preserve_daily', 'openrouter_model']) ||
|
|
613
|
+
selectedModel['preserve_daily'] !== true ||
|
|
614
|
+
(selectedModel['openrouter_model'] !== undefined &&
|
|
615
|
+
(target['choice'] !== 'sibling' ||
|
|
616
|
+
typeof selectedModel['openrouter_model'] !== 'string' ||
|
|
617
|
+
!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(selectedModel['openrouter_model']))))
|
|
618
|
+
return {
|
|
619
|
+
ok: false,
|
|
620
|
+
code: 'model_override_refused',
|
|
621
|
+
message: 'Preserve daily settings; an explicit OpenRouter model requires a fresh sibling launch.',
|
|
622
|
+
};
|
|
623
|
+
const managedRuntime = value['managed_runtime'];
|
|
624
|
+
if (managedRuntime !== undefined &&
|
|
625
|
+
(!isRecord(managedRuntime) ||
|
|
626
|
+
!hasOnlyKeys(managedRuntime, [
|
|
627
|
+
'sdk_module_path',
|
|
628
|
+
'journal_path',
|
|
629
|
+
'automatic_handoff_file',
|
|
630
|
+
'handoff_context_file',
|
|
631
|
+
]) ||
|
|
632
|
+
!isText(managedRuntime['sdk_module_path'], 4096) ||
|
|
633
|
+
!path.isAbsolute(managedRuntime['sdk_module_path']) ||
|
|
634
|
+
hasUnsafePathCharacters(managedRuntime['sdk_module_path']) ||
|
|
635
|
+
!isText(managedRuntime['journal_path'], 4096) ||
|
|
636
|
+
!path.isAbsolute(managedRuntime['journal_path']) ||
|
|
637
|
+
hasUnsafePathCharacters(managedRuntime['journal_path']) ||
|
|
638
|
+
(managedRuntime['automatic_handoff_file'] !== undefined &&
|
|
639
|
+
(!isText(managedRuntime['automatic_handoff_file'], 4096) ||
|
|
640
|
+
!path.isAbsolute(managedRuntime['automatic_handoff_file']) ||
|
|
641
|
+
hasUnsafePathCharacters(managedRuntime['automatic_handoff_file']))) ||
|
|
642
|
+
(managedRuntime['handoff_context_file'] !== undefined &&
|
|
643
|
+
(!isText(managedRuntime['handoff_context_file'], 4096) ||
|
|
644
|
+
!path.isAbsolute(managedRuntime['handoff_context_file']) ||
|
|
645
|
+
hasUnsafePathCharacters(managedRuntime['handoff_context_file']))) ||
|
|
646
|
+
target['choice'] !== 'sibling' ||
|
|
647
|
+
typeof selectedModel['openrouter_model'] !== 'string' ||
|
|
648
|
+
!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(selectedModel['openrouter_model'])))
|
|
649
|
+
return {
|
|
650
|
+
ok: false,
|
|
651
|
+
code: 'managed_runtime_invalid',
|
|
652
|
+
message: 'managed_runtime requires a fresh sibling, an explicit OpenRouter model and absolute SDK/journal paths and an optional absolute future config path.',
|
|
653
|
+
};
|
|
654
|
+
const bootstrap = value['bootstrap'];
|
|
655
|
+
if (!isRecord(bootstrap) ||
|
|
656
|
+
!hasOnlyKeys(bootstrap, [
|
|
657
|
+
'config_ref',
|
|
658
|
+
'config_digest',
|
|
659
|
+
'bundle_ref',
|
|
660
|
+
'bundle_digest',
|
|
661
|
+
'extension_entry',
|
|
662
|
+
]) ||
|
|
663
|
+
!isText(bootstrap['config_ref'], 4096) ||
|
|
664
|
+
!path.isAbsolute(bootstrap['config_ref']) ||
|
|
665
|
+
!isText(bootstrap['config_digest'], 71) ||
|
|
666
|
+
!DIGEST.test(bootstrap['config_digest']) ||
|
|
667
|
+
!isText(bootstrap['bundle_ref'], 4096) ||
|
|
668
|
+
!path.isAbsolute(bootstrap['bundle_ref']) ||
|
|
669
|
+
!isText(bootstrap['bundle_digest'], 71) ||
|
|
670
|
+
!DIGEST.test(bootstrap['bundle_digest']) ||
|
|
671
|
+
!isText(bootstrap['extension_entry'], 4096) ||
|
|
672
|
+
!path.isAbsolute(bootstrap['extension_entry']))
|
|
673
|
+
return {
|
|
674
|
+
ok: false,
|
|
675
|
+
code: 'bootstrap_invalid',
|
|
676
|
+
message: 'bootstrap must pin an absolute exported bundle, manifest digest, config digest and extension entry.',
|
|
677
|
+
};
|
|
678
|
+
const pointer = value['message_pointer'];
|
|
679
|
+
if (!isRecord(pointer) ||
|
|
680
|
+
!hasOnlyKeys(pointer, ['message_id', 'content_ref', 'content_digest']) ||
|
|
681
|
+
!isText(pointer['message_id'], 256) ||
|
|
682
|
+
!isText(pointer['content_ref'], 4096) ||
|
|
683
|
+
!path.isAbsolute(pointer['content_ref']) ||
|
|
684
|
+
!isText(pointer['content_digest'], 71) ||
|
|
685
|
+
!DIGEST.test(pointer['content_digest']))
|
|
686
|
+
return {
|
|
687
|
+
ok: false,
|
|
688
|
+
code: 'message_pointer_invalid',
|
|
689
|
+
message: 'message_pointer must identify a registered message file and exact content digest.',
|
|
690
|
+
};
|
|
691
|
+
const bindingRef = value['binding_ref'];
|
|
692
|
+
if (bindingRef !== undefined &&
|
|
693
|
+
(!isText(bindingRef, 4096) || !path.isAbsolute(bindingRef)))
|
|
694
|
+
return {
|
|
695
|
+
ok: false,
|
|
696
|
+
code: 'binding_ref_invalid',
|
|
697
|
+
message: 'binding_ref, when supplied, must be an absolute task-local routing-binding path.',
|
|
698
|
+
};
|
|
699
|
+
const readiness = value['readiness'];
|
|
700
|
+
if (readiness !== undefined &&
|
|
701
|
+
(!isRecord(readiness) ||
|
|
702
|
+
!hasOnlyKeys(readiness, ['budget_ms', 'interval_ms', 'max_samples']) ||
|
|
703
|
+
(readiness['budget_ms'] !== undefined &&
|
|
704
|
+
(typeof readiness['budget_ms'] !== 'number' ||
|
|
705
|
+
!Number.isSafeInteger(readiness['budget_ms']) ||
|
|
706
|
+
readiness['budget_ms'] < 1 ||
|
|
707
|
+
readiness['budget_ms'] > 10_000)) ||
|
|
708
|
+
(readiness['interval_ms'] !== undefined &&
|
|
709
|
+
(typeof readiness['interval_ms'] !== 'number' ||
|
|
710
|
+
!Number.isSafeInteger(readiness['interval_ms']) ||
|
|
711
|
+
readiness['interval_ms'] < 1 ||
|
|
712
|
+
readiness['interval_ms'] > 100)) ||
|
|
713
|
+
(readiness['max_samples'] !== undefined &&
|
|
714
|
+
(typeof readiness['max_samples'] !== 'number' ||
|
|
715
|
+
!Number.isSafeInteger(readiness['max_samples']) ||
|
|
716
|
+
readiness['max_samples'] < 1 ||
|
|
717
|
+
readiness['max_samples'] > 100))))
|
|
718
|
+
return {
|
|
719
|
+
ok: false,
|
|
720
|
+
code: 'readiness_invalid',
|
|
721
|
+
message: 'readiness may only tighten the 10s/100ms/100-sample upper bounds.',
|
|
722
|
+
};
|
|
723
|
+
return {
|
|
724
|
+
ok: true,
|
|
725
|
+
request: {
|
|
726
|
+
schema: COLLAB_BOOTSTRAP_REQUEST_SCHEMA,
|
|
727
|
+
...(value['action'] === 'apply' ? { action: 'apply' } : {}),
|
|
728
|
+
request_id: requestId,
|
|
729
|
+
task_root: taskRoot,
|
|
730
|
+
admission: admission,
|
|
731
|
+
caller: {
|
|
732
|
+
require_herdr_env: true,
|
|
733
|
+
pane_id: typeof caller['pane_id'] === 'string' ? caller['pane_id'] : null,
|
|
734
|
+
},
|
|
735
|
+
participant: participant,
|
|
736
|
+
target: target,
|
|
737
|
+
selected_model: {
|
|
738
|
+
preserve_daily: true,
|
|
739
|
+
...(typeof selectedModel['openrouter_model'] === 'string'
|
|
740
|
+
? { openrouter_model: selectedModel['openrouter_model'] }
|
|
741
|
+
: {}),
|
|
742
|
+
},
|
|
743
|
+
...(managedRuntime === undefined
|
|
744
|
+
? {}
|
|
745
|
+
: {
|
|
746
|
+
managed_runtime: {
|
|
747
|
+
sdk_module_path: managedRuntime['sdk_module_path'],
|
|
748
|
+
journal_path: managedRuntime['journal_path'],
|
|
749
|
+
...(typeof managedRuntime['automatic_handoff_file'] === 'string'
|
|
750
|
+
? {
|
|
751
|
+
automatic_handoff_file: managedRuntime['automatic_handoff_file'],
|
|
752
|
+
}
|
|
753
|
+
: {}),
|
|
754
|
+
...(typeof managedRuntime['handoff_context_file'] === 'string'
|
|
755
|
+
? {
|
|
756
|
+
handoff_context_file: managedRuntime['handoff_context_file'],
|
|
757
|
+
}
|
|
758
|
+
: {}),
|
|
759
|
+
},
|
|
760
|
+
}),
|
|
761
|
+
bootstrap: bootstrap,
|
|
762
|
+
message_pointer: pointer,
|
|
763
|
+
...(typeof bindingRef === 'string' ? { binding_ref: bindingRef } : {}),
|
|
764
|
+
...(readiness === undefined
|
|
765
|
+
? {}
|
|
766
|
+
: {
|
|
767
|
+
readiness: readiness,
|
|
768
|
+
}),
|
|
769
|
+
},
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
export function loadVisibleBootstrapRequest(file) {
|
|
773
|
+
const read = readBoundedFile(file, MAX_REQUEST_BYTES);
|
|
774
|
+
if (!read.ok)
|
|
775
|
+
return {
|
|
776
|
+
ok: false,
|
|
777
|
+
code: 'request_unreadable',
|
|
778
|
+
message: `Request must be an absolute bounded regular JSON file (${read.code}).`,
|
|
779
|
+
};
|
|
780
|
+
let parsed;
|
|
781
|
+
try {
|
|
782
|
+
parsed = JSON.parse(read.text);
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
return {
|
|
786
|
+
ok: false,
|
|
787
|
+
code: 'request_invalid_json',
|
|
788
|
+
message: 'Request is not valid JSON.',
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
const validated = validateVisibleBootstrapRequest(parsed);
|
|
792
|
+
return validated.ok
|
|
793
|
+
? {
|
|
794
|
+
ok: true,
|
|
795
|
+
value: { request: validated.request, digest: read.digest, file },
|
|
796
|
+
}
|
|
797
|
+
: validated;
|
|
798
|
+
}
|
|
799
|
+
function readJson(file, maxBytes) {
|
|
800
|
+
const read = readBoundedFile(file, maxBytes);
|
|
801
|
+
if (!read.ok)
|
|
802
|
+
return { ok: false, code: read.code };
|
|
803
|
+
try {
|
|
804
|
+
return {
|
|
805
|
+
ok: true,
|
|
806
|
+
value: JSON.parse(read.text),
|
|
807
|
+
digest: read.digest,
|
|
808
|
+
bytes: read.bytes,
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
return { ok: false, code: 'invalid_json' };
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
function readAssignment(file, attempt, record) {
|
|
816
|
+
const read = readJson(file, MAX_ASSIGNMENT_BYTES);
|
|
817
|
+
if (!read.ok)
|
|
818
|
+
return {
|
|
819
|
+
ok: false,
|
|
820
|
+
code: 'assignment_unreadable',
|
|
821
|
+
message: `Recorded assignment is not a bounded regular JSON file (${read.code}).`,
|
|
822
|
+
};
|
|
823
|
+
if (attempt.assignment_digest !== read.digest)
|
|
824
|
+
return {
|
|
825
|
+
ok: false,
|
|
826
|
+
code: 'assignment_digest_mismatch',
|
|
827
|
+
message: 'Recorded assignment digest differs from current bytes.',
|
|
828
|
+
};
|
|
829
|
+
if (!isAssignment(read.value))
|
|
830
|
+
return {
|
|
831
|
+
ok: false,
|
|
832
|
+
code: 'assignment_invalid',
|
|
833
|
+
message: 'Recorded assignment schema is invalid.',
|
|
834
|
+
};
|
|
835
|
+
if (record &&
|
|
836
|
+
(read.value.assignee.agent_id !== attempt.assignee.agent_id ||
|
|
837
|
+
read.value.assignee.session_id !== attempt.assignee.session_id)) {
|
|
838
|
+
try {
|
|
839
|
+
const current = readManagedSourceAssignment(record, attempt);
|
|
840
|
+
return {
|
|
841
|
+
ok: true,
|
|
842
|
+
assignment: current.assignment,
|
|
843
|
+
digest: current.originalDigest,
|
|
844
|
+
file: current.file,
|
|
845
|
+
admissionDigest: current.digest,
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
return {
|
|
850
|
+
ok: false,
|
|
851
|
+
code: 'source_admission_unverified',
|
|
852
|
+
message: 'Current source admission does not derive from a completed verified succession.',
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return {
|
|
857
|
+
ok: true,
|
|
858
|
+
assignment: read.value,
|
|
859
|
+
digest: read.digest,
|
|
860
|
+
file,
|
|
861
|
+
admissionDigest: read.digest,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
function readCapabilityCard(file, digest) {
|
|
865
|
+
const read = readJson(file, MAX_CARD_BYTES);
|
|
866
|
+
if (!read.ok)
|
|
867
|
+
return {
|
|
868
|
+
ok: false,
|
|
869
|
+
code: 'capability_card_unreadable',
|
|
870
|
+
message: `Capability card is not a bounded regular JSON file (${read.code}).`,
|
|
871
|
+
};
|
|
872
|
+
if (read.digest !== digest)
|
|
873
|
+
return {
|
|
874
|
+
ok: false,
|
|
875
|
+
code: 'capability_card_digest_mismatch',
|
|
876
|
+
message: 'Capability card bytes do not match the request digest.',
|
|
877
|
+
};
|
|
878
|
+
if (!isCard(read.value))
|
|
879
|
+
return {
|
|
880
|
+
ok: false,
|
|
881
|
+
code: 'capability_card_invalid',
|
|
882
|
+
message: 'Capability card schema is invalid.',
|
|
883
|
+
};
|
|
884
|
+
return { ok: true, card: read.value };
|
|
885
|
+
}
|
|
886
|
+
function readBinding(file, taskRoot, taskId) {
|
|
887
|
+
const read = readJson(file, MAX_BINDING_BYTES);
|
|
888
|
+
if (!read.ok)
|
|
889
|
+
return {
|
|
890
|
+
ok: false,
|
|
891
|
+
code: 'binding_unreadable',
|
|
892
|
+
message: `Binding must be a bounded regular JSON file (${read.code}).`,
|
|
893
|
+
};
|
|
894
|
+
let real;
|
|
895
|
+
try {
|
|
896
|
+
real = fs.realpathSync(file);
|
|
897
|
+
}
|
|
898
|
+
catch {
|
|
899
|
+
return {
|
|
900
|
+
ok: false,
|
|
901
|
+
code: 'binding_unreadable',
|
|
902
|
+
message: 'Binding path could not be resolved.',
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
if (!isPathInside(taskRoot, real))
|
|
906
|
+
return {
|
|
907
|
+
ok: false,
|
|
908
|
+
code: 'binding_not_task_local',
|
|
909
|
+
message: 'Binding must be inside the canonical task root.',
|
|
910
|
+
};
|
|
911
|
+
const parsed = collabBindingSchema.safeParse(read.value);
|
|
912
|
+
if (!parsed.success)
|
|
913
|
+
return {
|
|
914
|
+
ok: false,
|
|
915
|
+
code: 'binding_invalid',
|
|
916
|
+
message: 'Binding does not match pappra/collab-binding/1.',
|
|
917
|
+
};
|
|
918
|
+
if (parsed.data.task_id !== taskId ||
|
|
919
|
+
!safePathEqual(parsed.data.task_root, taskRoot))
|
|
920
|
+
return {
|
|
921
|
+
ok: false,
|
|
922
|
+
code: 'binding_identity_mismatch',
|
|
923
|
+
message: 'Binding task identity/root does not match the current task.',
|
|
924
|
+
};
|
|
925
|
+
const agents = new Set();
|
|
926
|
+
const names = new Set();
|
|
927
|
+
for (const item of parsed.data.participants) {
|
|
928
|
+
if (agents.has(item.agent_id) || names.has(item.herdr_agent))
|
|
929
|
+
return {
|
|
930
|
+
ok: false,
|
|
931
|
+
code: 'binding_ambiguous',
|
|
932
|
+
message: 'Binding repeats an agent id or Herdr name.',
|
|
933
|
+
};
|
|
934
|
+
agents.add(item.agent_id);
|
|
935
|
+
names.add(item.herdr_agent);
|
|
936
|
+
}
|
|
937
|
+
return { ok: true, binding: parsed.data, file: real, digest: read.digest };
|
|
938
|
+
}
|
|
939
|
+
function readBundle(root) {
|
|
940
|
+
let canonicalRoot;
|
|
941
|
+
try {
|
|
942
|
+
const rootStat = fs.lstatSync(root);
|
|
943
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
944
|
+
return {
|
|
945
|
+
ok: false,
|
|
946
|
+
code: 'bundle_root_unsafe',
|
|
947
|
+
message: 'Bundle root must be a real directory, not a symlink.',
|
|
948
|
+
};
|
|
949
|
+
canonicalRoot = fs.realpathSync(root);
|
|
950
|
+
}
|
|
951
|
+
catch {
|
|
952
|
+
return {
|
|
953
|
+
ok: false,
|
|
954
|
+
code: 'bundle_root_unavailable',
|
|
955
|
+
message: 'Bundle root could not be inspected.',
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
const entries = [];
|
|
959
|
+
const files = new Map();
|
|
960
|
+
let totalBytes = 0;
|
|
961
|
+
const visit = (directory, relative) => {
|
|
962
|
+
let names;
|
|
963
|
+
try {
|
|
964
|
+
names = fs.readdirSync(directory).sort();
|
|
965
|
+
}
|
|
966
|
+
catch {
|
|
967
|
+
return 'bundle_directory_unreadable';
|
|
968
|
+
}
|
|
969
|
+
for (const name of names) {
|
|
970
|
+
const file = path.join(directory, name);
|
|
971
|
+
const rel = relative === '' ? name : `${relative}/${name}`;
|
|
972
|
+
if (!isPathInside(canonicalRoot, file))
|
|
973
|
+
return 'bundle_path_escape';
|
|
974
|
+
let stat;
|
|
975
|
+
try {
|
|
976
|
+
stat = fs.lstatSync(file);
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
return 'bundle_entry_unreadable';
|
|
980
|
+
}
|
|
981
|
+
if (stat.isSymbolicLink())
|
|
982
|
+
return 'bundle_symlink_refused';
|
|
983
|
+
if (stat.isDirectory()) {
|
|
984
|
+
const nested = visit(file, rel);
|
|
985
|
+
if (nested !== null)
|
|
986
|
+
return nested;
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
if (!stat.isFile())
|
|
990
|
+
return 'bundle_special_file_refused';
|
|
991
|
+
if (stat.size > MAX_BUNDLE_FILE_BYTES)
|
|
992
|
+
return 'bundle_file_too_large';
|
|
993
|
+
totalBytes += stat.size;
|
|
994
|
+
if (totalBytes > MAX_BUNDLE_TOTAL_BYTES)
|
|
995
|
+
return 'bundle_too_large';
|
|
996
|
+
const read = readBoundedFile(file, MAX_BUNDLE_FILE_BYTES);
|
|
997
|
+
if (!read.ok)
|
|
998
|
+
return `bundle_file_${read.code}`;
|
|
999
|
+
entries.push({ path: rel, digest: read.digest });
|
|
1000
|
+
files.set(rel, { bytes: read.bytes, digest: read.digest });
|
|
1001
|
+
if (entries.length > MAX_BUNDLE_FILES)
|
|
1002
|
+
return 'bundle_file_count_exceeded';
|
|
1003
|
+
}
|
|
1004
|
+
return null;
|
|
1005
|
+
};
|
|
1006
|
+
const problem = visit(canonicalRoot, '');
|
|
1007
|
+
if (problem !== null)
|
|
1008
|
+
return {
|
|
1009
|
+
ok: false,
|
|
1010
|
+
code: problem,
|
|
1011
|
+
message: `Bundle manifest rejected (${problem}).`,
|
|
1012
|
+
};
|
|
1013
|
+
entries.sort((left, right) => Buffer.compare(Buffer.from(left.path, 'utf8'), Buffer.from(right.path, 'utf8')));
|
|
1014
|
+
return {
|
|
1015
|
+
ok: true,
|
|
1016
|
+
bundle: {
|
|
1017
|
+
root: canonicalRoot,
|
|
1018
|
+
digest: digestText(JSON.stringify(entries)),
|
|
1019
|
+
entries,
|
|
1020
|
+
files,
|
|
1021
|
+
},
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
function bindingParticipant(binding, agentId) {
|
|
1025
|
+
const matches = binding.participants.filter((participant) => participant.agent_id === agentId);
|
|
1026
|
+
return matches.length === 1 ? matches[0] : null;
|
|
1027
|
+
}
|
|
1028
|
+
function herdrNameForRequest(request) {
|
|
1029
|
+
return (request.participant.herdr_agent ?? generatedHerdrAgent(request.request_id));
|
|
1030
|
+
}
|
|
1031
|
+
function matchConfigToTask(request, record, attempt, assignment, config) {
|
|
1032
|
+
const guard = config.guard;
|
|
1033
|
+
if (config.taskId !== record.task_id || assignment.task_id !== record.task_id)
|
|
1034
|
+
return {
|
|
1035
|
+
ok: false,
|
|
1036
|
+
code: 'config_task_mismatch',
|
|
1037
|
+
message: 'Config, assignment and current task id must agree.',
|
|
1038
|
+
};
|
|
1039
|
+
if (guard.taskId !== record.task_id ||
|
|
1040
|
+
guard.subtaskId !== request.admission.subtask_id ||
|
|
1041
|
+
guard.attempt !== request.admission.attempt ||
|
|
1042
|
+
guard.inputRevision !== request.admission.input_revision)
|
|
1043
|
+
return {
|
|
1044
|
+
ok: false,
|
|
1045
|
+
code: 'config_attempt_mismatch',
|
|
1046
|
+
message: 'Bootstrap config must pin the exact requested task attempt/input revision.',
|
|
1047
|
+
};
|
|
1048
|
+
if (guard.coordinatorEpoch !== assignment.coordinator.owner_epoch ||
|
|
1049
|
+
attempt.owner_epoch !== guard.coordinatorEpoch ||
|
|
1050
|
+
record.coordinator?.owner_epoch !== guard.coordinatorEpoch)
|
|
1051
|
+
return {
|
|
1052
|
+
ok: false,
|
|
1053
|
+
code: 'coordinator_epoch_mismatch',
|
|
1054
|
+
message: 'Config, assignment, attempt and current task coordinator epochs must agree.',
|
|
1055
|
+
};
|
|
1056
|
+
const found = (record.rollover ?? []).filter((intent) => intent.intent_id === guard.intentId);
|
|
1057
|
+
if (found.length !== 1)
|
|
1058
|
+
return {
|
|
1059
|
+
ok: false,
|
|
1060
|
+
code: 'rollover_intent_missing_or_ambiguous',
|
|
1061
|
+
message: 'The exact config-pinned rollover intent must exist once.',
|
|
1062
|
+
};
|
|
1063
|
+
const intent = found[0];
|
|
1064
|
+
if (intent.task_id !== record.task_id ||
|
|
1065
|
+
intent.handoff_id !== guard.handoffId ||
|
|
1066
|
+
intent.generation !== guard.generation ||
|
|
1067
|
+
intent.subtask_id !== guard.subtaskId ||
|
|
1068
|
+
intent.attempt !== guard.attempt ||
|
|
1069
|
+
intent.input_revision !== guard.inputRevision ||
|
|
1070
|
+
intent.coordinator_epoch !== guard.coordinatorEpoch ||
|
|
1071
|
+
!sameIdentity(intent.to, {
|
|
1072
|
+
agent_id: guard.successor.agentId,
|
|
1073
|
+
session_id: guard.successor.sessionId,
|
|
1074
|
+
harness: guard.successor.harness,
|
|
1075
|
+
}))
|
|
1076
|
+
return {
|
|
1077
|
+
ok: false,
|
|
1078
|
+
code: 'rollover_intent_mismatch',
|
|
1079
|
+
message: 'Recorded rollover intent does not match the accepted config.',
|
|
1080
|
+
};
|
|
1081
|
+
if (request.participant.agent_id !== guard.successor.agentId ||
|
|
1082
|
+
request.participant.harness !== guard.successor.harness ||
|
|
1083
|
+
request.participant.native_session_selector !== guard.successor.sessionId)
|
|
1084
|
+
return {
|
|
1085
|
+
ok: false,
|
|
1086
|
+
code: 'participant_config_mismatch',
|
|
1087
|
+
message: 'Requested participant must exactly match the config-pinned successor identity.',
|
|
1088
|
+
};
|
|
1089
|
+
const intentClaims = [...intent.claims]
|
|
1090
|
+
.map((claim) => ({ claim_id: claim.claim_id, epoch: claim.resource_epoch }))
|
|
1091
|
+
.sort((left, right) => left.claim_id < right.claim_id
|
|
1092
|
+
? -1
|
|
1093
|
+
: left.claim_id > right.claim_id
|
|
1094
|
+
? 1
|
|
1095
|
+
: 0);
|
|
1096
|
+
const configClaims = [...guard.claims]
|
|
1097
|
+
.map((claim) => ({
|
|
1098
|
+
claim_id: claim.claimId,
|
|
1099
|
+
epoch: claim.originalResourceEpoch,
|
|
1100
|
+
}))
|
|
1101
|
+
.sort((left, right) => left.claim_id < right.claim_id
|
|
1102
|
+
? -1
|
|
1103
|
+
: left.claim_id > right.claim_id
|
|
1104
|
+
? 1
|
|
1105
|
+
: 0);
|
|
1106
|
+
if (JSON.stringify(intentClaims) !== JSON.stringify(configClaims))
|
|
1107
|
+
return {
|
|
1108
|
+
ok: false,
|
|
1109
|
+
code: 'config_claims_mismatch',
|
|
1110
|
+
message: 'Config claim pins do not match the recorded rollover intent.',
|
|
1111
|
+
};
|
|
1112
|
+
const handoffs = (record.handoffs ?? []).filter((handoff) => handoff.handoff_id === intent.handoff_id);
|
|
1113
|
+
if (handoffs.length !== 1)
|
|
1114
|
+
return {
|
|
1115
|
+
ok: false,
|
|
1116
|
+
code: 'handoff_missing_or_ambiguous',
|
|
1117
|
+
message: 'The exact intent must have one recorded handoff.',
|
|
1118
|
+
};
|
|
1119
|
+
const handoff = handoffs[0];
|
|
1120
|
+
if (handoff.state === 'aborted')
|
|
1121
|
+
return {
|
|
1122
|
+
ok: false,
|
|
1123
|
+
code: 'handoff_aborted',
|
|
1124
|
+
message: 'The recorded handoff was aborted; bootstrap is refused.',
|
|
1125
|
+
};
|
|
1126
|
+
let sourceClaims = true;
|
|
1127
|
+
let successorClaims = true;
|
|
1128
|
+
for (const ref of intent.claims) {
|
|
1129
|
+
const matches = (record.claims ?? []).filter((claim) => claim.claim_id === ref.claim_id);
|
|
1130
|
+
if (matches.length !== 1)
|
|
1131
|
+
return {
|
|
1132
|
+
ok: false,
|
|
1133
|
+
code: 'claim_missing_or_ambiguous',
|
|
1134
|
+
message: `Claim ${ref.claim_id} is missing or ambiguous.`,
|
|
1135
|
+
};
|
|
1136
|
+
const claim = matches[0];
|
|
1137
|
+
if (claim.released_at !== null)
|
|
1138
|
+
return {
|
|
1139
|
+
ok: false,
|
|
1140
|
+
code: 'claim_released',
|
|
1141
|
+
message: `Claim ${ref.claim_id} was released.`,
|
|
1142
|
+
};
|
|
1143
|
+
sourceClaims &&=
|
|
1144
|
+
claim.resource_epoch === ref.resource_epoch &&
|
|
1145
|
+
sameIdentity(claim.holder, intent.from);
|
|
1146
|
+
successorClaims &&=
|
|
1147
|
+
claim.resource_epoch === ref.resource_epoch + 1 &&
|
|
1148
|
+
sameIdentity(claim.holder, intent.to);
|
|
1149
|
+
}
|
|
1150
|
+
if (!sourceClaims && !successorClaims)
|
|
1151
|
+
return {
|
|
1152
|
+
ok: false,
|
|
1153
|
+
code: 'claim_drift',
|
|
1154
|
+
message: 'Current claims are neither the pinned source stage nor the complete successor-transfer stage.',
|
|
1155
|
+
};
|
|
1156
|
+
if (handoff.state === 'completed' && !successorClaims)
|
|
1157
|
+
return {
|
|
1158
|
+
ok: false,
|
|
1159
|
+
code: 'completed_handoff_claim_mismatch',
|
|
1160
|
+
message: 'Completed handoff does not have the exact successor claim set.',
|
|
1161
|
+
};
|
|
1162
|
+
if (successorClaims &&
|
|
1163
|
+
handoff.state !== 'completed' &&
|
|
1164
|
+
handoff.state !== 'transferring' &&
|
|
1165
|
+
handoff.state !== 'receiver_confirmed')
|
|
1166
|
+
return {
|
|
1167
|
+
ok: false,
|
|
1168
|
+
code: 'handoff_claim_stage_mismatch',
|
|
1169
|
+
message: 'Successor claims are present outside a recorded transfer stage.',
|
|
1170
|
+
};
|
|
1171
|
+
return { ok: true, intent, handoffState: handoff.state };
|
|
1172
|
+
}
|
|
1173
|
+
function bundleFile(bundle, relative) {
|
|
1174
|
+
return bundle.files.get(relative) ?? null;
|
|
1175
|
+
}
|
|
1176
|
+
function verifyBootstrapBundle(request, configDigest, dependencies) {
|
|
1177
|
+
const loaded = readBundle(request.bootstrap.bundle_ref);
|
|
1178
|
+
if (!loaded.ok)
|
|
1179
|
+
return loaded;
|
|
1180
|
+
const bundle = loaded.bundle;
|
|
1181
|
+
if (bundle.digest !== request.bootstrap.bundle_digest)
|
|
1182
|
+
return {
|
|
1183
|
+
ok: false,
|
|
1184
|
+
code: 'bundle_manifest_digest_mismatch',
|
|
1185
|
+
message: 'Bundle manifest digest differs from the pinned request.',
|
|
1186
|
+
};
|
|
1187
|
+
const configRel = 'extensions/pappra-bootstrap/bootstrap.json';
|
|
1188
|
+
const entryRel = 'extensions/pappra-bootstrap/index.ts';
|
|
1189
|
+
const runtimeRel = 'extensions/pappra-bootstrap/runtime.mjs';
|
|
1190
|
+
if (!safePathEqual(request.bootstrap.config_ref, path.join(bundle.root, configRel)) ||
|
|
1191
|
+
!safePathEqual(request.bootstrap.extension_entry, path.join(bundle.root, entryRel)))
|
|
1192
|
+
return {
|
|
1193
|
+
ok: false,
|
|
1194
|
+
code: 'bootstrap_paths_mismatch',
|
|
1195
|
+
message: 'Config and entry must be the exact co-located exported bundle files.',
|
|
1196
|
+
};
|
|
1197
|
+
const config = bundleFile(bundle, configRel);
|
|
1198
|
+
const entry = bundleFile(bundle, entryRel);
|
|
1199
|
+
const runtime = bundleFile(bundle, runtimeRel);
|
|
1200
|
+
if (config === null ||
|
|
1201
|
+
entry === null ||
|
|
1202
|
+
runtime === null ||
|
|
1203
|
+
config.digest !== configDigest)
|
|
1204
|
+
return {
|
|
1205
|
+
ok: false,
|
|
1206
|
+
code: 'bootstrap_bundle_incomplete',
|
|
1207
|
+
message: 'Bundle lacks the pinned config, entry or runtime bytes.',
|
|
1208
|
+
};
|
|
1209
|
+
if (entry.bytes.toString('utf8').trimEnd() !== BOOTSTRAP_ENTRY_SOURCE)
|
|
1210
|
+
return {
|
|
1211
|
+
ok: false,
|
|
1212
|
+
code: 'bootstrap_entry_mismatch',
|
|
1213
|
+
message: 'Bundle entry is not the accepted generated Pi bootstrap wrapper.',
|
|
1214
|
+
};
|
|
1215
|
+
const artifactPath = dependencies.acceptedArtifactPath ??
|
|
1216
|
+
path.join(getPackageRoot(), 'dist/hosts/integration/pappra-bootstrap-extension.mjs');
|
|
1217
|
+
const artifact = readBoundedFile(artifactPath, MAX_BUNDLE_FILE_BYTES);
|
|
1218
|
+
if (!artifact.ok)
|
|
1219
|
+
return {
|
|
1220
|
+
ok: false,
|
|
1221
|
+
code: 'accepted_artifact_unavailable',
|
|
1222
|
+
message: `Accepted bootstrap artifact is unavailable (${artifact.code}).`,
|
|
1223
|
+
};
|
|
1224
|
+
if (runtime.digest !== artifact.digest)
|
|
1225
|
+
return {
|
|
1226
|
+
ok: false,
|
|
1227
|
+
code: 'bootstrap_runtime_mismatch',
|
|
1228
|
+
message: 'Bundle runtime bytes differ from the accepted bootstrap artifact.',
|
|
1229
|
+
};
|
|
1230
|
+
const manifest = bundleFile(bundle, 'package.json');
|
|
1231
|
+
if (manifest === null)
|
|
1232
|
+
return {
|
|
1233
|
+
ok: false,
|
|
1234
|
+
code: 'bundle_package_manifest_missing',
|
|
1235
|
+
message: 'Bundle package.json is missing.',
|
|
1236
|
+
};
|
|
1237
|
+
let packageJson;
|
|
1238
|
+
try {
|
|
1239
|
+
packageJson = JSON.parse(manifest.bytes.toString('utf8'));
|
|
1240
|
+
}
|
|
1241
|
+
catch {
|
|
1242
|
+
return {
|
|
1243
|
+
ok: false,
|
|
1244
|
+
code: 'bundle_package_manifest_invalid',
|
|
1245
|
+
message: 'Bundle package.json is not valid JSON.',
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
if (!isRecord(packageJson) ||
|
|
1249
|
+
packageJson['name'] !== '@phyzess/pappra-pi-bootstrap' ||
|
|
1250
|
+
!isRecord(packageJson['pi']) ||
|
|
1251
|
+
!Array.isArray(packageJson['pi']['extensions']) ||
|
|
1252
|
+
!packageJson['pi']['extensions'].includes('./extensions/pappra-bootstrap/index.ts'))
|
|
1253
|
+
return {
|
|
1254
|
+
ok: false,
|
|
1255
|
+
code: 'bundle_package_manifest_mismatch',
|
|
1256
|
+
message: 'Bundle package manifest does not register the accepted bootstrap entry.',
|
|
1257
|
+
};
|
|
1258
|
+
return { ok: true, bundle };
|
|
1259
|
+
}
|
|
1260
|
+
function messageAndBindingFailure(request, record, message, binding) {
|
|
1261
|
+
if (message.task_id !== record.task_id ||
|
|
1262
|
+
message.to.agent_id !== request.participant.agent_id ||
|
|
1263
|
+
message.to.harness !== request.participant.harness ||
|
|
1264
|
+
message.to.session_id !== request.participant.native_session_selector)
|
|
1265
|
+
return {
|
|
1266
|
+
ok: false,
|
|
1267
|
+
code: 'message_recipient_mismatch',
|
|
1268
|
+
message: 'Registered first-contact message must target the exact requested participant/session.',
|
|
1269
|
+
};
|
|
1270
|
+
if (message.subtask_id !== request.admission.subtask_id ||
|
|
1271
|
+
message.attempt !== request.admission.attempt)
|
|
1272
|
+
return {
|
|
1273
|
+
ok: false,
|
|
1274
|
+
code: 'message_attempt_mismatch',
|
|
1275
|
+
message: 'Registered message must target the exact admitted subtask attempt.',
|
|
1276
|
+
};
|
|
1277
|
+
const inputRevision = message.body.input_revision;
|
|
1278
|
+
if (inputRevision !== null &&
|
|
1279
|
+
inputRevision !== undefined &&
|
|
1280
|
+
inputRevision !== request.admission.input_revision)
|
|
1281
|
+
return {
|
|
1282
|
+
ok: false,
|
|
1283
|
+
code: 'message_input_revision_mismatch',
|
|
1284
|
+
message: 'Registered message input revision differs from the admitted attempt.',
|
|
1285
|
+
};
|
|
1286
|
+
const senders = binding.participants.filter((participant) => participant.agent_id === message.from.agent_id);
|
|
1287
|
+
if (senders.length !== 1 ||
|
|
1288
|
+
senders[0].harness !== message.from.harness ||
|
|
1289
|
+
(senders[0].native_session_id !== null &&
|
|
1290
|
+
senders[0].native_session_id !== message.from.session_id))
|
|
1291
|
+
return {
|
|
1292
|
+
ok: false,
|
|
1293
|
+
code: 'message_sender_binding_mismatch',
|
|
1294
|
+
message: 'Binding must identify the registered sender and exact session.',
|
|
1295
|
+
};
|
|
1296
|
+
return { ok: true, sender: senders[0] };
|
|
1297
|
+
}
|
|
1298
|
+
function liveParticipant(bindingParticipant, expected, agents) {
|
|
1299
|
+
const matches = agents.filter((agent) => agent.name === bindingParticipant.herdr_agent);
|
|
1300
|
+
if (matches.length !== 1)
|
|
1301
|
+
return {
|
|
1302
|
+
ok: false,
|
|
1303
|
+
code: 'participant_not_unique',
|
|
1304
|
+
message: 'Herdr must report exactly one task-bound participant.',
|
|
1305
|
+
};
|
|
1306
|
+
const agent = matches[0];
|
|
1307
|
+
if (agent.paneId !== bindingParticipant.pane_id ||
|
|
1308
|
+
agent.harness !== bindingParticipant.harness ||
|
|
1309
|
+
agent.cwd === null ||
|
|
1310
|
+
!safePathEqual(agent.cwd, bindingParticipant.cwd))
|
|
1311
|
+
return {
|
|
1312
|
+
ok: false,
|
|
1313
|
+
code: 'participant_binding_drift',
|
|
1314
|
+
message: 'Live Herdr participant does not match the task-local binding.',
|
|
1315
|
+
};
|
|
1316
|
+
const observed = reportedSessionIdentity(agent.sessionKind, agent.sessionValue);
|
|
1317
|
+
if (observed.status !== 'match' || observed.identity !== expected.session_id)
|
|
1318
|
+
return {
|
|
1319
|
+
ok: false,
|
|
1320
|
+
code: 'participant_session_unconfirmed',
|
|
1321
|
+
message: 'Live Herdr metadata does not confirm the exact native session.',
|
|
1322
|
+
};
|
|
1323
|
+
if (expected.agent_id !== bindingParticipant.agent_id ||
|
|
1324
|
+
expected.harness !== bindingParticipant.harness)
|
|
1325
|
+
return {
|
|
1326
|
+
ok: false,
|
|
1327
|
+
code: 'participant_identity_mismatch',
|
|
1328
|
+
message: 'Task-local participant identity does not match the registered message.',
|
|
1329
|
+
};
|
|
1330
|
+
return { ok: true, agent };
|
|
1331
|
+
}
|
|
1332
|
+
function participantCardIssue(card, assignment, participant, cwd, now) {
|
|
1333
|
+
if (card.agent_id !== participant.agent_id ||
|
|
1334
|
+
card.harness !== participant.harness ||
|
|
1335
|
+
card.session_id !== participant.native_session_selector ||
|
|
1336
|
+
card.evidence_level !== 'runtime' ||
|
|
1337
|
+
!safePathEqual(card.cwd, cwd))
|
|
1338
|
+
return 'reuse_card_identity_mismatch';
|
|
1339
|
+
const age = now - Date.parse(card.observed_at);
|
|
1340
|
+
if (!Number.isFinite(age) || age > 30 * 60_000 || age < -60_000)
|
|
1341
|
+
return 'reuse_card_stale';
|
|
1342
|
+
for (const required of assignment.required.tools) {
|
|
1343
|
+
const capability = card.tools.find((item) => item.id === required);
|
|
1344
|
+
if (capability === undefined ||
|
|
1345
|
+
!['callable', 'verified'].includes(capability.state) ||
|
|
1346
|
+
typeof capability.evidence_ref !== 'string')
|
|
1347
|
+
return `reuse_tool_unconfirmed:${required}`;
|
|
1348
|
+
}
|
|
1349
|
+
for (const required of assignment.required.skills) {
|
|
1350
|
+
const capability = card.skills.find((item) => item.id === required.id);
|
|
1351
|
+
if (capability === undefined ||
|
|
1352
|
+
capability.bundle_digest !== required.bundle_digest ||
|
|
1353
|
+
!['callable', 'verified'].includes(capability.state) ||
|
|
1354
|
+
typeof capability.evidence_ref !== 'string')
|
|
1355
|
+
return `reuse_skill_unconfirmed:${required.id}`;
|
|
1356
|
+
}
|
|
1357
|
+
return null;
|
|
1358
|
+
}
|
|
1359
|
+
function callerIdentityMatches(caller, binding, assignment, messageFrom) {
|
|
1360
|
+
const session = reportedSessionIdentity(caller.sessionKind, caller.sessionValue);
|
|
1361
|
+
return (caller.paneId === binding.pane_id &&
|
|
1362
|
+
caller.harness === assignment.assignee.harness &&
|
|
1363
|
+
caller.cwd !== null &&
|
|
1364
|
+
safePathEqual(caller.cwd, assignment.assignee.cwd) &&
|
|
1365
|
+
session.status === 'match' &&
|
|
1366
|
+
session.identity === assignment.assignee.session_id &&
|
|
1367
|
+
messageFrom.agent_id === assignment.assignee.agent_id &&
|
|
1368
|
+
messageFrom.session_id === assignment.assignee.session_id &&
|
|
1369
|
+
messageFrom.harness === assignment.assignee.harness);
|
|
1370
|
+
}
|
|
1371
|
+
function acceptedPiArgv(argv, sessionId, agentName, extensionEntry, openrouterModel) {
|
|
1372
|
+
const executable = argv[0]?.split(/[\\/]/).at(-1)?.toLowerCase();
|
|
1373
|
+
const hasPair = (flag, value) => argv.some((argument, index) => argument === flag && argv[index + 1] === value);
|
|
1374
|
+
const forbidden = new Set([
|
|
1375
|
+
'-m',
|
|
1376
|
+
'--settings',
|
|
1377
|
+
'--config',
|
|
1378
|
+
'-c',
|
|
1379
|
+
'--approve',
|
|
1380
|
+
'--yes',
|
|
1381
|
+
'--resume',
|
|
1382
|
+
'--dangerously-skip-permissions',
|
|
1383
|
+
'--dangerously-bypass-approvals-and-sandbox',
|
|
1384
|
+
]);
|
|
1385
|
+
const overrideFlags = argv.filter((arg) => arg === '--provider' ||
|
|
1386
|
+
arg === '--model' ||
|
|
1387
|
+
arg.startsWith('--provider=') ||
|
|
1388
|
+
arg.startsWith('--model='));
|
|
1389
|
+
const modelMatches = openrouterModel === undefined
|
|
1390
|
+
? overrideFlags.length === 0
|
|
1391
|
+
: overrideFlags.length === 2 &&
|
|
1392
|
+
hasPair('--provider', 'openrouter') &&
|
|
1393
|
+
hasPair('--model', openrouterModel);
|
|
1394
|
+
return ((executable === 'pi' || executable === 'pi.exe') &&
|
|
1395
|
+
hasPair('--session-id', sessionId) &&
|
|
1396
|
+
hasPair('--name', agentName) &&
|
|
1397
|
+
hasPair('--extension', extensionEntry) &&
|
|
1398
|
+
modelMatches &&
|
|
1399
|
+
!argv.some((argument) => forbidden.has(argument)));
|
|
1400
|
+
}
|
|
1401
|
+
function managedSdkLaunchMatches(observed, context, pane, paths) {
|
|
1402
|
+
const request = context.request;
|
|
1403
|
+
const model = request.selected_model.openrouter_model;
|
|
1404
|
+
const liveStatuses = new Set(['idle', 'working', 'running']);
|
|
1405
|
+
const launch = observed.launch;
|
|
1406
|
+
return (model !== undefined &&
|
|
1407
|
+
observed.agentName === context.herdrAgent &&
|
|
1408
|
+
observed.harness === 'pi' &&
|
|
1409
|
+
observed.paneId === pane.paneId &&
|
|
1410
|
+
observed.cwd !== null &&
|
|
1411
|
+
safePathEqual(observed.cwd, request.target.cwd) &&
|
|
1412
|
+
liveStatuses.has(observed.status.toLowerCase()) &&
|
|
1413
|
+
observed.sessionId === request.participant.native_session_selector &&
|
|
1414
|
+
observed.identityProvenance === 'owned-pi-sdk-control' &&
|
|
1415
|
+
observed.controlSchema === 'harness-collab/pi-managed-sdk-session/1' &&
|
|
1416
|
+
Number.isSafeInteger(observed.processPid) &&
|
|
1417
|
+
observed.processPid > 0 &&
|
|
1418
|
+
observed.socketPeerPid === observed.processPid &&
|
|
1419
|
+
observed.foregroundProcessPid === observed.processPid &&
|
|
1420
|
+
observed.pane.paneId === pane.paneId &&
|
|
1421
|
+
observed.pane.workspaceId === pane.workspaceId &&
|
|
1422
|
+
observed.pane.terminalId === pane.terminalId &&
|
|
1423
|
+
observed.pane.cwd !== null &&
|
|
1424
|
+
safePathEqual(observed.pane.cwd, request.target.cwd) &&
|
|
1425
|
+
launch.runtime === 'sdk' &&
|
|
1426
|
+
launch.sdkModulePath === paths.sdkModulePath &&
|
|
1427
|
+
launch.bootstrapExtensionPath === request.bootstrap.extension_entry &&
|
|
1428
|
+
launch.herdrAgentName === context.herdrAgent &&
|
|
1429
|
+
launch.journalPath === paths.journalPath &&
|
|
1430
|
+
launch.sessionId === request.participant.native_session_selector &&
|
|
1431
|
+
safePathEqual(launch.cwd, request.target.cwd) &&
|
|
1432
|
+
launch.model.provider === 'openrouter' &&
|
|
1433
|
+
launch.model.id === model &&
|
|
1434
|
+
launch.ready === true);
|
|
1435
|
+
}
|
|
1436
|
+
function callerSummary(context) {
|
|
1437
|
+
return {
|
|
1438
|
+
pane_id: context.caller.paneId,
|
|
1439
|
+
workspace_id: context.caller.workspaceId,
|
|
1440
|
+
tab_id: context.caller.tabId,
|
|
1441
|
+
harness: context.caller.harness,
|
|
1442
|
+
cwd: context.caller.cwd,
|
|
1443
|
+
native_session_status: 'match',
|
|
1444
|
+
process_affiliation: 'not-established',
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
function participantSummary(context) {
|
|
1448
|
+
return {
|
|
1449
|
+
agent_id: context.request.participant.agent_id,
|
|
1450
|
+
harness: context.request.participant.harness,
|
|
1451
|
+
native_session_selector: context.request.participant.native_session_selector,
|
|
1452
|
+
herdr_agent: context.herdrAgent,
|
|
1453
|
+
runtime_capabilities: context.request.target.choice === 'reuse'
|
|
1454
|
+
? 'validated_existing_runtime_card'
|
|
1455
|
+
: 'not_synthesized',
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
function planReport(context, action) {
|
|
1459
|
+
const request = context.request;
|
|
1460
|
+
const paneId = context.request.target.choice === 'reuse'
|
|
1461
|
+
? (bindingParticipant(context.binding, request.participant.agent_id)
|
|
1462
|
+
?.pane_id ?? null)
|
|
1463
|
+
: '<created-pane-id>';
|
|
1464
|
+
const managedPathResult = request.managed_runtime === undefined
|
|
1465
|
+
? null
|
|
1466
|
+
: managedRuntimePathsFor(request);
|
|
1467
|
+
const startArgv = request.target.choice === 'sibling'
|
|
1468
|
+
? request.managed_runtime === undefined
|
|
1469
|
+
? [
|
|
1470
|
+
'herdr',
|
|
1471
|
+
'agent',
|
|
1472
|
+
'start',
|
|
1473
|
+
context.herdrAgent,
|
|
1474
|
+
'--kind',
|
|
1475
|
+
'pi',
|
|
1476
|
+
'--pane',
|
|
1477
|
+
paneId,
|
|
1478
|
+
'--timeout',
|
|
1479
|
+
String(context.request.target.start_timeout_ms ?? START_TIMEOUT_MS),
|
|
1480
|
+
'--',
|
|
1481
|
+
'--session-id',
|
|
1482
|
+
request.participant.native_session_selector,
|
|
1483
|
+
'--name',
|
|
1484
|
+
context.herdrAgent,
|
|
1485
|
+
'--extension',
|
|
1486
|
+
request.bootstrap.extension_entry,
|
|
1487
|
+
...(request.selected_model.openrouter_model
|
|
1488
|
+
? [
|
|
1489
|
+
'--provider',
|
|
1490
|
+
'openrouter',
|
|
1491
|
+
'--model',
|
|
1492
|
+
request.selected_model.openrouter_model,
|
|
1493
|
+
]
|
|
1494
|
+
: []),
|
|
1495
|
+
]
|
|
1496
|
+
: managedPathResult?.ok
|
|
1497
|
+
? [
|
|
1498
|
+
'herdr',
|
|
1499
|
+
...managedSdkStartArgs({
|
|
1500
|
+
paneId: paneId,
|
|
1501
|
+
configPath: managedPathResult.paths.configPath,
|
|
1502
|
+
}),
|
|
1503
|
+
]
|
|
1504
|
+
: null
|
|
1505
|
+
: null;
|
|
1506
|
+
return successReport(action, request, context.requestDigest, 'planned', {
|
|
1507
|
+
task_id: context.task.record.task_id,
|
|
1508
|
+
state_revision: context.task.record.state_revision,
|
|
1509
|
+
caller: callerSummary(context),
|
|
1510
|
+
participant: participantSummary(context),
|
|
1511
|
+
target: {
|
|
1512
|
+
choice: request.target.choice,
|
|
1513
|
+
cwd: request.target.cwd,
|
|
1514
|
+
preserve_focus: true,
|
|
1515
|
+
pane_id: paneId,
|
|
1516
|
+
direction: request.target.direction ?? 'right',
|
|
1517
|
+
split_argv: request.target.choice === 'sibling'
|
|
1518
|
+
? [
|
|
1519
|
+
'herdr',
|
|
1520
|
+
'pane',
|
|
1521
|
+
'split',
|
|
1522
|
+
'--pane',
|
|
1523
|
+
context.caller.paneId,
|
|
1524
|
+
'--cwd',
|
|
1525
|
+
request.target.cwd,
|
|
1526
|
+
'--no-focus',
|
|
1527
|
+
'--direction',
|
|
1528
|
+
request.target.direction ?? 'right',
|
|
1529
|
+
]
|
|
1530
|
+
: null,
|
|
1531
|
+
launch_argv: startArgv,
|
|
1532
|
+
},
|
|
1533
|
+
bootstrap: {
|
|
1534
|
+
config_digest: context.configDigest,
|
|
1535
|
+
bundle_manifest_digest: context.bundle.digest,
|
|
1536
|
+
bundle_files: context.bundle.entries.length,
|
|
1537
|
+
extension_entry: request.bootstrap.extension_entry,
|
|
1538
|
+
runtime: 'accepted_artifact_digest_match',
|
|
1539
|
+
gate_registration: 'not_proven_by_export_or_report',
|
|
1540
|
+
authority: 'default-deny-until-recorded-transfer-completes',
|
|
1541
|
+
model: request.selected_model.openrouter_model
|
|
1542
|
+
? {
|
|
1543
|
+
provider: 'openrouter',
|
|
1544
|
+
id: request.selected_model.openrouter_model,
|
|
1545
|
+
observation: 'explicit_launch_selection; runtime_observation_unverified',
|
|
1546
|
+
}
|
|
1547
|
+
: 'daily_default_launch; selected_runtime_inheritance_unverified',
|
|
1548
|
+
},
|
|
1549
|
+
effects: [
|
|
1550
|
+
{
|
|
1551
|
+
step: 'request_reservation',
|
|
1552
|
+
status: 'planned_only',
|
|
1553
|
+
detail: 'apply persists request_id/digest before any effect',
|
|
1554
|
+
},
|
|
1555
|
+
{
|
|
1556
|
+
step: request.target.choice === 'sibling'
|
|
1557
|
+
? 'visible_split'
|
|
1558
|
+
: 'participant_reuse',
|
|
1559
|
+
status: 'planned',
|
|
1560
|
+
detail: null,
|
|
1561
|
+
},
|
|
1562
|
+
...(request.target.choice === 'sibling'
|
|
1563
|
+
? [
|
|
1564
|
+
{
|
|
1565
|
+
step: 'bounded_shell_readiness',
|
|
1566
|
+
status: 'planned',
|
|
1567
|
+
detail: 'max 10s, 100ms intervals, 100 samples',
|
|
1568
|
+
},
|
|
1569
|
+
{
|
|
1570
|
+
step: request.managed_runtime === undefined
|
|
1571
|
+
? 'agent_start'
|
|
1572
|
+
: 'managed_sdk_start',
|
|
1573
|
+
status: 'planned',
|
|
1574
|
+
detail: request.managed_runtime === undefined
|
|
1575
|
+
? request.selected_model.openrouter_model
|
|
1576
|
+
? 'Pi extension + exact fresh session UUID + explicit OpenRouter model; daily resources preserved'
|
|
1577
|
+
: 'Pi extension + exact session UUID; no model override'
|
|
1578
|
+
: 'Fixed current-package SDK start via herdr pane run; exact session/model/extension/config pinned, runtime control evidence pending',
|
|
1579
|
+
},
|
|
1580
|
+
]
|
|
1581
|
+
: []),
|
|
1582
|
+
{
|
|
1583
|
+
step: 'persist_task_binding',
|
|
1584
|
+
status: 'planned',
|
|
1585
|
+
detail: request.target.choice === 'sibling'
|
|
1586
|
+
? context.derivedBindingFile
|
|
1587
|
+
: context.bindingFile,
|
|
1588
|
+
},
|
|
1589
|
+
{
|
|
1590
|
+
step: 'notify_registered_message',
|
|
1591
|
+
status: 'planned',
|
|
1592
|
+
detail: request.message_pointer.message_id,
|
|
1593
|
+
},
|
|
1594
|
+
],
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
async function prepare(input, action, dependencies) {
|
|
1598
|
+
const request = requestOf(input);
|
|
1599
|
+
const requestDigest = requestDigestOf(input);
|
|
1600
|
+
const fail = (status, code, message, next = null, extra = {}) => ({
|
|
1601
|
+
ok: false,
|
|
1602
|
+
report: failureReport(action, request, requestDigest, status, code, message, next, extra),
|
|
1603
|
+
});
|
|
1604
|
+
if (request.managed_runtime !== undefined) {
|
|
1605
|
+
const shape = validateVisibleBootstrapRequest(request);
|
|
1606
|
+
if (!shape.ok)
|
|
1607
|
+
return fail('invalid', shape.code, shape.message);
|
|
1608
|
+
const runtimePaths = managedRuntimePathsFor(request);
|
|
1609
|
+
if (!runtimePaths.ok)
|
|
1610
|
+
return fail('invalid', runtimePaths.code, runtimePaths.message);
|
|
1611
|
+
}
|
|
1612
|
+
if (!request.binding_ref)
|
|
1613
|
+
return fail('needs_input', 'binding_ref_required', 'A task-local pappra/collab-binding/1 file is required for explicit caller/recipient routing.', 'Add binding_ref to the request; no focused-pane or workspace-default fallback is used.');
|
|
1614
|
+
const opened = await openMessageTask(request.task_root);
|
|
1615
|
+
if (!opened.ok)
|
|
1616
|
+
return fail('needs_input', opened.code, opened.message, opened.nextAction);
|
|
1617
|
+
const task = opened.value;
|
|
1618
|
+
if (!safePathEqual(task.taskRoot, request.task_root))
|
|
1619
|
+
return fail('invalid', 'task_root_mismatch', 'Request task_root is not the canonical task root.');
|
|
1620
|
+
const attempt = findAttempt(task.record, request.admission.subtask_id, request.admission.attempt);
|
|
1621
|
+
if (attempt === null ||
|
|
1622
|
+
attempt.assignment_ref === null ||
|
|
1623
|
+
attempt.assignment_digest === null)
|
|
1624
|
+
return fail('needs_input', 'attempt_or_assignment_missing', 'The exact task attempt must have a pinned assignment reference/digest.');
|
|
1625
|
+
if (attempt.awaiting === 'approval')
|
|
1626
|
+
return fail('needs_input', 'awaiting_approval', 'The admitted attempt is awaiting human approval; bootstrap cannot resume it.');
|
|
1627
|
+
if (!['assigned', 'accepted', 'running'].includes(attempt.state))
|
|
1628
|
+
return fail('needs_input', 'attempt_not_startable', `Attempt state ${attempt.state} is not eligible for bootstrap.`);
|
|
1629
|
+
if (attempt.cancel_requested_at != null ||
|
|
1630
|
+
hasPendingCancellation(task.record, request.admission.subtask_id, request.admission.attempt))
|
|
1631
|
+
return fail('needs_input', 'cancel_unconfirmed', 'A cancellation is pending or unconfirmed; bootstrap is blocked until native state is reconciled.');
|
|
1632
|
+
if (attempt.input_revision !== request.admission.input_revision)
|
|
1633
|
+
return fail('needs_input', 'input_revision_mismatch', 'Request input revision differs from the current task attempt.');
|
|
1634
|
+
const assignmentRead = readAssignment(attempt.assignment_ref, attempt, task.record);
|
|
1635
|
+
if (!assignmentRead.ok)
|
|
1636
|
+
return fail('needs_input', assignmentRead.code, assignmentRead.message);
|
|
1637
|
+
const assignment = assignmentRead.assignment;
|
|
1638
|
+
if (assignment.assignment_id !== request.admission.assignment_id ||
|
|
1639
|
+
assignment.task_id !== task.record.task_id ||
|
|
1640
|
+
assignment.attempt !== attempt.attempt)
|
|
1641
|
+
return fail('needs_input', 'assignment_identity_mismatch', 'Request admission does not match the current recorded assignment/task attempt.');
|
|
1642
|
+
if (task.record.coordinator === null ||
|
|
1643
|
+
task.record.dispatch_frozen === true ||
|
|
1644
|
+
task.record.coordinator.owner_epoch !==
|
|
1645
|
+
assignment.coordinator.owner_epoch ||
|
|
1646
|
+
attempt.owner_epoch !== assignment.coordinator.owner_epoch)
|
|
1647
|
+
return fail('needs_input', 'coordinator_or_dispatch_state_invalid', 'Current coordinator epoch must match the assignment/attempt and dispatch must not be frozen.');
|
|
1648
|
+
const cardRead = readCapabilityCard(request.admission.capability_card_ref, request.admission.capability_card_digest);
|
|
1649
|
+
if (!cardRead.ok)
|
|
1650
|
+
return fail('needs_input', cardRead.code, cardRead.message);
|
|
1651
|
+
const preflight = runCollaborationPreflight({
|
|
1652
|
+
assignmentFile: assignmentRead.file,
|
|
1653
|
+
capabilitiesFile: request.admission.capability_card_ref,
|
|
1654
|
+
maxAgeMinutes: 30,
|
|
1655
|
+
now: (dependencies.clock ?? Date.now)(),
|
|
1656
|
+
});
|
|
1657
|
+
if (!preflight.ok)
|
|
1658
|
+
return fail('needs_input', 'admission_not_ready', `Current collaboration preflight is ${preflight.status}.`, 'Refresh actual current capability evidence and rerun preflight.', {
|
|
1659
|
+
effects: preflight.blockers.slice(0, 16).map((blocker) => ({
|
|
1660
|
+
step: 'admission',
|
|
1661
|
+
status: 'blocked',
|
|
1662
|
+
detail: `${blocker.code}${blocker.capability ? `:${blocker.capability}` : ''}`,
|
|
1663
|
+
})),
|
|
1664
|
+
});
|
|
1665
|
+
if (request.target.cwd !== assignment.assignee.cwd ||
|
|
1666
|
+
!safePathEqual(request.target.cwd, assignment.assignee.cwd))
|
|
1667
|
+
return fail('invalid', 'target_cwd_mismatch', 'Target cwd must be the exact admitted assignment cwd.');
|
|
1668
|
+
const bindingResult = readBinding(request.binding_ref, task.taskRoot, task.record.task_id);
|
|
1669
|
+
if (!bindingResult.ok)
|
|
1670
|
+
return fail('needs_input', bindingResult.code, bindingResult.message);
|
|
1671
|
+
const pointer = await readRegisteredMessageSafe(task, request.message_pointer.message_id);
|
|
1672
|
+
if (!pointer.ok)
|
|
1673
|
+
return fail('needs_input', pointer.code, pointer.message, pointer.nextAction);
|
|
1674
|
+
if (pointer.value.file !== request.message_pointer.content_ref ||
|
|
1675
|
+
pointer.value.digest !== request.message_pointer.content_digest)
|
|
1676
|
+
return fail('needs_input', 'message_pointer_mismatch', 'Message pointer path/digest does not match the registered message bytes.');
|
|
1677
|
+
const message = pointer.value.message;
|
|
1678
|
+
const routing = messageAndBindingFailure(request, task.record, message, bindingResult.binding);
|
|
1679
|
+
if (!routing.ok)
|
|
1680
|
+
return fail('needs_input', routing.code, routing.message);
|
|
1681
|
+
const callerBinding = routing.sender;
|
|
1682
|
+
const ops = dependencies.ops ??
|
|
1683
|
+
createHerdrBootstrapOps({
|
|
1684
|
+
taskRoot: task.taskRoot,
|
|
1685
|
+
...(dependencies.transport === undefined
|
|
1686
|
+
? {}
|
|
1687
|
+
: { transport: dependencies.transport }),
|
|
1688
|
+
});
|
|
1689
|
+
if (dependencies.ops !== undefined &&
|
|
1690
|
+
dependencies.transport !== undefined &&
|
|
1691
|
+
dependencies.ops.transport !== dependencies.transport)
|
|
1692
|
+
return fail('invalid', 'transport_composition_mismatch', 'The notification and bootstrap operations must share the same Herdr transport instance.');
|
|
1693
|
+
const transport = ops.transport;
|
|
1694
|
+
if (request.managed_runtime !== undefined &&
|
|
1695
|
+
ops.startManagedSdk === undefined)
|
|
1696
|
+
return fail('needs_input', 'managed_start_unavailable', 'This host cannot launch a managed SDK successor; no legacy Pi fallback is permitted.');
|
|
1697
|
+
const callerIdentity = await ops.verifyCaller(request.caller.pane_id);
|
|
1698
|
+
if (!callerIdentity.ok)
|
|
1699
|
+
return fail('needs_input', callerIdentity.code, callerIdentity.detail, 'Run from the task-bound Herdr caller pane.');
|
|
1700
|
+
if (!callerIdentityMatches(callerIdentity.caller, callerBinding, assignment, message.from))
|
|
1701
|
+
return fail('needs_input', 'caller_identity_mismatch', 'Current caller, task binding, assignment and registered sender identities do not match.');
|
|
1702
|
+
const listed = await ops.listAgents();
|
|
1703
|
+
if (!listed.ok)
|
|
1704
|
+
return fail('unknown', 'herdr_agent_list_unavailable', listed.detail);
|
|
1705
|
+
if (request.managed_runtime !== undefined &&
|
|
1706
|
+
listed.agents.some((agent) => {
|
|
1707
|
+
const reported = reportedSessionIdentity(agent.sessionKind, agent.sessionValue);
|
|
1708
|
+
return (reported.status === 'match' &&
|
|
1709
|
+
reported.identity === request.participant.native_session_selector);
|
|
1710
|
+
}))
|
|
1711
|
+
return fail('needs_input', 'managed_session_selector_in_use', 'The requested SDK native session selector is already reported by a live Herdr agent.');
|
|
1712
|
+
const liveCaller = liveParticipant(callerBinding, assignment.assignee, listed.agents);
|
|
1713
|
+
if (!liveCaller.ok)
|
|
1714
|
+
return fail('needs_input', liveCaller.code, liveCaller.message);
|
|
1715
|
+
const configResult = readPiBootstrapConfig(request.bootstrap.config_ref);
|
|
1716
|
+
if (!configResult.ok)
|
|
1717
|
+
return fail('needs_input', configResult.code, configResult.message, 'Use the exact accepted exported bootstrap config.');
|
|
1718
|
+
if (configResult.digest !== request.bootstrap.config_digest ||
|
|
1719
|
+
!safePathEqual(configResult.taskRoot, task.taskRoot))
|
|
1720
|
+
return fail('needs_input', 'config_pin_or_task_mismatch', 'Bootstrap config digest/task root differs from the request/current task.');
|
|
1721
|
+
const association = matchConfigToTask(request, task.record, attempt, assignment, configResult.config);
|
|
1722
|
+
if (!association.ok)
|
|
1723
|
+
return fail('needs_input', association.code, association.message);
|
|
1724
|
+
const bundle = verifyBootstrapBundle(request, configResult.digest, dependencies);
|
|
1725
|
+
if (!bundle.ok)
|
|
1726
|
+
return fail('needs_input', bundle.code, bundle.message);
|
|
1727
|
+
let herdrAgent = herdrNameForRequest(request);
|
|
1728
|
+
if (request.target.choice === 'sibling') {
|
|
1729
|
+
if (bindingResult.binding.participants.some((participant) => participant.agent_id === request.participant.agent_id ||
|
|
1730
|
+
participant.herdr_agent === herdrAgent))
|
|
1731
|
+
return fail('needs_input', 'sibling_identity_already_bound', 'A task-bound participant already uses the requested identity/name; there is no silent reuse fallback.');
|
|
1732
|
+
if (listed.agents.some((agent) => agent.name === herdrAgent))
|
|
1733
|
+
return fail('needs_input', 'herdr_agent_name_in_use', 'Deterministic Herdr agent name is already live; use a fresh request_id.');
|
|
1734
|
+
}
|
|
1735
|
+
else {
|
|
1736
|
+
const existing = bindingParticipant(bindingResult.binding, request.participant.agent_id);
|
|
1737
|
+
if (existing === null ||
|
|
1738
|
+
existing.harness !== request.participant.harness ||
|
|
1739
|
+
existing.native_session_id !==
|
|
1740
|
+
request.participant.native_session_selector ||
|
|
1741
|
+
!safePathEqual(existing.cwd, request.target.cwd))
|
|
1742
|
+
return fail('needs_input', 'unsupported_reuse', 'Reuse requires an exact task-local participant/session/cwd binding.');
|
|
1743
|
+
if (request.participant.herdr_agent !== undefined &&
|
|
1744
|
+
request.participant.herdr_agent !== existing.herdr_agent)
|
|
1745
|
+
return fail('needs_input', 'unsupported_reuse', 'Requested Herdr name differs from the task-local participant binding.');
|
|
1746
|
+
herdrAgent = existing.herdr_agent;
|
|
1747
|
+
const participantLive = liveParticipant(existing, {
|
|
1748
|
+
agent_id: request.participant.agent_id,
|
|
1749
|
+
session_id: request.participant.native_session_selector,
|
|
1750
|
+
harness: request.participant.harness,
|
|
1751
|
+
}, listed.agents);
|
|
1752
|
+
if (!participantLive.ok)
|
|
1753
|
+
return fail('needs_input', 'unsupported_reuse', participantLive.message);
|
|
1754
|
+
let participantCard = cardRead.card;
|
|
1755
|
+
if (request.participant.capability_card_ref !== undefined &&
|
|
1756
|
+
request.participant.capability_card_digest !== undefined) {
|
|
1757
|
+
const supplied = readCapabilityCard(request.participant.capability_card_ref, request.participant.capability_card_digest);
|
|
1758
|
+
if (!supplied.ok)
|
|
1759
|
+
return fail('needs_input', 'unsupported_reuse', supplied.message);
|
|
1760
|
+
participantCard = supplied.card;
|
|
1761
|
+
}
|
|
1762
|
+
const cardIssue = participantCardIssue(participantCard, assignment, request.participant, request.target.cwd, (dependencies.clock ?? Date.now)());
|
|
1763
|
+
if (cardIssue !== null)
|
|
1764
|
+
return fail('needs_input', 'unsupported_reuse', `Existing participant capability evidence is not ready (${cardIssue}).`, 'Refresh the exact participant card; reuse never starts or replaces an agent.');
|
|
1765
|
+
}
|
|
1766
|
+
const derivedBindingFile = request.target.choice === 'sibling'
|
|
1767
|
+
? path.join(task.taskRoot, 'notifications', 'visible-bootstrap-bindings', `${request.request_id}.json`)
|
|
1768
|
+
: bindingResult.file;
|
|
1769
|
+
const context = {
|
|
1770
|
+
request,
|
|
1771
|
+
requestDigest: requestDigestOf(input),
|
|
1772
|
+
requestFile: requestFileOf(input),
|
|
1773
|
+
task,
|
|
1774
|
+
assignment,
|
|
1775
|
+
assignmentDigest: assignmentRead.digest,
|
|
1776
|
+
assignmentFile: assignmentRead.file,
|
|
1777
|
+
admissionAssignmentDigest: assignmentRead.admissionDigest,
|
|
1778
|
+
attempt,
|
|
1779
|
+
callerCard: cardRead.card,
|
|
1780
|
+
caller: callerIdentity.caller,
|
|
1781
|
+
transport,
|
|
1782
|
+
ops,
|
|
1783
|
+
binding: bindingResult.binding,
|
|
1784
|
+
bindingFile: bindingResult.file,
|
|
1785
|
+
bindingDigest: bindingResult.digest,
|
|
1786
|
+
message,
|
|
1787
|
+
config: configResult.config,
|
|
1788
|
+
configDigest: configResult.digest,
|
|
1789
|
+
acceptedArtifactPath: dependencies.acceptedArtifactPath ?? null,
|
|
1790
|
+
bundle: bundle.bundle,
|
|
1791
|
+
herdrAgent,
|
|
1792
|
+
derivedBindingFile,
|
|
1793
|
+
};
|
|
1794
|
+
return { ok: true, context };
|
|
1795
|
+
}
|
|
1796
|
+
function reservationFor(record, requestId) {
|
|
1797
|
+
const matches = (record.launch_reservations ?? []).filter((entry) => {
|
|
1798
|
+
const bootstrap = entry['bootstrap'];
|
|
1799
|
+
return isRecord(bootstrap) && bootstrap['request_id'] === requestId;
|
|
1800
|
+
});
|
|
1801
|
+
return matches.length > 1 ? 'ambiguous' : (matches[0] ?? null);
|
|
1802
|
+
}
|
|
1803
|
+
function bootstrapData(entry) {
|
|
1804
|
+
const value = entry['bootstrap'];
|
|
1805
|
+
return isRecord(value) ? value : {};
|
|
1806
|
+
}
|
|
1807
|
+
function deadNoEffectBootstrap(entry) {
|
|
1808
|
+
const data = bootstrapData(entry);
|
|
1809
|
+
return (entry.released_at === null &&
|
|
1810
|
+
entry.outcome === null &&
|
|
1811
|
+
entry.phase === 'reserved' &&
|
|
1812
|
+
data['phase'] === 'reserved' &&
|
|
1813
|
+
data['created_pane_id'] === null &&
|
|
1814
|
+
Array.isArray(data['receipts']) &&
|
|
1815
|
+
data['receipts'].length === 0 &&
|
|
1816
|
+
observeProcessIdentity(data['process_identity']) === 'dead');
|
|
1817
|
+
}
|
|
1818
|
+
function reservationSummary(entry) {
|
|
1819
|
+
const data = bootstrapData(entry);
|
|
1820
|
+
return {
|
|
1821
|
+
reservation_id: entry.reservation_id,
|
|
1822
|
+
phase: typeof data['phase'] === 'string'
|
|
1823
|
+
? data['phase']
|
|
1824
|
+
: (entry.phase ?? 'unknown'),
|
|
1825
|
+
outcome: entry.outcome,
|
|
1826
|
+
released: entry.released_at !== null,
|
|
1827
|
+
request_id: data['request_id'] ?? null,
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
function reserveRequest(context, now) {
|
|
1831
|
+
const request = context.request;
|
|
1832
|
+
const reservationId = `bootstrap-${crypto.createHash('sha256').update(request.request_id).digest('hex').slice(0, 32)}`;
|
|
1833
|
+
const outcome = transact(context.task.taskRoot, {
|
|
1834
|
+
actor: context.assignment.assignee.agent_id,
|
|
1835
|
+
purpose: 'visible-bootstrap-reserve',
|
|
1836
|
+
clock: now,
|
|
1837
|
+
apply: (record) => {
|
|
1838
|
+
if (record.task_id !== context.task.record.task_id ||
|
|
1839
|
+
record.task_root !== context.task.taskRoot)
|
|
1840
|
+
return { ok: false, reason: 'task_identity_changed' };
|
|
1841
|
+
const prior = reservationFor(record, request.request_id);
|
|
1842
|
+
if (prior === 'ambiguous')
|
|
1843
|
+
return {
|
|
1844
|
+
ok: false,
|
|
1845
|
+
reason: 'request_reservation_ambiguous',
|
|
1846
|
+
};
|
|
1847
|
+
if (prior !== null) {
|
|
1848
|
+
if (bootstrapData(prior)['request_digest'] !== context.requestDigest)
|
|
1849
|
+
return {
|
|
1850
|
+
ok: false,
|
|
1851
|
+
reason: 'request_id_content_conflict',
|
|
1852
|
+
existing: prior,
|
|
1853
|
+
};
|
|
1854
|
+
if (deadNoEffectBootstrap(prior)) {
|
|
1855
|
+
record.launch_reservations = (record.launch_reservations ?? []).filter((entry) => entry !== prior);
|
|
1856
|
+
}
|
|
1857
|
+
else
|
|
1858
|
+
return {
|
|
1859
|
+
ok: false,
|
|
1860
|
+
reason: prior.released_at === null
|
|
1861
|
+
? 'request_reconcile_required'
|
|
1862
|
+
: 'request_already_recorded',
|
|
1863
|
+
existing: prior,
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
const attempt = findAttempt(record, request.admission.subtask_id, request.admission.attempt);
|
|
1867
|
+
if (attempt === null ||
|
|
1868
|
+
attempt.assignment_digest !== context.attempt.assignment_digest ||
|
|
1869
|
+
attempt.input_revision !== request.admission.input_revision ||
|
|
1870
|
+
attempt.owner_epoch !== context.attempt.owner_epoch)
|
|
1871
|
+
return { ok: false, reason: 'attempt_changed' };
|
|
1872
|
+
const active = (record.launch_reservations ?? []).find((entry) => entry.released_at === null &&
|
|
1873
|
+
entry.subtask_id === request.admission.subtask_id &&
|
|
1874
|
+
entry.attempt === request.admission.attempt);
|
|
1875
|
+
if (active !== undefined)
|
|
1876
|
+
return {
|
|
1877
|
+
ok: false,
|
|
1878
|
+
reason: 'attempt_launch_already_reserved',
|
|
1879
|
+
existing: active,
|
|
1880
|
+
};
|
|
1881
|
+
const reservation = {
|
|
1882
|
+
reservation_id: reservationId,
|
|
1883
|
+
subtask_id: request.admission.subtask_id,
|
|
1884
|
+
attempt: request.admission.attempt,
|
|
1885
|
+
kind: 'pi',
|
|
1886
|
+
mode: 'visible-bootstrap',
|
|
1887
|
+
holder: context.assignment.assignee,
|
|
1888
|
+
requested_session_id: request.participant.native_session_selector,
|
|
1889
|
+
transport: 'herdr',
|
|
1890
|
+
reserved_at: new Date(now).toISOString(),
|
|
1891
|
+
released_at: null,
|
|
1892
|
+
outcome: null,
|
|
1893
|
+
phase: 'reserved',
|
|
1894
|
+
bootstrap: {
|
|
1895
|
+
process_identity: captureProcessIdentity(),
|
|
1896
|
+
request_id: request.request_id,
|
|
1897
|
+
request_digest: context.requestDigest,
|
|
1898
|
+
request_file: context.requestFile,
|
|
1899
|
+
target_choice: request.target.choice,
|
|
1900
|
+
participant_agent_id: request.participant.agent_id,
|
|
1901
|
+
participant_session_id: request.participant.native_session_selector,
|
|
1902
|
+
bundle_manifest_digest: context.bundle.digest,
|
|
1903
|
+
config_digest: context.configDigest,
|
|
1904
|
+
extension_entry: request.bootstrap.extension_entry,
|
|
1905
|
+
created_pane_id: null,
|
|
1906
|
+
created_workspace_id: null,
|
|
1907
|
+
created_terminal_id: null,
|
|
1908
|
+
herdr_agent: context.herdrAgent,
|
|
1909
|
+
binding_file: context.derivedBindingFile,
|
|
1910
|
+
message_id: request.message_pointer.message_id,
|
|
1911
|
+
message_digest: request.message_pointer.content_digest,
|
|
1912
|
+
phase: 'reserved',
|
|
1913
|
+
receipts: [],
|
|
1914
|
+
},
|
|
1915
|
+
};
|
|
1916
|
+
record.launch_reservations = [
|
|
1917
|
+
...(record.launch_reservations ?? []),
|
|
1918
|
+
reservation,
|
|
1919
|
+
];
|
|
1920
|
+
return {
|
|
1921
|
+
record,
|
|
1922
|
+
op: 'visible-bootstrap-reserve',
|
|
1923
|
+
summary: {
|
|
1924
|
+
request_id: request.request_id,
|
|
1925
|
+
subtask_id: request.admission.subtask_id,
|
|
1926
|
+
attempt: request.admission.attempt,
|
|
1927
|
+
},
|
|
1928
|
+
result: { reservation_id: reservationId },
|
|
1929
|
+
};
|
|
1930
|
+
},
|
|
1931
|
+
});
|
|
1932
|
+
if (!outcome.ok) {
|
|
1933
|
+
const existing = isRecord(outcome['existing'])
|
|
1934
|
+
? outcome['existing']
|
|
1935
|
+
: undefined;
|
|
1936
|
+
return {
|
|
1937
|
+
ok: false,
|
|
1938
|
+
code: outcome.reason,
|
|
1939
|
+
message: outcome.reason,
|
|
1940
|
+
...(existing === undefined ? {} : { existing }),
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
return { ok: true, reservationId, revision: outcome.state_revision };
|
|
1944
|
+
}
|
|
1945
|
+
function updateReservation(context, reservationId, input, now) {
|
|
1946
|
+
const outcome = transact(context.task.taskRoot, {
|
|
1947
|
+
actor: context.assignment.assignee.agent_id,
|
|
1948
|
+
purpose: `visible-bootstrap-${input.phase}`,
|
|
1949
|
+
clock: now,
|
|
1950
|
+
apply: (record) => {
|
|
1951
|
+
const matches = (record.launch_reservations ?? []).filter((entry) => entry.reservation_id === reservationId);
|
|
1952
|
+
if (matches.length !== 1)
|
|
1953
|
+
return {
|
|
1954
|
+
ok: false,
|
|
1955
|
+
reason: 'reservation_missing_or_ambiguous',
|
|
1956
|
+
};
|
|
1957
|
+
const entry = matches[0];
|
|
1958
|
+
if (entry.bootstrap?.['request_digest'] !== context.requestDigest)
|
|
1959
|
+
return { ok: false, reason: 'reservation_digest_mismatch' };
|
|
1960
|
+
if (entry.released_at !== null)
|
|
1961
|
+
return { ok: false, reason: 'reservation_already_released' };
|
|
1962
|
+
const priorReceipts = Array.isArray(entry.bootstrap['receipts'])
|
|
1963
|
+
? entry.bootstrap['receipts'].filter(isRecord)
|
|
1964
|
+
: [];
|
|
1965
|
+
const receipt = input.receipt === undefined ? null : sanitizedReceipt(input.receipt);
|
|
1966
|
+
entry.phase = input.phase;
|
|
1967
|
+
if (input.detail !== undefined)
|
|
1968
|
+
entry.detail = input.detail;
|
|
1969
|
+
if (input.outcome !== undefined)
|
|
1970
|
+
entry.outcome = input.outcome;
|
|
1971
|
+
if (input.release === true)
|
|
1972
|
+
entry.released_at = new Date(now).toISOString();
|
|
1973
|
+
entry.bootstrap = {
|
|
1974
|
+
...entry.bootstrap,
|
|
1975
|
+
...(input.extra ?? {}),
|
|
1976
|
+
request_id: context.request.request_id,
|
|
1977
|
+
request_digest: context.requestDigest,
|
|
1978
|
+
phase: input.phase,
|
|
1979
|
+
updated_at: new Date(now).toISOString(),
|
|
1980
|
+
...(receipt === null
|
|
1981
|
+
? {}
|
|
1982
|
+
: { receipts: [...priorReceipts, receipt].slice(-MAX_RECEIPTS) }),
|
|
1983
|
+
};
|
|
1984
|
+
return {
|
|
1985
|
+
record,
|
|
1986
|
+
op: 'visible-bootstrap-phase',
|
|
1987
|
+
summary: { request_id: context.request.request_id, phase: input.phase },
|
|
1988
|
+
result: { phase: input.phase },
|
|
1989
|
+
};
|
|
1990
|
+
},
|
|
1991
|
+
});
|
|
1992
|
+
return outcome.ok
|
|
1993
|
+
? { ok: true, revision: outcome.state_revision }
|
|
1994
|
+
: { ok: false, code: outcome.reason, message: outcome.reason };
|
|
1995
|
+
}
|
|
1996
|
+
function commitEffectIntent(context, reservationId, expectedRevision, expectedRecordFile, phase, clock, signal) {
|
|
1997
|
+
const outcome = transact(context.task.taskRoot, {
|
|
1998
|
+
actor: context.assignment.assignee.agent_id,
|
|
1999
|
+
purpose: `visible-bootstrap-intent-${phase}`,
|
|
2000
|
+
expectRevision: expectedRevision,
|
|
2001
|
+
clock,
|
|
2002
|
+
apply: (record) => {
|
|
2003
|
+
if (signal?.aborted)
|
|
2004
|
+
return { ok: false, reason: 'abort_requested' };
|
|
2005
|
+
if (!sameDirectoryIdentity(context.task.identity.taskRoot, context.task.taskRoot) ||
|
|
2006
|
+
!sameDirectoryIdentity(context.task.identity.messagesDirectory, context.task.messagesDirectory) ||
|
|
2007
|
+
!sameFileIdentity(expectedRecordFile))
|
|
2008
|
+
return { ok: false, reason: 'task_namespace_changed' };
|
|
2009
|
+
if (record.task_id !== context.task.record.task_id ||
|
|
2010
|
+
record.task_root !== context.task.taskRoot ||
|
|
2011
|
+
record.domain_id !== context.task.record.domain_id ||
|
|
2012
|
+
record.domain_root !== context.task.record.domain_root ||
|
|
2013
|
+
record.created_at !== context.task.record.created_at)
|
|
2014
|
+
return { ok: false, reason: 'task_namespace_changed' };
|
|
2015
|
+
const request = context.request;
|
|
2016
|
+
const attempt = findAttempt(record, request.admission.subtask_id, request.admission.attempt);
|
|
2017
|
+
if (attempt === null ||
|
|
2018
|
+
attempt.assignment_ref !== context.attempt.assignment_ref ||
|
|
2019
|
+
attempt.assignment_digest !== context.assignmentDigest ||
|
|
2020
|
+
attempt.input_revision !== request.admission.input_revision ||
|
|
2021
|
+
attempt.owner_epoch !== context.attempt.owner_epoch ||
|
|
2022
|
+
record.coordinator?.owner_epoch !==
|
|
2023
|
+
context.assignment.coordinator.owner_epoch ||
|
|
2024
|
+
record.dispatch_frozen === true)
|
|
2025
|
+
return { ok: false, reason: 'attempt_or_coordinator_changed' };
|
|
2026
|
+
if (attempt.awaiting === 'approval')
|
|
2027
|
+
return { ok: false, reason: 'awaiting_approval' };
|
|
2028
|
+
if (!['assigned', 'accepted', 'running'].includes(attempt.state))
|
|
2029
|
+
return { ok: false, reason: 'attempt_not_startable' };
|
|
2030
|
+
if (attempt.cancel_requested_at != null ||
|
|
2031
|
+
hasPendingCancellation(record, request.admission.subtask_id, request.admission.attempt))
|
|
2032
|
+
return { ok: false, reason: 'cancel_unconfirmed' };
|
|
2033
|
+
const assignment = readAssignment(attempt.assignment_ref, attempt, record);
|
|
2034
|
+
if (!assignment.ok ||
|
|
2035
|
+
assignment.digest !== context.assignmentDigest ||
|
|
2036
|
+
assignment.file !== context.assignmentFile ||
|
|
2037
|
+
assignment.admissionDigest !== context.admissionAssignmentDigest)
|
|
2038
|
+
return { ok: false, reason: 'assignment_changed' };
|
|
2039
|
+
const card = readCapabilityCard(request.admission.capability_card_ref, request.admission.capability_card_digest);
|
|
2040
|
+
if (!card.ok)
|
|
2041
|
+
return { ok: false, reason: card.code };
|
|
2042
|
+
const preflight = runCollaborationPreflight({
|
|
2043
|
+
assignmentFile: assignment.file,
|
|
2044
|
+
capabilitiesFile: request.admission.capability_card_ref,
|
|
2045
|
+
maxAgeMinutes: 30,
|
|
2046
|
+
now: clock,
|
|
2047
|
+
});
|
|
2048
|
+
if (!preflight.ok)
|
|
2049
|
+
return { ok: false, reason: 'admission_not_ready' };
|
|
2050
|
+
const config = readPiBootstrapConfig(request.bootstrap.config_ref);
|
|
2051
|
+
if (!config.ok ||
|
|
2052
|
+
config.digest !== context.configDigest ||
|
|
2053
|
+
!safePathEqual(config.taskRoot, context.task.taskRoot))
|
|
2054
|
+
return { ok: false, reason: 'config_changed' };
|
|
2055
|
+
const association = matchConfigToTask(request, record, attempt, context.assignment, config.config);
|
|
2056
|
+
if (!association.ok)
|
|
2057
|
+
return { ok: false, reason: association.code };
|
|
2058
|
+
const bundle = verifyBootstrapBundle(request, context.configDigest, context.acceptedArtifactPath === null
|
|
2059
|
+
? {}
|
|
2060
|
+
: { acceptedArtifactPath: context.acceptedArtifactPath });
|
|
2061
|
+
if (!bundle.ok || bundle.bundle.digest !== context.bundle.digest)
|
|
2062
|
+
return {
|
|
2063
|
+
ok: false,
|
|
2064
|
+
reason: bundle.ok ? 'bundle_changed' : bundle.code,
|
|
2065
|
+
};
|
|
2066
|
+
const binding = readBinding(context.bindingFile, context.task.taskRoot, record.task_id);
|
|
2067
|
+
if (!binding.ok || binding.digest !== context.bindingDigest)
|
|
2068
|
+
return { ok: false, reason: 'binding_changed' };
|
|
2069
|
+
const route = messageAndBindingFailure(request, record, context.message, binding.binding);
|
|
2070
|
+
if (!route.ok)
|
|
2071
|
+
return { ok: false, reason: route.code };
|
|
2072
|
+
const messageRecord = (record.messages ?? []).find((entry) => entry.message_id === request.message_pointer.message_id &&
|
|
2073
|
+
entry.content_digest === context.message.content_digest &&
|
|
2074
|
+
['applied', 'recorded'].includes(entry.state));
|
|
2075
|
+
if (messageRecord === undefined)
|
|
2076
|
+
return { ok: false, reason: 'message_registration_changed' };
|
|
2077
|
+
const matches = (record.launch_reservations ?? []).filter((entry) => entry.reservation_id === reservationId);
|
|
2078
|
+
if (matches.length !== 1)
|
|
2079
|
+
return {
|
|
2080
|
+
ok: false,
|
|
2081
|
+
reason: 'reservation_missing_or_ambiguous',
|
|
2082
|
+
};
|
|
2083
|
+
const entry = matches[0];
|
|
2084
|
+
if (entry.released_at !== null ||
|
|
2085
|
+
entry.bootstrap?.['request_digest'] !== context.requestDigest)
|
|
2086
|
+
return { ok: false, reason: 'reservation_changed' };
|
|
2087
|
+
const at = new Date(clock).toISOString();
|
|
2088
|
+
entry.phase = phase;
|
|
2089
|
+
entry.bootstrap = {
|
|
2090
|
+
...entry.bootstrap,
|
|
2091
|
+
phase,
|
|
2092
|
+
effect_intent_at: at,
|
|
2093
|
+
effect_intent_request_digest: context.requestDigest,
|
|
2094
|
+
};
|
|
2095
|
+
return {
|
|
2096
|
+
record,
|
|
2097
|
+
op: 'visible-bootstrap-effect-intent',
|
|
2098
|
+
summary: { request_id: request.request_id, phase },
|
|
2099
|
+
result: { phase },
|
|
2100
|
+
};
|
|
2101
|
+
},
|
|
2102
|
+
});
|
|
2103
|
+
return outcome.ok
|
|
2104
|
+
? { ok: true, revision: outcome.state_revision }
|
|
2105
|
+
: { ok: false, code: outcome.reason, message: outcome.reason };
|
|
2106
|
+
}
|
|
2107
|
+
function guardedBootstrapTransport(context, reservationId, now, signal) {
|
|
2108
|
+
const underlying = context.transport;
|
|
2109
|
+
return {
|
|
2110
|
+
id: `${underlying.id}-visible-bootstrap-guard`,
|
|
2111
|
+
verifyCaller: (options) => underlying.verifyCaller(options),
|
|
2112
|
+
list: () => underlying.list(),
|
|
2113
|
+
prompt: async (agent, text, registered) => {
|
|
2114
|
+
if (agent !== context.herdrAgent)
|
|
2115
|
+
return {
|
|
2116
|
+
status: 'refused',
|
|
2117
|
+
detail: 'Bootstrap guard refused an unexpected Herdr recipient.',
|
|
2118
|
+
};
|
|
2119
|
+
const opened = await openMessageTask(context.task.taskRoot);
|
|
2120
|
+
if (!opened.ok)
|
|
2121
|
+
return {
|
|
2122
|
+
status: 'blocked',
|
|
2123
|
+
detail: `Bootstrap guard could not reopen the task (${opened.code}).`,
|
|
2124
|
+
};
|
|
2125
|
+
const namespace = await assertMessageTaskCurrent(opened.value);
|
|
2126
|
+
if (namespace !== null)
|
|
2127
|
+
return {
|
|
2128
|
+
status: 'blocked',
|
|
2129
|
+
detail: `Bootstrap guard found a changed task namespace (${namespace.code}).`,
|
|
2130
|
+
};
|
|
2131
|
+
const intent = commitEffectIntent(context, reservationId, opened.value.record.state_revision, opened.value.identity.recordFile, 'notify_prompt_pending', now(), signal);
|
|
2132
|
+
if (!intent.ok)
|
|
2133
|
+
return {
|
|
2134
|
+
status: 'blocked',
|
|
2135
|
+
detail: `Bootstrap guard blocked prompt (${intent.code}).`,
|
|
2136
|
+
};
|
|
2137
|
+
return underlying.prompt(agent, text, registered);
|
|
2138
|
+
},
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
function sanitizedReceipt(receipt) {
|
|
2142
|
+
return {
|
|
2143
|
+
operation: receipt.operation,
|
|
2144
|
+
argv: receipt.argv.slice(0, 24).map((item) => item.slice(0, 4096)),
|
|
2145
|
+
code: receipt.code,
|
|
2146
|
+
signal: receipt.signal,
|
|
2147
|
+
spawned: receipt.spawned,
|
|
2148
|
+
timed_out: receipt.timed_out,
|
|
2149
|
+
spawn_error: receipt.spawn_error?.slice(0, 512) ?? null,
|
|
2150
|
+
stdout_bytes: Buffer.byteLength(receipt.stdout),
|
|
2151
|
+
stdout_digest: digestText(receipt.stdout),
|
|
2152
|
+
stderr_bytes: Buffer.byteLength(receipt.stderr),
|
|
2153
|
+
stderr_digest: digestText(receipt.stderr),
|
|
2154
|
+
};
|
|
2155
|
+
}
|
|
2156
|
+
function persistReceipt(context, reservationId, now) {
|
|
2157
|
+
return (receipt) => {
|
|
2158
|
+
const saved = updateReservation(context, reservationId, {
|
|
2159
|
+
phase: `${receipt.operation}_receipt_recorded`,
|
|
2160
|
+
receipt,
|
|
2161
|
+
}, now());
|
|
2162
|
+
if (!saved.ok)
|
|
2163
|
+
throw new Error(`Cannot persist bounded ${receipt.operation} result (${saved.code}).`);
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
2166
|
+
async function revalidateCurrent(context, now) {
|
|
2167
|
+
const opened = await openMessageTask(context.task.taskRoot);
|
|
2168
|
+
if (!opened.ok)
|
|
2169
|
+
return { ok: false, code: opened.code, message: opened.message };
|
|
2170
|
+
const record = opened.value.record;
|
|
2171
|
+
if (!safePathEqual(opened.value.taskRoot, context.task.taskRoot) ||
|
|
2172
|
+
!sameDirectoryIdentity(context.task.identity.taskRoot, opened.value.taskRoot) ||
|
|
2173
|
+
!sameDirectoryIdentity(context.task.identity.messagesDirectory, opened.value.messagesDirectory) ||
|
|
2174
|
+
record.task_id !== context.task.record.task_id ||
|
|
2175
|
+
record.domain_id !== context.task.record.domain_id ||
|
|
2176
|
+
record.domain_root !== context.task.record.domain_root ||
|
|
2177
|
+
record.created_at !== context.task.record.created_at)
|
|
2178
|
+
return {
|
|
2179
|
+
ok: false,
|
|
2180
|
+
code: 'task_identity_changed',
|
|
2181
|
+
message: 'Current task root/identity changed.',
|
|
2182
|
+
};
|
|
2183
|
+
const attempt = findAttempt(record, context.request.admission.subtask_id, context.request.admission.attempt);
|
|
2184
|
+
if (attempt === null ||
|
|
2185
|
+
attempt.assignment_ref !== context.attempt.assignment_ref ||
|
|
2186
|
+
attempt.assignment_digest !== context.attempt.assignment_digest ||
|
|
2187
|
+
attempt.input_revision !== context.request.admission.input_revision ||
|
|
2188
|
+
attempt.owner_epoch !== context.attempt.owner_epoch ||
|
|
2189
|
+
record.coordinator?.owner_epoch !==
|
|
2190
|
+
context.assignment.coordinator.owner_epoch ||
|
|
2191
|
+
record.dispatch_frozen === true)
|
|
2192
|
+
return {
|
|
2193
|
+
ok: false,
|
|
2194
|
+
code: 'attempt_changed',
|
|
2195
|
+
message: 'Current attempt assignment/input/epoch or dispatch state changed after planning.',
|
|
2196
|
+
};
|
|
2197
|
+
if (attempt.awaiting === 'approval')
|
|
2198
|
+
return {
|
|
2199
|
+
ok: false,
|
|
2200
|
+
code: 'awaiting_approval',
|
|
2201
|
+
message: 'The admitted attempt is awaiting human approval.',
|
|
2202
|
+
};
|
|
2203
|
+
if (!['assigned', 'accepted', 'running'].includes(attempt.state))
|
|
2204
|
+
return {
|
|
2205
|
+
ok: false,
|
|
2206
|
+
code: 'attempt_not_startable',
|
|
2207
|
+
message: `Attempt state ${attempt.state} is not eligible for bootstrap.`,
|
|
2208
|
+
};
|
|
2209
|
+
if (attempt.cancel_requested_at != null ||
|
|
2210
|
+
hasPendingCancellation(record, context.request.admission.subtask_id, context.request.admission.attempt))
|
|
2211
|
+
return {
|
|
2212
|
+
ok: false,
|
|
2213
|
+
code: 'cancel_unconfirmed',
|
|
2214
|
+
message: 'A cancellation is pending or unconfirmed.',
|
|
2215
|
+
};
|
|
2216
|
+
const currentAssignment = readAssignment(attempt.assignment_ref, attempt, opened.value.record);
|
|
2217
|
+
if (!currentAssignment.ok ||
|
|
2218
|
+
currentAssignment.digest !== context.assignmentDigest ||
|
|
2219
|
+
currentAssignment.file !== context.assignmentFile ||
|
|
2220
|
+
currentAssignment.admissionDigest !== context.admissionAssignmentDigest)
|
|
2221
|
+
return {
|
|
2222
|
+
ok: false,
|
|
2223
|
+
code: 'assignment_changed',
|
|
2224
|
+
message: 'Pinned assignment changed after planning.',
|
|
2225
|
+
};
|
|
2226
|
+
const card = readCapabilityCard(context.request.admission.capability_card_ref, context.request.admission.capability_card_digest);
|
|
2227
|
+
if (!card.ok)
|
|
2228
|
+
return { ok: false, code: card.code, message: card.message };
|
|
2229
|
+
const preflight = runCollaborationPreflight({
|
|
2230
|
+
assignmentFile: currentAssignment.file,
|
|
2231
|
+
capabilitiesFile: context.request.admission.capability_card_ref,
|
|
2232
|
+
maxAgeMinutes: 30,
|
|
2233
|
+
now,
|
|
2234
|
+
});
|
|
2235
|
+
if (!preflight.ok)
|
|
2236
|
+
return {
|
|
2237
|
+
ok: false,
|
|
2238
|
+
code: 'admission_not_ready',
|
|
2239
|
+
message: `Current preflight is ${preflight.status}.`,
|
|
2240
|
+
};
|
|
2241
|
+
const currentConfig = readPiBootstrapConfig(context.request.bootstrap.config_ref);
|
|
2242
|
+
if (!currentConfig.ok ||
|
|
2243
|
+
currentConfig.digest !== context.configDigest ||
|
|
2244
|
+
!safePathEqual(currentConfig.taskRoot, opened.value.taskRoot))
|
|
2245
|
+
return {
|
|
2246
|
+
ok: false,
|
|
2247
|
+
code: 'config_changed',
|
|
2248
|
+
message: 'Pinned bootstrap config or task root changed after planning.',
|
|
2249
|
+
};
|
|
2250
|
+
const association = matchConfigToTask(context.request, record, attempt, context.assignment, currentConfig.config);
|
|
2251
|
+
if (!association.ok)
|
|
2252
|
+
return { ok: false, code: association.code, message: association.message };
|
|
2253
|
+
const bundle = verifyBootstrapBundle(context.request, context.configDigest, context.acceptedArtifactPath === null
|
|
2254
|
+
? {}
|
|
2255
|
+
: { acceptedArtifactPath: context.acceptedArtifactPath });
|
|
2256
|
+
if (!bundle.ok || bundle.bundle.digest !== context.bundle.digest)
|
|
2257
|
+
return {
|
|
2258
|
+
ok: false,
|
|
2259
|
+
code: bundle.ok ? 'bundle_changed' : bundle.code,
|
|
2260
|
+
message: bundle.ok
|
|
2261
|
+
? 'Pinned bundle changed after planning.'
|
|
2262
|
+
: bundle.message,
|
|
2263
|
+
};
|
|
2264
|
+
const pointer = await readRegisteredMessageSafe(opened.value, context.request.message_pointer.message_id);
|
|
2265
|
+
if (!pointer.ok ||
|
|
2266
|
+
pointer.value.digest !== context.request.message_pointer.content_digest ||
|
|
2267
|
+
pointer.value.file !== context.request.message_pointer.content_ref)
|
|
2268
|
+
return {
|
|
2269
|
+
ok: false,
|
|
2270
|
+
code: 'message_changed',
|
|
2271
|
+
message: 'Registered first-contact message changed after planning.',
|
|
2272
|
+
};
|
|
2273
|
+
const currentBinding = readBinding(context.bindingFile, opened.value.taskRoot, record.task_id);
|
|
2274
|
+
if (!currentBinding.ok || currentBinding.digest !== context.bindingDigest)
|
|
2275
|
+
return {
|
|
2276
|
+
ok: false,
|
|
2277
|
+
code: 'binding_changed',
|
|
2278
|
+
message: 'Task-local routing binding changed after planning.',
|
|
2279
|
+
};
|
|
2280
|
+
return { ok: true, task: opened.value };
|
|
2281
|
+
}
|
|
2282
|
+
function notificationSummary(result) {
|
|
2283
|
+
if (!result.ok)
|
|
2284
|
+
return {
|
|
2285
|
+
ok: false,
|
|
2286
|
+
status: 'failed',
|
|
2287
|
+
code: result.code,
|
|
2288
|
+
detail: result.message,
|
|
2289
|
+
};
|
|
2290
|
+
const value = result.value;
|
|
2291
|
+
return {
|
|
2292
|
+
ok: value.status === 'submitted' || value.status === 'already_recorded',
|
|
2293
|
+
status: value.status,
|
|
2294
|
+
message_id: value.messageId,
|
|
2295
|
+
message_digest: value.messageDigest,
|
|
2296
|
+
delivery_path: value.deliveryPath,
|
|
2297
|
+
attempts: value.attempts,
|
|
2298
|
+
consumed: false,
|
|
2299
|
+
task_accepted: false,
|
|
2300
|
+
detail: value.detail,
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2303
|
+
function liveCallerAgain(context) {
|
|
2304
|
+
return (async () => {
|
|
2305
|
+
const result = await context.ops.verifyCaller(context.request.caller.pane_id);
|
|
2306
|
+
if (!result.ok)
|
|
2307
|
+
return { ok: false, code: result.code, message: result.detail };
|
|
2308
|
+
const sender = bindingParticipant(context.binding, context.message.from.agent_id);
|
|
2309
|
+
if (sender === null ||
|
|
2310
|
+
!callerIdentityMatches(result.caller, sender, context.assignment, context.message.from))
|
|
2311
|
+
return {
|
|
2312
|
+
ok: false,
|
|
2313
|
+
code: 'caller_identity_changed',
|
|
2314
|
+
message: 'Caller pane/session/cwd changed before the next effect.',
|
|
2315
|
+
};
|
|
2316
|
+
const list = await context.ops.listAgents();
|
|
2317
|
+
if (!list.ok)
|
|
2318
|
+
return {
|
|
2319
|
+
ok: false,
|
|
2320
|
+
code: 'herdr_agent_list_unavailable',
|
|
2321
|
+
message: list.detail,
|
|
2322
|
+
};
|
|
2323
|
+
const live = liveParticipant(sender, context.assignment.assignee, list.agents);
|
|
2324
|
+
return live.ok
|
|
2325
|
+
? { ok: true, caller: result.caller }
|
|
2326
|
+
: { ok: false, code: live.code, message: live.message };
|
|
2327
|
+
})();
|
|
2328
|
+
}
|
|
2329
|
+
function paneSnapshotMatches(pane, expected) {
|
|
2330
|
+
return (pane.paneId === expected.paneId &&
|
|
2331
|
+
pane.workspaceId === expected.workspaceId &&
|
|
2332
|
+
pane.terminalId === expected.terminalId &&
|
|
2333
|
+
pane.cwd !== null &&
|
|
2334
|
+
expected.cwd !== null &&
|
|
2335
|
+
safePathEqual(pane.cwd, expected.cwd) &&
|
|
2336
|
+
pane.agent === null);
|
|
2337
|
+
}
|
|
2338
|
+
async function closeOwnedEmptyPane(context, reservationId, pane, now) {
|
|
2339
|
+
const current = await context.ops.paneGet(pane.paneId, READ_TIMEOUT_MS);
|
|
2340
|
+
if (!current.ok || !paneSnapshotMatches(current.value, pane))
|
|
2341
|
+
return {
|
|
2342
|
+
state: 'unknown_not_closed',
|
|
2343
|
+
code: current.ok ? 'owned_pane_identity_drift' : current.code,
|
|
2344
|
+
};
|
|
2345
|
+
const process = await context.ops.paneProcessInfo(pane.paneId, READ_TIMEOUT_MS);
|
|
2346
|
+
if (!process.ok)
|
|
2347
|
+
return { state: 'unknown_not_closed', code: process.code };
|
|
2348
|
+
const facts = process.value;
|
|
2349
|
+
const shellName = facts.foregroundProcesses.length === 1
|
|
2350
|
+
? facts.foregroundProcesses[0].name.split(/[\\/]/)
|
|
2351
|
+
.at(-1)
|
|
2352
|
+
.replace(/^[-]+/, '')
|
|
2353
|
+
.replace(/\.exe$/i, '')
|
|
2354
|
+
.toLowerCase()
|
|
2355
|
+
: null;
|
|
2356
|
+
if (facts.shellPid === null ||
|
|
2357
|
+
facts.shellPid <= 0 ||
|
|
2358
|
+
facts.foregroundProcessGroupId !== facts.shellPid ||
|
|
2359
|
+
facts.foregroundProcesses.length !== 1 ||
|
|
2360
|
+
facts.foregroundProcesses[0].pid !== facts.shellPid ||
|
|
2361
|
+
shellName === null ||
|
|
2362
|
+
!SHELL_NAMES.has(shellName))
|
|
2363
|
+
return {
|
|
2364
|
+
state: 'unknown_not_closed',
|
|
2365
|
+
code: 'owned_pane_not_available_shell',
|
|
2366
|
+
};
|
|
2367
|
+
const intent = updateReservation(context, reservationId, {
|
|
2368
|
+
phase: 'cleanup_close_pending',
|
|
2369
|
+
extra: {
|
|
2370
|
+
cleanup_pane_id: pane.paneId,
|
|
2371
|
+
cleanup_workspace_id: pane.workspaceId,
|
|
2372
|
+
cleanup_terminal_id: pane.terminalId,
|
|
2373
|
+
},
|
|
2374
|
+
}, now());
|
|
2375
|
+
if (!intent.ok)
|
|
2376
|
+
return { state: 'unknown_not_closed', code: intent.code };
|
|
2377
|
+
const finalPane = await context.ops.paneGet(pane.paneId, READ_TIMEOUT_MS);
|
|
2378
|
+
if (!finalPane.ok || !paneSnapshotMatches(finalPane.value, pane))
|
|
2379
|
+
return {
|
|
2380
|
+
state: 'unknown_not_closed',
|
|
2381
|
+
code: finalPane.ok ? 'owned_pane_changed_before_close' : finalPane.code,
|
|
2382
|
+
};
|
|
2383
|
+
const closed = await context.ops.closePane({
|
|
2384
|
+
paneId: pane.paneId,
|
|
2385
|
+
onReceipt: persistReceipt(context, reservationId, now),
|
|
2386
|
+
});
|
|
2387
|
+
return closed.ok
|
|
2388
|
+
? { state: 'closed_owned_pane', code: null }
|
|
2389
|
+
: {
|
|
2390
|
+
state: closed.status === 'unknown' ? 'unknown_not_closed' : 'close_failed',
|
|
2391
|
+
code: closed.code,
|
|
2392
|
+
};
|
|
2393
|
+
}
|
|
2394
|
+
function terminalReport(context, status, code, message, next, extra = {}) {
|
|
2395
|
+
return failureReport('apply', context.request, context.requestDigest, status, code, message, next, {
|
|
2396
|
+
task_id: context.task.record.task_id,
|
|
2397
|
+
caller: callerSummary(context),
|
|
2398
|
+
participant: participantSummary(context),
|
|
2399
|
+
bootstrap: {
|
|
2400
|
+
config_digest: context.configDigest,
|
|
2401
|
+
bundle_manifest_digest: context.bundle.digest,
|
|
2402
|
+
extension_entry: context.request.bootstrap.extension_entry,
|
|
2403
|
+
authority: 'default-deny-until-recorded-transfer-completes',
|
|
2404
|
+
model: 'daily_default_launch; selected_runtime_inheritance_unverified',
|
|
2405
|
+
},
|
|
2406
|
+
...extra,
|
|
2407
|
+
});
|
|
2408
|
+
}
|
|
2409
|
+
async function applySibling(context, reservationId, reservationRevision, dependencies, now) {
|
|
2410
|
+
const request = context.request;
|
|
2411
|
+
const steps = [];
|
|
2412
|
+
let revision = reservationRevision;
|
|
2413
|
+
const phase = (input, clock = now()) => {
|
|
2414
|
+
const updated = updateReservation(context, reservationId, input, clock);
|
|
2415
|
+
if (updated.ok)
|
|
2416
|
+
revision = updated.revision;
|
|
2417
|
+
return updated;
|
|
2418
|
+
};
|
|
2419
|
+
const callerNow = await liveCallerAgain(context);
|
|
2420
|
+
if (!callerNow.ok) {
|
|
2421
|
+
const released = phase({
|
|
2422
|
+
phase: 'caller_changed_before_split',
|
|
2423
|
+
outcome: 'not_started',
|
|
2424
|
+
release: true,
|
|
2425
|
+
detail: callerNow.code,
|
|
2426
|
+
});
|
|
2427
|
+
return terminalReport(context, 'needs_input', callerNow.code, callerNow.message, 'Run from the exact task-bound caller pane.', {
|
|
2428
|
+
state_revision: released.ok ? released.revision : revision,
|
|
2429
|
+
reservation: {
|
|
2430
|
+
reservation_id: reservationId,
|
|
2431
|
+
phase: 'not_started',
|
|
2432
|
+
outcome: 'not_started',
|
|
2433
|
+
},
|
|
2434
|
+
effects: [
|
|
2435
|
+
{ step: 'split', status: 'not_started', detail: callerNow.code },
|
|
2436
|
+
],
|
|
2437
|
+
});
|
|
2438
|
+
}
|
|
2439
|
+
const current = await revalidateCurrent(context, now());
|
|
2440
|
+
if (!current.ok) {
|
|
2441
|
+
const released = phase({
|
|
2442
|
+
phase: 'not_started_revalidated',
|
|
2443
|
+
outcome: 'not_started',
|
|
2444
|
+
release: true,
|
|
2445
|
+
detail: current.code,
|
|
2446
|
+
});
|
|
2447
|
+
return terminalReport(context, 'needs_input', current.code, current.message, 'Re-plan after reconciling the task/config/message state.', {
|
|
2448
|
+
state_revision: released.ok ? released.revision : revision,
|
|
2449
|
+
reservation: {
|
|
2450
|
+
reservation_id: reservationId,
|
|
2451
|
+
phase: 'not_started',
|
|
2452
|
+
outcome: 'not_started',
|
|
2453
|
+
},
|
|
2454
|
+
effects: [
|
|
2455
|
+
{ step: 'split', status: 'not_started', detail: current.code },
|
|
2456
|
+
],
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
const pending = commitEffectIntent(context, reservationId, current.task.record.state_revision, current.task.identity.recordFile, 'split_pending', now(), dependencies.signal);
|
|
2460
|
+
if (!pending.ok) {
|
|
2461
|
+
const released = phase({
|
|
2462
|
+
phase: 'split_intent_refused',
|
|
2463
|
+
outcome: 'not_started',
|
|
2464
|
+
release: true,
|
|
2465
|
+
detail: pending.code,
|
|
2466
|
+
});
|
|
2467
|
+
return terminalReport(context, 'needs_input', pending.code, pending.message, 'Reconcile current authority/cancellation state before a new request.', {
|
|
2468
|
+
state_revision: released.ok ? released.revision : revision,
|
|
2469
|
+
reservation: {
|
|
2470
|
+
reservation_id: reservationId,
|
|
2471
|
+
phase: 'not_started',
|
|
2472
|
+
outcome: 'not_started',
|
|
2473
|
+
},
|
|
2474
|
+
effects: [
|
|
2475
|
+
{ step: 'split', status: 'not_started', detail: pending.code },
|
|
2476
|
+
],
|
|
2477
|
+
});
|
|
2478
|
+
}
|
|
2479
|
+
revision = pending.revision;
|
|
2480
|
+
const split = await context.ops.splitPane({
|
|
2481
|
+
paneId: context.caller.paneId,
|
|
2482
|
+
cwd: request.target.cwd,
|
|
2483
|
+
direction: request.target.direction ?? 'right',
|
|
2484
|
+
onReceipt: persistReceipt(context, reservationId, now),
|
|
2485
|
+
});
|
|
2486
|
+
if (!split.ok) {
|
|
2487
|
+
const unknown = split.status === 'unknown';
|
|
2488
|
+
const saved = phase({
|
|
2489
|
+
phase: unknown ? 'split_unknown' : 'split_failed',
|
|
2490
|
+
outcome: unknown ? 'unknown' : 'not_started',
|
|
2491
|
+
release: !unknown,
|
|
2492
|
+
detail: split.code,
|
|
2493
|
+
});
|
|
2494
|
+
return terminalReport(context, unknown ? 'unknown' : 'failed', split.code, split.detail, 'Inspect the durable request and Herdr pane list; never retry a split with the same request id.', {
|
|
2495
|
+
state_revision: saved.ok ? saved.revision : revision,
|
|
2496
|
+
reservation: {
|
|
2497
|
+
reservation_id: reservationId,
|
|
2498
|
+
phase: unknown ? 'split_unknown' : 'split_failed',
|
|
2499
|
+
},
|
|
2500
|
+
effects: [{ step: 'split', status: split.status, detail: split.code }],
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
const pane = split.value;
|
|
2504
|
+
if (!PANE_ID.test(pane.paneId) ||
|
|
2505
|
+
pane.paneId === context.caller.paneId ||
|
|
2506
|
+
pane.workspaceId !== context.caller.workspaceId ||
|
|
2507
|
+
pane.agent !== null ||
|
|
2508
|
+
pane.cwd === null ||
|
|
2509
|
+
!safePathEqual(pane.cwd, request.target.cwd)) {
|
|
2510
|
+
const saved = phase({
|
|
2511
|
+
phase: 'split_identity_unknown',
|
|
2512
|
+
outcome: 'unknown',
|
|
2513
|
+
detail: 'split result identity mismatch',
|
|
2514
|
+
extra: { split_response_received: true },
|
|
2515
|
+
});
|
|
2516
|
+
return terminalReport(context, 'unknown', 'split_identity_unverified', 'Herdr returned a pane whose exact owned identity/cwd could not be confirmed; no guessed cleanup was attempted.', null, {
|
|
2517
|
+
state_revision: saved.ok ? saved.revision : revision,
|
|
2518
|
+
effects: [
|
|
2519
|
+
{ step: 'split', status: 'unknown', detail: 'identity/cwd mismatch' },
|
|
2520
|
+
],
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
const created = phase({
|
|
2524
|
+
phase: 'pane_created',
|
|
2525
|
+
extra: {
|
|
2526
|
+
created_pane_id: pane.paneId,
|
|
2527
|
+
created_workspace_id: pane.workspaceId,
|
|
2528
|
+
created_terminal_id: pane.terminalId,
|
|
2529
|
+
created_tab_id: pane.tabId,
|
|
2530
|
+
},
|
|
2531
|
+
});
|
|
2532
|
+
if (!created.ok)
|
|
2533
|
+
return terminalReport(context, 'unknown', created.code, created.message, null, {
|
|
2534
|
+
state_revision: revision,
|
|
2535
|
+
target: { ...request.target, pane_id: pane.paneId },
|
|
2536
|
+
});
|
|
2537
|
+
steps.push({
|
|
2538
|
+
step: 'split',
|
|
2539
|
+
status: 'completed',
|
|
2540
|
+
detail: `owned pane ${pane.paneId}`,
|
|
2541
|
+
});
|
|
2542
|
+
const readiness = await verifyAvailableShell(context.ops, {
|
|
2543
|
+
paneId: pane.paneId,
|
|
2544
|
+
workspaceId: pane.workspaceId,
|
|
2545
|
+
terminalId: pane.terminalId,
|
|
2546
|
+
cwd: request.target.cwd,
|
|
2547
|
+
}, {
|
|
2548
|
+
budgetMs: request.readiness?.budget_ms ?? 10_000,
|
|
2549
|
+
intervalMs: request.readiness?.interval_ms ?? 100,
|
|
2550
|
+
maxSamples: request.readiness?.max_samples ?? 100,
|
|
2551
|
+
clock: now,
|
|
2552
|
+
...(dependencies.sleeper === undefined
|
|
2553
|
+
? {}
|
|
2554
|
+
: { sleeper: dependencies.sleeper }),
|
|
2555
|
+
shouldAbort: () => dependencies.signal?.aborted === true,
|
|
2556
|
+
});
|
|
2557
|
+
const readyPhase = phase({
|
|
2558
|
+
phase: readiness.status === 'ready'
|
|
2559
|
+
? 'shell_ready'
|
|
2560
|
+
: `readiness_${readiness.status}`,
|
|
2561
|
+
detail: readiness.code,
|
|
2562
|
+
extra: { readiness: readiness.evidence },
|
|
2563
|
+
});
|
|
2564
|
+
if (readiness.status !== 'ready') {
|
|
2565
|
+
steps.push({
|
|
2566
|
+
step: 'shell-readiness',
|
|
2567
|
+
status: readiness.status,
|
|
2568
|
+
detail: readiness.code,
|
|
2569
|
+
});
|
|
2570
|
+
const cleanup = await closeOwnedEmptyPane(context, reservationId, pane, now);
|
|
2571
|
+
const cleanupConfirmed = cleanup.state === 'closed_owned_pane';
|
|
2572
|
+
const saved = phase({
|
|
2573
|
+
phase: `readiness_${readiness.status}_complete`,
|
|
2574
|
+
outcome: cleanupConfirmed ? 'not_started' : 'unknown',
|
|
2575
|
+
release: cleanupConfirmed,
|
|
2576
|
+
detail: readiness.code,
|
|
2577
|
+
extra: { cleanup: { state: cleanup.state, code: cleanup.code } },
|
|
2578
|
+
});
|
|
2579
|
+
const status = readiness.status === 'unknown' || !cleanupConfirmed
|
|
2580
|
+
? 'unknown'
|
|
2581
|
+
: 'failed';
|
|
2582
|
+
return terminalReport(context, status, `shell_readiness_${readiness.status}`, `No agent was started because the owned pane did not reach verified available-shell state (${readiness.code}).`, cleanupConfirmed
|
|
2583
|
+
? 'Inspect readiness samples and create a new request only after the pane is reconciled.'
|
|
2584
|
+
: 'Reconcile the owned pane; no automatic close/retry is permitted.', {
|
|
2585
|
+
state_revision: saved.ok
|
|
2586
|
+
? saved.revision
|
|
2587
|
+
: readyPhase.ok
|
|
2588
|
+
? readyPhase.revision
|
|
2589
|
+
: revision,
|
|
2590
|
+
target: { ...request.target, pane_id: pane.paneId },
|
|
2591
|
+
readiness: readiness.evidence,
|
|
2592
|
+
reservation: {
|
|
2593
|
+
reservation_id: reservationId,
|
|
2594
|
+
phase: `readiness_${readiness.status}`,
|
|
2595
|
+
cleanup: cleanup.state,
|
|
2596
|
+
},
|
|
2597
|
+
effects: [
|
|
2598
|
+
...steps,
|
|
2599
|
+
{ step: 'cleanup', status: cleanup.state, detail: cleanup.code },
|
|
2600
|
+
],
|
|
2601
|
+
});
|
|
2602
|
+
}
|
|
2603
|
+
steps.push({
|
|
2604
|
+
step: 'shell-readiness',
|
|
2605
|
+
status: 'ready',
|
|
2606
|
+
detail: `${readiness.evidence.attempts} samples in ${readiness.evidence.elapsed_ms}ms`,
|
|
2607
|
+
});
|
|
2608
|
+
const callerBeforeStart = await liveCallerAgain(context);
|
|
2609
|
+
const paneBeforeStart = await context.ops.paneGet(pane.paneId, READ_TIMEOUT_MS);
|
|
2610
|
+
const beforeStart = await revalidateCurrent(context, now());
|
|
2611
|
+
if (!callerBeforeStart.ok ||
|
|
2612
|
+
!paneBeforeStart.ok ||
|
|
2613
|
+
!paneSnapshotMatches(paneBeforeStart.value, pane) ||
|
|
2614
|
+
!beforeStart.ok) {
|
|
2615
|
+
const reason = !callerBeforeStart.ok
|
|
2616
|
+
? callerBeforeStart.code
|
|
2617
|
+
: !paneBeforeStart.ok
|
|
2618
|
+
? paneBeforeStart.code
|
|
2619
|
+
: !paneSnapshotMatches(paneBeforeStart.value, pane)
|
|
2620
|
+
? 'owned_pane_identity_drift'
|
|
2621
|
+
: !beforeStart.ok
|
|
2622
|
+
? beforeStart.code
|
|
2623
|
+
: 'revalidation_not_required';
|
|
2624
|
+
const cleanup = await closeOwnedEmptyPane(context, reservationId, pane, now);
|
|
2625
|
+
const saved = phase({
|
|
2626
|
+
phase: 'pre_start_revalidation_failed',
|
|
2627
|
+
outcome: cleanup.state === 'closed_owned_pane' ? 'not_started' : 'unknown',
|
|
2628
|
+
release: cleanup.state === 'closed_owned_pane',
|
|
2629
|
+
detail: reason,
|
|
2630
|
+
extra: { cleanup: { state: cleanup.state, code: cleanup.code } },
|
|
2631
|
+
});
|
|
2632
|
+
return terminalReport(context, cleanup.state === 'closed_owned_pane' ? 'needs_input' : 'unknown', reason, 'Caller/task/pane identity changed after readiness; agent start was not attempted.', 'Reconcile the task and owned pane before creating a new request.', {
|
|
2633
|
+
state_revision: saved.ok ? saved.revision : revision,
|
|
2634
|
+
target: { ...request.target, pane_id: pane.paneId },
|
|
2635
|
+
readiness: readiness.evidence,
|
|
2636
|
+
effects: [
|
|
2637
|
+
...steps,
|
|
2638
|
+
{ step: 'cleanup', status: cleanup.state, detail: cleanup.code },
|
|
2639
|
+
],
|
|
2640
|
+
});
|
|
2641
|
+
}
|
|
2642
|
+
const refuseManagedStart = async (code, detail) => {
|
|
2643
|
+
const cleanup = await closeOwnedEmptyPane(context, reservationId, pane, now);
|
|
2644
|
+
const cleanupConfirmed = cleanup.state === 'closed_owned_pane';
|
|
2645
|
+
const saved = phase({
|
|
2646
|
+
phase: 'managed_sdk_setup_failed',
|
|
2647
|
+
outcome: cleanupConfirmed ? 'not_started' : 'unknown',
|
|
2648
|
+
release: cleanupConfirmed,
|
|
2649
|
+
detail: code,
|
|
2650
|
+
extra: { cleanup: { state: cleanup.state, code: cleanup.code } },
|
|
2651
|
+
});
|
|
2652
|
+
return terminalReport(context, cleanupConfirmed ? 'needs_input' : 'unknown', code, detail, cleanupConfirmed
|
|
2653
|
+
? 'Correct the managed SDK paths/config and submit a fresh request id.'
|
|
2654
|
+
: 'Reconcile the owned pane; no automatic cleanup or retry is permitted.', {
|
|
2655
|
+
state_revision: saved.ok ? saved.revision : revision,
|
|
2656
|
+
target: { ...request.target, pane_id: pane.paneId },
|
|
2657
|
+
readiness: readiness.evidence,
|
|
2658
|
+
reservation: {
|
|
2659
|
+
reservation_id: reservationId,
|
|
2660
|
+
phase: 'managed_sdk_setup_failed',
|
|
2661
|
+
cleanup: cleanup.state,
|
|
2662
|
+
},
|
|
2663
|
+
effects: [
|
|
2664
|
+
...steps,
|
|
2665
|
+
{ step: 'managed-sdk-config', status: 'failed', detail: code },
|
|
2666
|
+
{ step: 'cleanup', status: cleanup.state, detail: cleanup.code },
|
|
2667
|
+
],
|
|
2668
|
+
});
|
|
2669
|
+
};
|
|
2670
|
+
const unknownManagedStart = (code, detail) => {
|
|
2671
|
+
const saved = phase({
|
|
2672
|
+
phase: 'managed_sdk_start_unknown',
|
|
2673
|
+
outcome: 'unknown',
|
|
2674
|
+
detail: code,
|
|
2675
|
+
});
|
|
2676
|
+
return terminalReport(context, 'unknown', code, detail, 'Inspect the managed SDK control response and owned pane; never re-submit this request id.', {
|
|
2677
|
+
state_revision: saved.ok ? saved.revision : revision,
|
|
2678
|
+
target: { ...request.target, pane_id: pane.paneId },
|
|
2679
|
+
readiness: readiness.evidence,
|
|
2680
|
+
reservation: {
|
|
2681
|
+
reservation_id: reservationId,
|
|
2682
|
+
phase: 'managed_sdk_start_unknown',
|
|
2683
|
+
},
|
|
2684
|
+
effects: [
|
|
2685
|
+
...steps,
|
|
2686
|
+
{ step: 'managed-sdk-start', status: 'unknown', detail: code },
|
|
2687
|
+
],
|
|
2688
|
+
});
|
|
2689
|
+
};
|
|
2690
|
+
const startPending = commitEffectIntent(context, reservationId, beforeStart.task.record.state_revision, beforeStart.task.identity.recordFile, request.managed_runtime === undefined
|
|
2691
|
+
? 'agent_start_pending'
|
|
2692
|
+
: 'managed_sdk_start_pending', now(), dependencies.signal);
|
|
2693
|
+
if (!startPending.ok) {
|
|
2694
|
+
const cleanup = await closeOwnedEmptyPane(context, reservationId, pane, now);
|
|
2695
|
+
const saved = phase({
|
|
2696
|
+
phase: 'start_intent_refused',
|
|
2697
|
+
outcome: cleanup.state === 'closed_owned_pane' ? 'not_started' : 'unknown',
|
|
2698
|
+
release: cleanup.state === 'closed_owned_pane',
|
|
2699
|
+
detail: startPending.code,
|
|
2700
|
+
extra: { cleanup: { state: cleanup.state, code: cleanup.code } },
|
|
2701
|
+
});
|
|
2702
|
+
return terminalReport(context, cleanup.state === 'closed_owned_pane' ? 'needs_input' : 'unknown', startPending.code, startPending.message, 'Reconcile current task authority and the owned pane before any new request.', {
|
|
2703
|
+
state_revision: saved.ok ? saved.revision : revision,
|
|
2704
|
+
target: { ...request.target, pane_id: pane.paneId },
|
|
2705
|
+
readiness: readiness.evidence,
|
|
2706
|
+
effects: [
|
|
2707
|
+
...steps,
|
|
2708
|
+
{ step: 'cleanup', status: cleanup.state, detail: cleanup.code },
|
|
2709
|
+
],
|
|
2710
|
+
});
|
|
2711
|
+
}
|
|
2712
|
+
revision = startPending.revision;
|
|
2713
|
+
let nativeSessionId;
|
|
2714
|
+
let managedLaunch = null;
|
|
2715
|
+
let managedConfigPath = null;
|
|
2716
|
+
let managedConfigDigest = null;
|
|
2717
|
+
if (request.managed_runtime !== undefined) {
|
|
2718
|
+
const runtimePaths = managedRuntimePathsFor(request);
|
|
2719
|
+
if (!runtimePaths.ok)
|
|
2720
|
+
return refuseManagedStart(runtimePaths.code, runtimePaths.message);
|
|
2721
|
+
const config = writeManagedSdkConfig(request, context, pane.paneId, runtimePaths.paths);
|
|
2722
|
+
if (!config.ok)
|
|
2723
|
+
return refuseManagedStart(config.code, 'The private immutable SDK configuration could not be created and verified.');
|
|
2724
|
+
managedConfigPath = runtimePaths.paths.configPath;
|
|
2725
|
+
managedConfigDigest = config.digest;
|
|
2726
|
+
const configRecorded = phase({
|
|
2727
|
+
phase: 'managed_sdk_start_pending',
|
|
2728
|
+
extra: {
|
|
2729
|
+
managed_sdk_config: {
|
|
2730
|
+
path: runtimePaths.paths.configPath,
|
|
2731
|
+
digest: config.digest,
|
|
2732
|
+
journal_path: runtimePaths.paths.journalPath,
|
|
2733
|
+
control_socket_path: runtimePaths.paths.controlSocketPath,
|
|
2734
|
+
session_id: request.participant.native_session_selector,
|
|
2735
|
+
model: {
|
|
2736
|
+
provider: 'openrouter',
|
|
2737
|
+
id: request.selected_model.openrouter_model,
|
|
2738
|
+
},
|
|
2739
|
+
bootstrap_extension_path: request.bootstrap.extension_entry,
|
|
2740
|
+
herdr_agent_name: context.herdrAgent,
|
|
2741
|
+
herdr_pane_id: pane.paneId,
|
|
2742
|
+
...(runtimePaths.paths.handoffContextFile === undefined
|
|
2743
|
+
? {}
|
|
2744
|
+
: { handoff_context_file: runtimePaths.paths.handoffContextFile }),
|
|
2745
|
+
},
|
|
2746
|
+
},
|
|
2747
|
+
});
|
|
2748
|
+
if (!configRecorded.ok)
|
|
2749
|
+
return refuseManagedStart(configRecorded.code, 'Managed SDK config was created but could not be bound to the durable reservation.');
|
|
2750
|
+
const startManagedSdk = context.ops.startManagedSdk;
|
|
2751
|
+
const model = request.selected_model.openrouter_model;
|
|
2752
|
+
if (startManagedSdk === undefined || model === undefined)
|
|
2753
|
+
return refuseManagedStart('managed_start_unavailable', 'No managed SDK start port or explicit OpenRouter model is available; legacy Pi fallback is forbidden.');
|
|
2754
|
+
const managedInput = {
|
|
2755
|
+
name: context.herdrAgent,
|
|
2756
|
+
paneId: pane.paneId,
|
|
2757
|
+
workspaceId: pane.workspaceId,
|
|
2758
|
+
terminalId: pane.terminalId,
|
|
2759
|
+
cwd: request.target.cwd,
|
|
2760
|
+
sessionId: request.participant.native_session_selector,
|
|
2761
|
+
openrouterModel: model,
|
|
2762
|
+
sdkModulePath: runtimePaths.paths.sdkModulePath,
|
|
2763
|
+
journalPath: runtimePaths.paths.journalPath,
|
|
2764
|
+
controlSocketPath: runtimePaths.paths.controlSocketPath,
|
|
2765
|
+
bootstrapExtensionPath: request.bootstrap.extension_entry,
|
|
2766
|
+
configPath: runtimePaths.paths.configPath,
|
|
2767
|
+
configDigest: config.digest,
|
|
2768
|
+
timeoutMs: context.request.target.start_timeout_ms ?? START_TIMEOUT_MS,
|
|
2769
|
+
...(runtimePaths.paths.automaticHandoffFile === undefined
|
|
2770
|
+
? {}
|
|
2771
|
+
: { automaticHandoffFile: runtimePaths.paths.automaticHandoffFile }),
|
|
2772
|
+
...(runtimePaths.paths.handoffContextFile === undefined
|
|
2773
|
+
? {}
|
|
2774
|
+
: { handoffContextFile: runtimePaths.paths.handoffContextFile }),
|
|
2775
|
+
onReceipt: persistReceipt(context, reservationId, now),
|
|
2776
|
+
};
|
|
2777
|
+
let startedManaged;
|
|
2778
|
+
try {
|
|
2779
|
+
startedManaged = await startManagedSdk(managedInput);
|
|
2780
|
+
}
|
|
2781
|
+
catch {
|
|
2782
|
+
return unknownManagedStart('managed_sdk_start_exception', 'Managed SDK start threw after submission may have begun; no retry or cleanup was attempted.');
|
|
2783
|
+
}
|
|
2784
|
+
if (!startedManaged.ok) {
|
|
2785
|
+
return startedManaged.status === 'unknown'
|
|
2786
|
+
? unknownManagedStart(startedManaged.code, startedManaged.detail)
|
|
2787
|
+
: refuseManagedStart(startedManaged.code, startedManaged.detail);
|
|
2788
|
+
}
|
|
2789
|
+
if (!acceptedManagedSdkStartReceipt(startedManaged.receipt, {
|
|
2790
|
+
paneId: pane.paneId,
|
|
2791
|
+
configPath: runtimePaths.paths.configPath,
|
|
2792
|
+
}))
|
|
2793
|
+
return unknownManagedStart('managed_sdk_start_receipt_unverified', 'The fixed managed CLI submission receipt did not match the exact pane/config command.');
|
|
2794
|
+
if (!managedSdkLaunchMatches(startedManaged.value, context, pane, runtimePaths.paths))
|
|
2795
|
+
return unknownManagedStart('managed_sdk_control_unverified', 'Herdr name/state, SDK control metadata, socket peer owner and pane foreground PID did not agree.');
|
|
2796
|
+
managedLaunch = startedManaged.value;
|
|
2797
|
+
nativeSessionId = startedManaged.value.sessionId;
|
|
2798
|
+
}
|
|
2799
|
+
else {
|
|
2800
|
+
const started = await context.ops.startAgent({
|
|
2801
|
+
name: context.herdrAgent,
|
|
2802
|
+
paneId: pane.paneId,
|
|
2803
|
+
sessionId: request.participant.native_session_selector,
|
|
2804
|
+
extensionEntry: request.bootstrap.extension_entry,
|
|
2805
|
+
timeoutMs: request.target.start_timeout_ms ?? START_TIMEOUT_MS,
|
|
2806
|
+
...(request.selected_model.openrouter_model
|
|
2807
|
+
? { openrouterModel: request.selected_model.openrouter_model }
|
|
2808
|
+
: {}),
|
|
2809
|
+
onReceipt: persistReceipt(context, reservationId, now),
|
|
2810
|
+
});
|
|
2811
|
+
if (!started.ok) {
|
|
2812
|
+
const cleanup = started.status === 'unknown'
|
|
2813
|
+
? null
|
|
2814
|
+
: await closeOwnedEmptyPane(context, reservationId, pane, now);
|
|
2815
|
+
const noEffect = cleanup?.state === 'closed_owned_pane';
|
|
2816
|
+
const saved = phase({
|
|
2817
|
+
phase: started.status === 'unknown'
|
|
2818
|
+
? 'agent_start_unknown'
|
|
2819
|
+
: 'agent_start_failed',
|
|
2820
|
+
outcome: noEffect ? 'not_started' : 'unknown',
|
|
2821
|
+
release: noEffect,
|
|
2822
|
+
detail: started.code,
|
|
2823
|
+
...(cleanup
|
|
2824
|
+
? { extra: { cleanup: { state: cleanup.state, code: cleanup.code } } }
|
|
2825
|
+
: {}),
|
|
2826
|
+
});
|
|
2827
|
+
return terminalReport(context, started.status === 'unknown' ? 'unknown' : 'failed', started.code, started.detail, 'Inspect the exact Herdr start result; do not retry or close a pane that may now contain an agent.', {
|
|
2828
|
+
state_revision: saved.ok ? saved.revision : revision,
|
|
2829
|
+
target: { ...request.target, pane_id: pane.paneId },
|
|
2830
|
+
readiness: readiness.evidence,
|
|
2831
|
+
effects: [
|
|
2832
|
+
...steps,
|
|
2833
|
+
{
|
|
2834
|
+
step: 'agent-start',
|
|
2835
|
+
status: started.status,
|
|
2836
|
+
detail: started.code,
|
|
2837
|
+
},
|
|
2838
|
+
],
|
|
2839
|
+
});
|
|
2840
|
+
}
|
|
2841
|
+
const native = reportedSessionIdentity(started.value.sessionKind, started.value.sessionValue);
|
|
2842
|
+
if (started.value.name !== context.herdrAgent ||
|
|
2843
|
+
started.value.paneId !== pane.paneId ||
|
|
2844
|
+
started.value.workspaceId !== pane.workspaceId ||
|
|
2845
|
+
started.value.terminalId !== pane.terminalId ||
|
|
2846
|
+
native.status !== 'match' ||
|
|
2847
|
+
native.identity !== request.participant.native_session_selector ||
|
|
2848
|
+
started.value.harness !== 'pi' ||
|
|
2849
|
+
started.value.cwd === null ||
|
|
2850
|
+
!safePathEqual(started.value.cwd, request.target.cwd) ||
|
|
2851
|
+
started.value.launchPending ||
|
|
2852
|
+
!acceptedPiArgv(started.value.argv, request.participant.native_session_selector, context.herdrAgent, request.bootstrap.extension_entry, request.selected_model.openrouter_model) ||
|
|
2853
|
+
!started.value.interactiveReady) {
|
|
2854
|
+
const saved = phase({
|
|
2855
|
+
phase: 'agent_identity_unknown',
|
|
2856
|
+
outcome: 'unknown',
|
|
2857
|
+
detail: 'exact successor identity not proven',
|
|
2858
|
+
extra: {
|
|
2859
|
+
created_pane_id: pane.paneId,
|
|
2860
|
+
observed_agent_name: started.value.name,
|
|
2861
|
+
observed_session_kind: started.value.sessionKind,
|
|
2862
|
+
observed_session_id: native.status === 'match' ? native.identity : null,
|
|
2863
|
+
},
|
|
2864
|
+
});
|
|
2865
|
+
return terminalReport(context, 'unknown', 'agent_identity_unverified', 'Agent start returned but the exact live successor identity/interactive state is unconfirmed; no binding or prompt was written.', null, {
|
|
2866
|
+
state_revision: saved.ok ? saved.revision : revision,
|
|
2867
|
+
target: { ...request.target, pane_id: pane.paneId },
|
|
2868
|
+
readiness: readiness.evidence,
|
|
2869
|
+
effects: [
|
|
2870
|
+
...steps,
|
|
2871
|
+
{
|
|
2872
|
+
step: 'agent-start',
|
|
2873
|
+
status: 'unknown',
|
|
2874
|
+
detail: 'identity mismatch',
|
|
2875
|
+
},
|
|
2876
|
+
],
|
|
2877
|
+
});
|
|
2878
|
+
}
|
|
2879
|
+
nativeSessionId = native.identity;
|
|
2880
|
+
}
|
|
2881
|
+
const callerAfterStart = await liveCallerAgain(context);
|
|
2882
|
+
const authorityAfterStart = await revalidateCurrent(context, now());
|
|
2883
|
+
if (!callerAfterStart.ok || !authorityAfterStart.ok) {
|
|
2884
|
+
const reason = !callerAfterStart.ok
|
|
2885
|
+
? callerAfterStart.code
|
|
2886
|
+
: authorityAfterStart.ok
|
|
2887
|
+
? 'post_start_revalidation_unexpected'
|
|
2888
|
+
: authorityAfterStart.code;
|
|
2889
|
+
const detail = !callerAfterStart.ok
|
|
2890
|
+
? callerAfterStart.message
|
|
2891
|
+
: authorityAfterStart.ok
|
|
2892
|
+
? 'Post-start authority unexpectedly passed the failure branch.'
|
|
2893
|
+
: authorityAfterStart.message;
|
|
2894
|
+
const saved = phase({
|
|
2895
|
+
phase: 'post_start_revalidation_failed',
|
|
2896
|
+
outcome: 'unknown',
|
|
2897
|
+
detail: reason,
|
|
2898
|
+
});
|
|
2899
|
+
return terminalReport(context, 'needs_input', reason, `${detail} The successor may be running but remains unbound and unprompted.`, 'Reconcile current task authority and the owned pane before creating a new request.', {
|
|
2900
|
+
state_revision: saved.ok ? saved.revision : revision,
|
|
2901
|
+
target: { ...request.target, pane_id: pane.paneId },
|
|
2902
|
+
readiness: readiness.evidence,
|
|
2903
|
+
effects: [
|
|
2904
|
+
...steps,
|
|
2905
|
+
{
|
|
2906
|
+
step: managedLaunch === null ? 'agent-start' : 'managed-sdk-start',
|
|
2907
|
+
status: 'blocked',
|
|
2908
|
+
detail: reason,
|
|
2909
|
+
},
|
|
2910
|
+
],
|
|
2911
|
+
});
|
|
2912
|
+
}
|
|
2913
|
+
const startedPhase = phase({
|
|
2914
|
+
phase: managedLaunch === null ? 'agent_started' : 'managed_sdk_started',
|
|
2915
|
+
extra: {
|
|
2916
|
+
created_pane_id: pane.paneId,
|
|
2917
|
+
created_workspace_id: pane.workspaceId,
|
|
2918
|
+
created_terminal_id: pane.terminalId,
|
|
2919
|
+
observed_session_id: nativeSessionId,
|
|
2920
|
+
...(managedLaunch === null
|
|
2921
|
+
? {}
|
|
2922
|
+
: {
|
|
2923
|
+
managed_sdk_control: {
|
|
2924
|
+
runtime: 'sdk',
|
|
2925
|
+
identity_provenance: managedLaunch.identityProvenance,
|
|
2926
|
+
control_schema: managedLaunch.controlSchema,
|
|
2927
|
+
process_pid: managedLaunch.processPid,
|
|
2928
|
+
socket_peer_pid: managedLaunch.socketPeerPid,
|
|
2929
|
+
foreground_process_pid: managedLaunch.foregroundProcessPid,
|
|
2930
|
+
model: managedLaunch.launch.model,
|
|
2931
|
+
journal_path: managedLaunch.launch.journalPath,
|
|
2932
|
+
config_path: managedConfigPath,
|
|
2933
|
+
config_digest: managedConfigDigest,
|
|
2934
|
+
},
|
|
2935
|
+
}),
|
|
2936
|
+
},
|
|
2937
|
+
});
|
|
2938
|
+
if (!startedPhase.ok)
|
|
2939
|
+
return terminalReport(context, 'unknown', startedPhase.code, startedPhase.message, null, { readiness: readiness.evidence, state_revision: revision });
|
|
2940
|
+
steps.push({
|
|
2941
|
+
step: managedLaunch === null ? 'agent-start' : 'managed-sdk-start',
|
|
2942
|
+
status: 'completed',
|
|
2943
|
+
detail: managedLaunch === null
|
|
2944
|
+
? 'Pi agent start matched exact native session id, extension argv, model selection and interactive readiness.'
|
|
2945
|
+
: 'Managed SDK launch matched authenticated control, actual model/session/config metadata, socket peer owner and pane foreground PID; Herdr session-report ID was not used.',
|
|
2946
|
+
});
|
|
2947
|
+
const bindingPhase = phase({ phase: 'binding_pending' });
|
|
2948
|
+
if (!bindingPhase.ok)
|
|
2949
|
+
return terminalReport(context, 'unknown', bindingPhase.code, bindingPhase.message, null, { readiness: readiness.evidence, state_revision: revision });
|
|
2950
|
+
const participant = {
|
|
2951
|
+
agent_id: request.participant.agent_id,
|
|
2952
|
+
harness: request.participant.harness,
|
|
2953
|
+
cwd: request.target.cwd,
|
|
2954
|
+
herdr_agent: context.herdrAgent,
|
|
2955
|
+
pane_id: pane.paneId,
|
|
2956
|
+
native_session_id: nativeSessionId,
|
|
2957
|
+
};
|
|
2958
|
+
let binding;
|
|
2959
|
+
try {
|
|
2960
|
+
if (context.binding.participants.some((item) => item.agent_id === participant.agent_id ||
|
|
2961
|
+
item.herdr_agent === participant.herdr_agent))
|
|
2962
|
+
throw new Error('binding_participant_conflict');
|
|
2963
|
+
binding = {
|
|
2964
|
+
...context.binding,
|
|
2965
|
+
participants: [...context.binding.participants, participant],
|
|
2966
|
+
};
|
|
2967
|
+
}
|
|
2968
|
+
catch {
|
|
2969
|
+
const saved = phase({
|
|
2970
|
+
phase: 'binding_conflict',
|
|
2971
|
+
outcome: 'unknown',
|
|
2972
|
+
detail: 'task-local participant conflict',
|
|
2973
|
+
});
|
|
2974
|
+
return terminalReport(context, 'needs_input', 'binding_participant_conflict', 'Agent started but binding conflicts; it is left running and no notification was sent.', null, {
|
|
2975
|
+
state_revision: saved.ok ? saved.revision : revision,
|
|
2976
|
+
target: { ...request.target, pane_id: pane.paneId },
|
|
2977
|
+
readiness: readiness.evidence,
|
|
2978
|
+
effects: [
|
|
2979
|
+
...steps,
|
|
2980
|
+
{ step: 'task-binding', status: 'conflict', detail: null },
|
|
2981
|
+
],
|
|
2982
|
+
});
|
|
2983
|
+
}
|
|
2984
|
+
const savedBinding = writeBindingExclusive(context.task.taskRoot, context.derivedBindingFile, binding);
|
|
2985
|
+
if (!savedBinding.ok) {
|
|
2986
|
+
const saved = phase({
|
|
2987
|
+
phase: 'binding_write_failed',
|
|
2988
|
+
outcome: 'unknown',
|
|
2989
|
+
detail: savedBinding.code,
|
|
2990
|
+
});
|
|
2991
|
+
return terminalReport(context, 'unknown', savedBinding.code, 'Agent started but its task-local routing binding could not be persisted; no notification was sent.', 'Reconcile the running agent and binding before any new request.', {
|
|
2992
|
+
state_revision: saved.ok ? saved.revision : revision,
|
|
2993
|
+
target: { ...request.target, pane_id: pane.paneId },
|
|
2994
|
+
readiness: readiness.evidence,
|
|
2995
|
+
effects: [
|
|
2996
|
+
...steps,
|
|
2997
|
+
{ step: 'task-binding', status: 'failed', detail: savedBinding.code },
|
|
2998
|
+
],
|
|
2999
|
+
});
|
|
3000
|
+
}
|
|
3001
|
+
const bindingStored = phase({
|
|
3002
|
+
phase: 'binding_written',
|
|
3003
|
+
extra: { derived_binding_digest: savedBinding.digest },
|
|
3004
|
+
});
|
|
3005
|
+
if (!bindingStored.ok)
|
|
3006
|
+
return terminalReport(context, 'unknown', bindingStored.code, bindingStored.message, null, {
|
|
3007
|
+
target: { ...request.target, pane_id: pane.paneId },
|
|
3008
|
+
readiness: readiness.evidence,
|
|
3009
|
+
effects: [
|
|
3010
|
+
...steps,
|
|
3011
|
+
{
|
|
3012
|
+
step: 'task-binding',
|
|
3013
|
+
status: 'written_unrecorded',
|
|
3014
|
+
detail: context.derivedBindingFile,
|
|
3015
|
+
},
|
|
3016
|
+
],
|
|
3017
|
+
});
|
|
3018
|
+
steps.push({
|
|
3019
|
+
step: 'task-binding',
|
|
3020
|
+
status: 'persisted',
|
|
3021
|
+
detail: 'Task-local routing metadata written after exact live identity; it grants no authority.',
|
|
3022
|
+
});
|
|
3023
|
+
const callerBeforeNotify = await liveCallerAgain(context);
|
|
3024
|
+
const recipientAgents = await context.ops.listAgents();
|
|
3025
|
+
const recipientLive = recipientAgents.ok
|
|
3026
|
+
? liveParticipant(participant, {
|
|
3027
|
+
agent_id: request.participant.agent_id,
|
|
3028
|
+
session_id: request.participant.native_session_selector,
|
|
3029
|
+
harness: request.participant.harness,
|
|
3030
|
+
}, recipientAgents.agents)
|
|
3031
|
+
: {
|
|
3032
|
+
ok: false,
|
|
3033
|
+
code: 'herdr_agent_list_unavailable',
|
|
3034
|
+
message: recipientAgents.detail,
|
|
3035
|
+
};
|
|
3036
|
+
const revalidated = await revalidateCurrent(context, now());
|
|
3037
|
+
if (!callerBeforeNotify.ok ||
|
|
3038
|
+
!recipientAgents.ok ||
|
|
3039
|
+
!recipientLive.ok ||
|
|
3040
|
+
!revalidated.ok) {
|
|
3041
|
+
const reason = !callerBeforeNotify.ok
|
|
3042
|
+
? callerBeforeNotify.code
|
|
3043
|
+
: !recipientAgents.ok
|
|
3044
|
+
? 'herdr_agent_list_unavailable'
|
|
3045
|
+
: !recipientLive.ok
|
|
3046
|
+
? recipientLive.code
|
|
3047
|
+
: revalidated.ok
|
|
3048
|
+
? 'revalidation_unexpected'
|
|
3049
|
+
: revalidated.code;
|
|
3050
|
+
const detail = !callerBeforeNotify.ok
|
|
3051
|
+
? callerBeforeNotify.message
|
|
3052
|
+
: !recipientAgents.ok
|
|
3053
|
+
? recipientAgents.detail
|
|
3054
|
+
: !recipientLive.ok
|
|
3055
|
+
? recipientLive.message
|
|
3056
|
+
: revalidated.ok
|
|
3057
|
+
? 'Task revalidation unexpectedly passed.'
|
|
3058
|
+
: revalidated.message;
|
|
3059
|
+
const saved = phase({
|
|
3060
|
+
phase: 'notify_revalidation_failed',
|
|
3061
|
+
outcome: 'unknown',
|
|
3062
|
+
detail: reason,
|
|
3063
|
+
});
|
|
3064
|
+
return terminalReport(context, 'needs_input', reason, detail, 'Reconcile the caller, successor and current task before issuing a new request.', {
|
|
3065
|
+
state_revision: saved.ok ? saved.revision : revision,
|
|
3066
|
+
target: { ...request.target, pane_id: pane.paneId },
|
|
3067
|
+
readiness: readiness.evidence,
|
|
3068
|
+
effects: [
|
|
3069
|
+
...steps,
|
|
3070
|
+
{ step: 'notify', status: 'not_started', detail: reason },
|
|
3071
|
+
],
|
|
3072
|
+
});
|
|
3073
|
+
}
|
|
3074
|
+
const notificationPending = commitEffectIntent(context, reservationId, revalidated.task.record.state_revision, revalidated.task.identity.recordFile, 'notify_pending', now(), dependencies.signal);
|
|
3075
|
+
if (!notificationPending.ok) {
|
|
3076
|
+
const saved = phase({
|
|
3077
|
+
phase: 'notify_intent_refused',
|
|
3078
|
+
outcome: 'unknown',
|
|
3079
|
+
detail: notificationPending.code,
|
|
3080
|
+
});
|
|
3081
|
+
return terminalReport(context, 'needs_input', notificationPending.code, notificationPending.message, 'Reconcile current task authority before sending the registered message.', {
|
|
3082
|
+
state_revision: saved.ok ? saved.revision : revision,
|
|
3083
|
+
target: { ...request.target, pane_id: pane.paneId },
|
|
3084
|
+
readiness: readiness.evidence,
|
|
3085
|
+
effects: [
|
|
3086
|
+
...steps,
|
|
3087
|
+
{
|
|
3088
|
+
step: 'notify',
|
|
3089
|
+
status: 'not_started',
|
|
3090
|
+
detail: notificationPending.code,
|
|
3091
|
+
},
|
|
3092
|
+
],
|
|
3093
|
+
});
|
|
3094
|
+
}
|
|
3095
|
+
revision = notificationPending.revision;
|
|
3096
|
+
const notify = dependencies.notify ?? notifyRegisteredMessage;
|
|
3097
|
+
const notification = await notify({
|
|
3098
|
+
taskRoot: context.task.taskRoot,
|
|
3099
|
+
messageId: request.message_pointer.message_id,
|
|
3100
|
+
bindingFile: context.derivedBindingFile,
|
|
3101
|
+
apply: true,
|
|
3102
|
+
retry: false,
|
|
3103
|
+
callerPane: context.caller.paneId,
|
|
3104
|
+
transport: guardedBootstrapTransport(context, reservationId, now, dependencies.signal),
|
|
3105
|
+
clock: now(),
|
|
3106
|
+
});
|
|
3107
|
+
const summary = notificationSummary(notification);
|
|
3108
|
+
const submitted = notification.ok && notification.value.status === 'submitted';
|
|
3109
|
+
const recorded = notification.ok && notification.value.status === 'already_recorded';
|
|
3110
|
+
const final = phase({
|
|
3111
|
+
phase: submitted || recorded ? 'completed' : 'notification_unknown',
|
|
3112
|
+
outcome: submitted
|
|
3113
|
+
? 'submitted'
|
|
3114
|
+
: recorded
|
|
3115
|
+
? 'already_recorded'
|
|
3116
|
+
: 'unknown',
|
|
3117
|
+
release: submitted || recorded,
|
|
3118
|
+
detail: notification.ok ? notification.value.detail : notification.message,
|
|
3119
|
+
extra: {
|
|
3120
|
+
notification: summary,
|
|
3121
|
+
message_consumed: false,
|
|
3122
|
+
task_accepted: false,
|
|
3123
|
+
},
|
|
3124
|
+
});
|
|
3125
|
+
steps.push({
|
|
3126
|
+
step: 'notify-registered-message',
|
|
3127
|
+
status: submitted ? 'submitted' : recorded ? 'already_recorded' : 'unknown',
|
|
3128
|
+
detail: typeof summary['detail'] === 'string' ? summary['detail'] : null,
|
|
3129
|
+
});
|
|
3130
|
+
if (!submitted && !recorded)
|
|
3131
|
+
return terminalReport(context, 'unknown', 'notification_not_confirmed', 'Registered-message submission was not confirmed; no second prompt or automatic retry was attempted.', 'Inspect the persisted delivery/reservation state before any new request.', {
|
|
3132
|
+
state_revision: final.ok ? final.revision : revision,
|
|
3133
|
+
target: { ...request.target, pane_id: pane.paneId },
|
|
3134
|
+
readiness: readiness.evidence,
|
|
3135
|
+
reservation: {
|
|
3136
|
+
reservation_id: reservationId,
|
|
3137
|
+
phase: 'notification_unknown',
|
|
3138
|
+
},
|
|
3139
|
+
notification: summary,
|
|
3140
|
+
effects: steps,
|
|
3141
|
+
});
|
|
3142
|
+
const status = submitted ? 'applied' : 'already_recorded';
|
|
3143
|
+
return successReport('apply', request, context.requestDigest, status, {
|
|
3144
|
+
task_id: context.task.record.task_id,
|
|
3145
|
+
state_revision: final.ok ? final.revision : revision,
|
|
3146
|
+
caller: callerSummary(context),
|
|
3147
|
+
participant: participantSummary(context),
|
|
3148
|
+
target: { ...request.target, pane_id: pane.paneId },
|
|
3149
|
+
bootstrap: {
|
|
3150
|
+
config_digest: context.configDigest,
|
|
3151
|
+
bundle_manifest_digest: context.bundle.digest,
|
|
3152
|
+
extension_entry: request.bootstrap.extension_entry,
|
|
3153
|
+
runtime: managedLaunch === null
|
|
3154
|
+
? 'accepted_artifact_digest_match'
|
|
3155
|
+
: 'managed_sdk_control_observed',
|
|
3156
|
+
authority: 'default-deny-until-recorded-transfer-completes',
|
|
3157
|
+
gate_registration: 'not_independently_observed',
|
|
3158
|
+
bootstrap_extension_load: managedLaunch === null
|
|
3159
|
+
? 'not_independently_observed'
|
|
3160
|
+
: 'control_metadata_observed',
|
|
3161
|
+
model: managedLaunch === null
|
|
3162
|
+
? 'daily_default_launch; selected_runtime_inheritance_unverified'
|
|
3163
|
+
: {
|
|
3164
|
+
...managedLaunch.launch.model,
|
|
3165
|
+
observation: 'owned-pi-sdk-control',
|
|
3166
|
+
},
|
|
3167
|
+
...(managedLaunch === null
|
|
3168
|
+
? {}
|
|
3169
|
+
: {
|
|
3170
|
+
managed_sdk_control: {
|
|
3171
|
+
identity_provenance: managedLaunch.identityProvenance,
|
|
3172
|
+
control_schema: managedLaunch.controlSchema,
|
|
3173
|
+
session_id: managedLaunch.sessionId,
|
|
3174
|
+
process_pid: managedLaunch.processPid,
|
|
3175
|
+
socket_peer_pid: managedLaunch.socketPeerPid,
|
|
3176
|
+
foreground_process_pid: managedLaunch.foregroundProcessPid,
|
|
3177
|
+
herdr_agent_name: managedLaunch.launch.herdrAgentName,
|
|
3178
|
+
herdr_pane_id: managedLaunch.paneId,
|
|
3179
|
+
sdk_module_path: managedLaunch.launch.sdkModulePath,
|
|
3180
|
+
bootstrap_extension_path: managedLaunch.launch.bootstrapExtensionPath,
|
|
3181
|
+
journal_path: managedLaunch.launch.journalPath,
|
|
3182
|
+
managed_config_path: managedConfigPath,
|
|
3183
|
+
managed_config_digest: managedConfigDigest,
|
|
3184
|
+
handoff_context_file: request.managed_runtime?.handoff_context_file ?? null,
|
|
3185
|
+
ready: managedLaunch.launch.ready,
|
|
3186
|
+
},
|
|
3187
|
+
}),
|
|
3188
|
+
},
|
|
3189
|
+
reservation: {
|
|
3190
|
+
reservation_id: reservationId,
|
|
3191
|
+
phase: 'completed',
|
|
3192
|
+
outcome: submitted ? 'submitted' : 'already_recorded',
|
|
3193
|
+
},
|
|
3194
|
+
readiness: readiness.evidence,
|
|
3195
|
+
effects: steps,
|
|
3196
|
+
notification: summary,
|
|
3197
|
+
limits: [
|
|
3198
|
+
managedLaunch === null
|
|
3199
|
+
? 'agent_start_and_extension_argument_do_not_prove_gate_registration'
|
|
3200
|
+
: 'managed_sdk_extension_path_observed_but_gate_registration_not_independently_proven',
|
|
3201
|
+
'new_successor_capabilities_are_not_synthesized',
|
|
3202
|
+
'message_submitted_not_consumed_or_accepted',
|
|
3203
|
+
'no_old_writer_quiescence_or_native_archive_proof',
|
|
3204
|
+
...(managedLaunch === null
|
|
3205
|
+
? ['selected_runtime_model_inheritance_unverified']
|
|
3206
|
+
: []),
|
|
3207
|
+
],
|
|
3208
|
+
});
|
|
3209
|
+
}
|
|
3210
|
+
async function applyReuse(context, reservationId, reservationRevision, dependencies, now) {
|
|
3211
|
+
const request = context.request;
|
|
3212
|
+
const existing = bindingParticipant(context.binding, request.participant.agent_id);
|
|
3213
|
+
if (existing === null)
|
|
3214
|
+
return terminalReport(context, 'needs_input', 'unsupported_reuse', 'Exact task-bound participant is absent.', null);
|
|
3215
|
+
let revision = reservationRevision;
|
|
3216
|
+
const caller = await liveCallerAgain(context);
|
|
3217
|
+
if (!caller.ok)
|
|
3218
|
+
return terminalReport(context, 'needs_input', caller.code, caller.message, 'Run from the exact bound caller pane.', { reservation: { reservation_id: reservationId, phase: 'reserved' } });
|
|
3219
|
+
const listed = await context.ops.listAgents();
|
|
3220
|
+
if (!listed.ok)
|
|
3221
|
+
return terminalReport(context, 'unknown', 'herdr_agent_list_unavailable', listed.detail, null, { reservation: { reservation_id: reservationId, phase: 'reserved' } });
|
|
3222
|
+
const recipient = liveParticipant(existing, {
|
|
3223
|
+
agent_id: request.participant.agent_id,
|
|
3224
|
+
session_id: request.participant.native_session_selector,
|
|
3225
|
+
harness: request.participant.harness,
|
|
3226
|
+
}, listed.agents);
|
|
3227
|
+
if (!recipient.ok)
|
|
3228
|
+
return terminalReport(context, 'needs_input', 'unsupported_reuse', recipient.message, 'Existing-participant reuse never falls back to a split or starts another agent.', { target: { ...request.target, pane_id: existing.pane_id } });
|
|
3229
|
+
const current = await revalidateCurrent(context, now());
|
|
3230
|
+
if (!current.ok)
|
|
3231
|
+
return terminalReport(context, 'needs_input', current.code, current.message, 'Re-plan against current task and cancellation state.', { target: { ...request.target, pane_id: existing.pane_id } });
|
|
3232
|
+
const intent = commitEffectIntent(context, reservationId, current.task.record.state_revision, current.task.identity.recordFile, 'notify_pending', now(), dependencies.signal);
|
|
3233
|
+
if (!intent.ok)
|
|
3234
|
+
return terminalReport(context, 'needs_input', intent.code, intent.message, 'Reconcile current task authority/cancellation state before sending the message.', {
|
|
3235
|
+
reservation: {
|
|
3236
|
+
reservation_id: reservationId,
|
|
3237
|
+
phase: 'notify_intent_refused',
|
|
3238
|
+
},
|
|
3239
|
+
});
|
|
3240
|
+
revision = intent.revision;
|
|
3241
|
+
const notify = dependencies.notify ?? notifyRegisteredMessage;
|
|
3242
|
+
const result = await notify({
|
|
3243
|
+
taskRoot: context.task.taskRoot,
|
|
3244
|
+
messageId: request.message_pointer.message_id,
|
|
3245
|
+
bindingFile: context.bindingFile,
|
|
3246
|
+
apply: true,
|
|
3247
|
+
retry: false,
|
|
3248
|
+
callerPane: context.caller.paneId,
|
|
3249
|
+
transport: guardedBootstrapTransport(context, reservationId, now, dependencies.signal),
|
|
3250
|
+
clock: now(),
|
|
3251
|
+
});
|
|
3252
|
+
const summary = notificationSummary(result);
|
|
3253
|
+
const submitted = result.ok && result.value.status === 'submitted';
|
|
3254
|
+
const recorded = result.ok && result.value.status === 'already_recorded';
|
|
3255
|
+
const final = updateReservation(context, reservationId, {
|
|
3256
|
+
phase: submitted || recorded ? 'completed' : 'notification_unknown',
|
|
3257
|
+
outcome: submitted
|
|
3258
|
+
? 'submitted'
|
|
3259
|
+
: recorded
|
|
3260
|
+
? 'already_recorded'
|
|
3261
|
+
: 'unknown',
|
|
3262
|
+
release: submitted || recorded,
|
|
3263
|
+
detail: result.ok ? result.value.detail : result.message,
|
|
3264
|
+
extra: { notification: summary },
|
|
3265
|
+
}, now());
|
|
3266
|
+
const step = {
|
|
3267
|
+
step: 'reuse-and-notify',
|
|
3268
|
+
status: submitted ? 'submitted' : recorded ? 'already_recorded' : 'unknown',
|
|
3269
|
+
detail: typeof summary['detail'] === 'string' ? summary['detail'] : null,
|
|
3270
|
+
};
|
|
3271
|
+
if (!submitted && !recorded)
|
|
3272
|
+
return terminalReport(context, 'unknown', 'reuse_notification_unconfirmed', 'Existing participant notification was not confirmed; no retry or new agent was started.', 'Inspect the original delivery/reservation before any new request.', {
|
|
3273
|
+
state_revision: final.ok ? final.revision : revision,
|
|
3274
|
+
target: { ...request.target, pane_id: existing.pane_id },
|
|
3275
|
+
reservation: {
|
|
3276
|
+
reservation_id: reservationId,
|
|
3277
|
+
phase: 'notification_unknown',
|
|
3278
|
+
},
|
|
3279
|
+
effects: [step],
|
|
3280
|
+
notification: summary,
|
|
3281
|
+
});
|
|
3282
|
+
return successReport('apply', request, context.requestDigest, submitted ? 'applied' : 'already_recorded', {
|
|
3283
|
+
task_id: context.task.record.task_id,
|
|
3284
|
+
state_revision: final.ok ? final.revision : revision,
|
|
3285
|
+
caller: callerSummary(context),
|
|
3286
|
+
participant: {
|
|
3287
|
+
agent_id: request.participant.agent_id,
|
|
3288
|
+
harness: request.participant.harness,
|
|
3289
|
+
pane_id: existing.pane_id,
|
|
3290
|
+
session_id: request.participant.native_session_selector,
|
|
3291
|
+
capabilities: 'runtime_card_checked',
|
|
3292
|
+
},
|
|
3293
|
+
target: { ...request.target, pane_id: existing.pane_id },
|
|
3294
|
+
bootstrap: {
|
|
3295
|
+
config_digest: context.configDigest,
|
|
3296
|
+
bundle_manifest_digest: context.bundle.digest,
|
|
3297
|
+
gate_registration: 'existing_session_not_independently_observed',
|
|
3298
|
+
},
|
|
3299
|
+
reservation: {
|
|
3300
|
+
reservation_id: reservationId,
|
|
3301
|
+
phase: 'completed',
|
|
3302
|
+
outcome: submitted ? 'submitted' : 'already_recorded',
|
|
3303
|
+
},
|
|
3304
|
+
effects: [step],
|
|
3305
|
+
notification: summary,
|
|
3306
|
+
limits: [
|
|
3307
|
+
'reuse_did_not_query_process_info_or_wait_for_shell',
|
|
3308
|
+
'reuse_did_not_start_or_close_an_agent',
|
|
3309
|
+
'message_submitted_not_consumed_or_accepted',
|
|
3310
|
+
],
|
|
3311
|
+
});
|
|
3312
|
+
}
|
|
3313
|
+
export async function planVisibleBootstrap(input, dependencies = {}) {
|
|
3314
|
+
const request = requestOf(input);
|
|
3315
|
+
const prepared = await prepare(input, 'plan', dependencies);
|
|
3316
|
+
if (!prepared.ok)
|
|
3317
|
+
return prepared.report;
|
|
3318
|
+
const prior = reservationFor(prepared.context.task.record, request.request_id);
|
|
3319
|
+
if (prior === 'ambiguous')
|
|
3320
|
+
return failureReport('plan', request, requestDigestOf(input), 'unknown', 'request_reservation_ambiguous', 'Multiple durable reservations already use this request id.');
|
|
3321
|
+
if (prior !== null && !deadNoEffectBootstrap(prior)) {
|
|
3322
|
+
const digest = bootstrapData(prior)['request_digest'];
|
|
3323
|
+
if (digest !== requestDigestOf(input))
|
|
3324
|
+
return failureReport('plan', request, requestDigestOf(input), 'invalid', 'request_id_content_conflict', 'request_id is already pinned to different request bytes.');
|
|
3325
|
+
if (prior.released_at === null)
|
|
3326
|
+
return failureReport('plan', request, requestDigestOf(input), 'reconcile_required', 'request_in_progress_or_ambiguous', 'A prior apply reserved this request but did not reach a recorded terminal result.', 'Inspect the durable pane, agent and delivery state; plan never repairs an ambiguous effect.', {
|
|
3327
|
+
state_revision: prepared.context.task.record.state_revision,
|
|
3328
|
+
reservation: reservationSummary(prior),
|
|
3329
|
+
effects: [
|
|
3330
|
+
{
|
|
3331
|
+
step: 'existing-request',
|
|
3332
|
+
status: 'no-effects',
|
|
3333
|
+
detail: 'Plan never repeats or repairs an effect.',
|
|
3334
|
+
},
|
|
3335
|
+
],
|
|
3336
|
+
});
|
|
3337
|
+
return successReport('plan', request, requestDigestOf(input), 'already_recorded', {
|
|
3338
|
+
state_revision: prepared.context.task.record.state_revision,
|
|
3339
|
+
reservation: reservationSummary(prior),
|
|
3340
|
+
effects: [
|
|
3341
|
+
{
|
|
3342
|
+
step: 'existing-request',
|
|
3343
|
+
status: 'already_recorded',
|
|
3344
|
+
detail: 'Plan never repeats or repairs an effect.',
|
|
3345
|
+
},
|
|
3346
|
+
],
|
|
3347
|
+
});
|
|
3348
|
+
}
|
|
3349
|
+
return planReport(prepared.context, 'plan');
|
|
3350
|
+
}
|
|
3351
|
+
export async function applyVisibleBootstrap(input, dependencies = {}) {
|
|
3352
|
+
const request = requestOf(input);
|
|
3353
|
+
const requestDigest = requestDigestOf(input);
|
|
3354
|
+
const now = dependencies.clock ?? Date.now;
|
|
3355
|
+
const opened = await openMessageTask(request.task_root);
|
|
3356
|
+
if (!opened.ok)
|
|
3357
|
+
return failureReport('apply', request, requestDigest, 'needs_input', opened.code, opened.message, opened.nextAction);
|
|
3358
|
+
const prior = reservationFor(opened.value.record, request.request_id);
|
|
3359
|
+
if (prior === 'ambiguous')
|
|
3360
|
+
return failureReport('apply', request, requestDigest, 'unknown', 'request_reservation_ambiguous', 'Multiple reservations already use this request id.');
|
|
3361
|
+
if (prior !== null && !deadNoEffectBootstrap(prior)) {
|
|
3362
|
+
if (bootstrapData(prior)['request_digest'] !== requestDigest)
|
|
3363
|
+
return failureReport('apply', request, requestDigest, 'invalid', 'request_id_content_conflict', 'request_id was already used with different request bytes.');
|
|
3364
|
+
const phase = typeof bootstrapData(prior)['phase'] === 'string'
|
|
3365
|
+
? bootstrapData(prior)['phase']
|
|
3366
|
+
: (prior.phase ?? 'unknown');
|
|
3367
|
+
if (phase === 'completed' ||
|
|
3368
|
+
prior.outcome === 'submitted' ||
|
|
3369
|
+
prior.outcome === 'already_recorded')
|
|
3370
|
+
return successReport('apply', request, requestDigest, 'already_recorded', {
|
|
3371
|
+
task_id: opened.value.record.task_id,
|
|
3372
|
+
state_revision: opened.value.record.state_revision,
|
|
3373
|
+
reservation: reservationSummary(prior),
|
|
3374
|
+
effects: [
|
|
3375
|
+
{
|
|
3376
|
+
step: 'duplicate',
|
|
3377
|
+
status: 'already_recorded',
|
|
3378
|
+
detail: 'No split/start/prompt operation was repeated.',
|
|
3379
|
+
},
|
|
3380
|
+
],
|
|
3381
|
+
});
|
|
3382
|
+
return failureReport('apply', request, requestDigest, 'reconcile_required', 'request_in_progress_or_ambiguous', `Existing bootstrap request is in phase ${String(phase)}; no effect was repeated.`, 'Inspect the persisted pane, agent, binding and delivery state before creating a new request.', {
|
|
3383
|
+
state_revision: opened.value.record.state_revision,
|
|
3384
|
+
reservation: reservationSummary(prior),
|
|
3385
|
+
});
|
|
3386
|
+
}
|
|
3387
|
+
const prepared = await prepare(input, 'apply', dependencies);
|
|
3388
|
+
if (!prepared.ok)
|
|
3389
|
+
return prepared.report;
|
|
3390
|
+
const context = prepared.context;
|
|
3391
|
+
const namespace = await assertMessageTaskCurrent(context.task);
|
|
3392
|
+
if (namespace !== null)
|
|
3393
|
+
return failureReport('apply', request, requestDigest, 'needs_input', namespace.code, namespace.message, namespace.nextAction);
|
|
3394
|
+
const reservation = reserveRequest(context, now());
|
|
3395
|
+
if (!reservation.ok) {
|
|
3396
|
+
const existing = reservation.existing;
|
|
3397
|
+
return failureReport('apply', request, requestDigest, reservation.code === 'request_id_content_conflict'
|
|
3398
|
+
? 'invalid'
|
|
3399
|
+
: existing === undefined
|
|
3400
|
+
? 'needs_input'
|
|
3401
|
+
: 'reconcile_required', reservation.code, reservation.message, 'Inspect the serialized request reservation; no duplicate effect is permitted.', {
|
|
3402
|
+
state_revision: opened.value.record.state_revision,
|
|
3403
|
+
...(existing === undefined
|
|
3404
|
+
? {}
|
|
3405
|
+
: { reservation: reservationSummary(existing) }),
|
|
3406
|
+
});
|
|
3407
|
+
}
|
|
3408
|
+
return request.target.choice === 'sibling'
|
|
3409
|
+
? applySibling(context, reservation.reservationId, reservation.revision, dependencies, now)
|
|
3410
|
+
: applyReuse(context, reservation.reservationId, reservation.revision, dependencies, now);
|
|
3411
|
+
}
|
|
3412
|
+
function writeBindingExclusive(taskRoot, target, binding) {
|
|
3413
|
+
const directory = path.dirname(target);
|
|
3414
|
+
if (!isPathInside(taskRoot, directory) || !isPathInside(taskRoot, target))
|
|
3415
|
+
return { ok: false, code: 'binding_path_escape' };
|
|
3416
|
+
try {
|
|
3417
|
+
const relative = path.relative(taskRoot, directory);
|
|
3418
|
+
let current = taskRoot;
|
|
3419
|
+
for (const part of relative.split(path.sep).filter(Boolean)) {
|
|
3420
|
+
current = path.join(current, part);
|
|
3421
|
+
try {
|
|
3422
|
+
const stat = fs.lstatSync(current);
|
|
3423
|
+
if (!stat.isDirectory() ||
|
|
3424
|
+
stat.isSymbolicLink() ||
|
|
3425
|
+
fs.realpathSync(current) !== current)
|
|
3426
|
+
return { ok: false, code: 'binding_directory_unsafe' };
|
|
3427
|
+
}
|
|
3428
|
+
catch (error) {
|
|
3429
|
+
if (!isRecord(error) || error['code'] !== 'ENOENT')
|
|
3430
|
+
return { ok: false, code: 'binding_directory_unavailable' };
|
|
3431
|
+
fs.mkdirSync(current, { mode: 0o700 });
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
const validated = collabBindingSchema.safeParse(binding);
|
|
3435
|
+
if (!validated.success)
|
|
3436
|
+
return { ok: false, code: 'binding_invalid' };
|
|
3437
|
+
const bytes = Buffer.from(`${JSON.stringify(validated.data, null, 2)}\n`, 'utf8');
|
|
3438
|
+
const fd = fs.openSync(target, 'wx', 0o600);
|
|
3439
|
+
try {
|
|
3440
|
+
fs.writeSync(fd, bytes);
|
|
3441
|
+
fs.fsyncSync(fd);
|
|
3442
|
+
}
|
|
3443
|
+
finally {
|
|
3444
|
+
fs.closeSync(fd);
|
|
3445
|
+
}
|
|
3446
|
+
return { ok: true, digest: digestText(bytes) };
|
|
3447
|
+
}
|
|
3448
|
+
catch (error) {
|
|
3449
|
+
const code = isRecord(error) ? error['code'] : null;
|
|
3450
|
+
return {
|
|
3451
|
+
ok: false,
|
|
3452
|
+
code: code === 'EEXIST' ? 'binding_file_exists' : 'binding_write_failed',
|
|
3453
|
+
};
|
|
3454
|
+
}
|
|
3455
|
+
}
|