diff --git a/LESSONS.md b/LESSONS.md index c437e12f..086271e9 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -715,3 +715,7 @@ facts without auto-resuming an old worker or confusing a reused PID with ownersh ### 2026-11-03 — Preserve context and recheck current authority separately Seen 1× (native pre-step and existing scheduler regressions). Admission-time rights do not cover role/resource revocation during a long execution. Reuse one live authority check and the existing pool stop/acknowledgement contract. Keep frozen persona/model for continuation, permit only the retained active retired-role claim, and do not await an Agent's idle inside its own pre-step hook. A resumed physical Session also exists in history: select its current live carrier for authorization. Recheck the actual human sender after awaiting the initiating authority. + +### 2026-11-03 — Revalidate handoff targets after exit inspection + +Seen 1× (composed role governance/contract regression). Confirming an old source exit yields to concurrent governance. Re-read the committed destination before acquiring a new claim; retirement keeps the Task blocked instead of authorizing a stale role. diff --git a/PROJECT.md b/PROJECT.md index 2bd4752a..a34706c5 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -46,136 +46,9 @@ This is a documentation/branch handoff, not another service listener, schema cha main merge, runtime rollout or takeover of original frontend Task40/PR48. No deployment credentials or operator private files are included. -### Service recovery acceptance — S6 / issue55, 2026-11-02 - -The final stage composes the verified S5 service with the native frontend candidate -from original Task40/PR48, preserving both source histories. An operator CLI repeats -isolated real-store/Git/controlled-adapter recovery scenarios; it never injects -production faults or represents mocks as vendor execution. Real GitHub feedback -has automatically reached the original Task's actual Codex Developer, with explicit -read/handled receipts and downstream Tester handoff, without manual relay/new Task. -Matching candidate test/Review/PR and Chief decisions remain separately recorded. -See [runbook](docs/service-recovery-demo.md). Integration/real UI screenshots and -#41's remaining visual/dual-mode matrix are not established by this CLI. - -### Trusted execution boundary — S5 / issue54, 2026-11-02 - -The compatible profile explicitly supports trusted private execution on one -dedicated service account, without mandatory per-Bot users/containers. A new -read-only execution.boundary query reports current human rights/delegation, exact -credential selection, authorized resources and honest preflight/isolation limits. -Unknown profiles refuse repository/resource admission; watch grants never elevate -current Project rights. No live account, credential or sandbox change is made. -See [decision](decisions/2026-11-02-trusted-execution-service.md) and -[boundary architecture](docs/subsystems/project-execution-boundary.md). -Private-memory APIs, resource/lease/generation/confirmed-exit/workspace coordination, -same-candidate tests/Review and Chief adjudication remain mandatory. - -### GitHub feedback subscriptions — S4 / issue53, 2026-11-02 - -Source/Thread/TaskSubscription now feeds the existing durable inbox through scoped -GitHub reads and configurable native schedules. Stable revisions, cursor replay, -atomic event/input/Effect persistence and current-Task routing preserve Bot handoff -choice. Old-SHA checks/Review and remote closure never grant current evidence or -local closure. Default activation keeps earlier discussion as context, explicit -history backfill is available; stopped/context Tasks never wake. Long feedback is -retained losslessly in bounded parts, and unsubmitted Review drafts are excluded. -See [Source architecture](docs/subsystems/project-sources.md). Source tests and a -real UID998 scoped GitHub read are recorded; complete live feedback/handoff -acceptance remains separate until actually run. - -Browser-offline polling uses a host-only current-principal Project read port, -not a browser-session bypass flag. Repository identity is captured before async -credential lookup; in-flight changes refuse rather than borrowing another scope. - -S1/S2/S3 and the #60 host-seal compatibility repair are deployed together at -e20262ed, with4384 full checks and actual three-account authorization probes. -Original frontend Task40/PR48 resumed through Chief and sealed a new candidate; -the native Tester handoff is active, not final UI acceptance. Current backend -development remains in the foreground session, without frontend Task replacement. - -### Approved integration sealing — issue60, 2026-11-02 - -S3/PR61 is verified (4374 tests and exact-source real Codex inbox→Chief-pool -acceptance), pending compatible rollout with S2. The autonomous frontend exposed a -separate host preparation gap: an approved operator merge remained staged while -SDK shared-ref writes were correctly denied; host sealing only recognized the -published upstream and attempted a soft reset during that extra merge. - -The foreground repair adds manager-only, durable source admission with exact -operator ref/SHA, workspace snapshot and generation fences. Host sealing retains -the staged tree and trusted parents without opening refs to SDKs or claiming test/ -Review evidence. Failed/no-op saves cannot authorize a parent. The original -Task40/PR48/Attempt and merge draft stay intact; resume through Chief after verified -service deployment. No backend Bot Task or control-plane/sandbox/credential change. - -The first compatible S2/S3/60 rollout passed three-account live reads, but source -admission exposed over-broad historical Chief occupancy. Admission now requires -exit proof for the current Chief claim, every unreleased Project Chief and this -Attempt's workers, without treating unrelated released historical Chief turns as -workspace owners. Their old records remain intact; unknown current exits still block. - -### Durable Task inputs — S3 / issue52, 2026-11-02 - -The service now distinguishes persisted input, durable role inbox, adapter delivery, -SDK output observation, declared reading and validated handling. Current-claim -receipts and bounded same-thread safe turns preserve existing Bot handoff authority. -Chief receipt plans cannot consume inputs on failed decisions; closure and input -handling share the final state commit. New pending feedback, unknown protocols, -revoked source and changed hashes keep their fences. See [input contract](docs/subsystems/project-inputs.md). - -S1/PR56 and #57/PR59 are deployed with original UI preserved; S2/PR58 passed4350 -checks and fork CI. S3 source/real Codex receipt validation is in progress, not -live delivery or final UI acceptance. The original frontend Task40/PR48 continues -through its own Chief/Dev/Reviewer/Tester flow. No backend development Task, -control-plane edit, merge/queue/release or new per-Bot sandbox is authorized here. - -### Verified supervisor exit compatibility — 2026-11-02 - -Foreground repair #57 unifies workspace and logical Task-role resumption for -host-confirmed SDK exit and prior systemd invocation exit. The latter must match -the exact released worker/lease, generation, role, resource and process scope. -Unknown exits remain blocked; no state or lock deletion is a recovery mechanism. -Task40/PR48 retains its current Attempt and all draft/history records. Verification -and safe-checkpoint deployment precede Chief resumption; no frontend code is edited. - -### Direct backend recovery — 2026-11-01 - -The owner requires backend repairs in the foreground Codex session; do not dispatch -backend development through FlyBot. Preserve the existing autonomous frontend work -while implementing backend changes independently. The [owner decision](decisions/2026-11-01-direct-backend-recovery.md) -still governs; completed R6–R8 implementation/probe evidence is archived in -[execution history](docs/history/2026-11-03-execution-evidence.md#direct-backend-recovery--2026-11-01). - -### Automatic development service — 2026-11-01 - -Owner approved [#49–#55](https://github.com/zy95-12/FlyBot/issues/49), sequential -backend issue/PR delivery alongside existing frontend Task40/PR48. S1 ([PR56](https://github.com/zy95-12/FlyBot/pull/56)) implements -one versioned service facade, legacy adapters and a shared derived TaskView; -[contract](docs/subsystems/project-development-service.md), -[decision](decisions/2026-11-01-development-service.md). Durable Effects, input -receipts, external subscriptions, trusted deployment boundaries and recovery -acceptance remain S2–S6. Source checks and staged live rollout are separate; -healthy frontend work and native schedules must not be interrupted for deployment. -The initial673c741b service facade was deployed at a proven idle boundary, with -51 mixed-source UID998 checks and unchanged native UI assets. Live verification -found the old25-Attempt detail response exceeded8MiB. Follow-up b9cceb02 preserves -full history with paged summaries/hash-bound context retrieval: full UID1000 -Node24 verify4333 passed,0fail/skip. It was deployed at a tested, released Chief-wait checkpoint. Owner/Maintainer/Member -actual HTTP probes passed, including full-context paging and denied foreign/mutating -requests; the original detail response is now about1.45MiB. Native UI bytes remain -unchanged; frontend integration and final UI acceptance stay with Task40/PR48. - -### Durable service actions — S2/#51 - -S2 stages handoff dispatch and GitHub PR/comment actions with existing state, -requires an actual rename acknowledgement before I/O, and retains bounded retry/ -reconciliation receipts across restart. Current principals, claim generations, -worker exit and candidate-specific tests/review remain authoritative. Private -comment bodies are immutable artifact references, not public context or tokens. -[Effect architecture](docs/subsystems/project-effects.md). Source implementation -and fault-injection checks are in progress; no S2 live deployment, real GitHub -outbox acceptance or independent repository review is claimed by those checks. +### Earlier service implementation evidence + +Detailed S1–S6 implementation/recovery history is [archived verbatim](docs/history/2026-11-03-service-integration.md). Existing contracts remain in their subsystem docs. New convergence work is not deployed; preserve healthy frontend Task40/PR48 and native schedules. Backend work remains in this owner-authorized session. ## Historical upstream Project State @@ -302,15 +175,9 @@ decisions, concluded as [docs/governance.md](docs/governance.md) requires and re contains the integration fixes and regressions; review/CI/merge remain separate. -### M7.2 — 2026-10-31 - -M7.2 adds operator-granted local/Docker execution resources, safe environment observations, candidate pools, sticky resource leases and shared queue capacity accounting. The actual two-runtime probe passed with distinct hostnames/filesystems and confirmed cleanup. SDK authentication and mixed-resource Task delivery remain M7.3–M7.5. See docs/subsystems/project-resources.md. - - -### M7.3 checkpoint — 2026-10-31 - -M7.3 work in progress adds pinned official Codex/Claude Agent SDK temporary worker adapters through the existing Workflow seam, explicit per-resource/principal profiles, streamed evidence and exit-confirmed resource release. Chief SDK actions now use a host-validated scoped JSON bridge; cross-resource dispatch remains pending; no full real-SDK acceptance is claimed. - +Earlier M7.2/M7.3 implementation-status checkpoints are retained verbatim in +[the historical record](docs/history/2026-11-03-completed-m7-checkpoints.md). Current +resource/Harness contracts and authorized acceptance boundaries remain below. ### M7.4 — 2026-10-31 @@ -563,63 +430,9 @@ DSH deterministic single-resource return/context coverage. Historical deployment probe details, including their original limitations, remain in [execution history](docs/history/2026-11-03-execution-evidence.md#task-scoped-sdk-context-and-resident-chief-memory--2026-11-01). -### Task40 S1 compatibility integration — 2026-11-02 - -The preserved `0e72dc60` frontend is integrated with trusted operator backend -`b920cae8` (including S1/34918fb3 and the shared verified-exit-proof repair). -Legacy delivery/Chief/schedule/delegation requests enter the S1 facade; native -personal/room/attachment routes and retained-root behavior remain intact. The -compact board now supplies live native activity to the shared status mapper: -stored Bot existence alone still cannot establish running execution. The new -integration regression failed before that adaptation; focused integrated suites -pass 83/83. Neither parent's prior validation is an integration PASS. Host full -verification, independent Review and actual deployed three-account/weak-network/ -screenshot acceptance remain required. No deployment, push or PR replacement occurs. - - -Issue60 live follow-up: a completed arbitration deferral now leaves its own Chief -claim waiting-human instead of active. Actual released SDK/lease proof remains -required before source admission. This fixes the observed current-Attempt operator -checkpoint without changing Task history, source trust or workspace ownership. - - -S6 dependency preparation uses the complete deployed release graph, including native -React/UI packages, rather than mixing the old private native cache. The versioned -`dev/demo/prepare-worktree.mjs` adapter refuses unknown existing entries. For legacy -managed workspaces, first prove SDK/leases released and preserve Git HEAD/index/status; -back up their untracked dependency directory outside the worktree, then prepare as -the service UID. Preserve the original Task/PR and source-admit the current Attempt -through the Owner API. This operator migration does not edit task state or Git code. - - -S6 live recovery also fences SDK-owned Chief delegations before asynchronous worker -registration. Dormant native DSH turn/end events must not consume the scheduled SDK -job during that setup window; only its fenced SDK callback may finish it. The -regression injects that event before a worker ID exists, then requires exactly one -completion from the actual bound callback. Native-only execution retains its events. - - -A late result-handler failure cannot overwrite a confirmed released SDK lifecycle. -Historical failed-status rows with a successful process exit may still prove exit -only when worker ID/generation, Project/Attempt/role/resource and both worker/lease -stop proofs match a released SDK lease exactly. Unknown/unreachable exits, missing -proofs and mismatched leases remain refused. This preserves historical failure -attribution without blocking every future integration admission for the same Task. - - -Chief route with rework=false resumes an interrupted allocated pre-candidate -Attempt after all role workers/leases/native occupancy are confirmed released, -whether its worktree is clean or dirty. A clean operator-restart checkpoint must -not manufacture a successor and discard its Attempt-scoped integration admission. -Explicit returns/rework and unknown worker occupancy retain existing isolation. - - -Delivery branch documentation now includes a source-matched API reference for all -12 v1 queries/21 commands and selected native authentication/Project/private-agent -entrypoints, plus a workflow/private safe-watch/state guide. It distinguishes -TaskView from stored lifecycle, actual records from fixture validation and ordinary -questions from impactful Tasks. This does not change runtime, enable watch or add UI. +### Task40 service integration history +Earlier S1 integration evidence is [archived verbatim](docs/history/2026-11-03-service-integration.md). Original frontend Task40/PR48 remains separate work: preserve its original UI/functions and healthy execution; backend source convergence does not authorize merge, deployment or UI takeover. ### Publish the service snapshot in the upstream repository — 2026-11-02 @@ -642,3 +455,19 @@ pins, and model input follows a durable-save/cancellation barrier. Existing Harness/lease/Workflow services remain authoritative. Adapter capability inspection reports exact native Task-role resume with committed activation/quiescence; actual DSH deterministic return/context coverage is recorded separately from real-provider validation. No public service protocol, SQL transaction, deployment or frontend change is claimed. + + +### Collaboration contract convergence — 2026-11-03 (#2175) + +Expose durable Task handoffs through one scoped contract projection, with distinct +pending/accepted/processed/revoked/invalidated states, source/target context references, +actual claim/worker binding, initiative fences and predecessor/successor history. +Idempotent trusted offer/accept ports reuse pools; unresolved old writers cannot be +bypassed by Chief rerouting to another resource. Consultation is a separate private +protocol over retained transcripts, without Task creation or initiative transfer. +Chief prefers an active personal agent for user questions: safe answers require user +publication, watch answers use existing inbox rights; only committed Effects may wake +an authorized native watch. External clients have isolated mailboxes, not fabricated +provider sessions. SDK process/HTTP tests and real-provider acceptance remain distinct. +Native restoration/context bounds, transaction events and parent waits stay on their +original dependent issues; no control-plane, deployment or frontend changes. diff --git a/ROADMAP.md b/ROADMAP.md index e7261716..f003ddc9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,24 +199,9 @@ outcome is recorded in [`decisions/`](decisions/README.md) with its evidence, pa dissent. A stage moving from "not started" to "active" is itself a decision, not an edit alone. -### M7.2 — 2026-10-31 - -M7.1 PR #26 passed full verify and actual DSH context/lifecycle checks. M7.2 implementation on feat/m7-resource-pools adds resource capabilities/leases and real local+Docker execution probes; integration acceptance with SDKs remains in M7.5. - - -### M7.3 checkpoint — 2026-10-31 - -M7.2 PR #27 passed full verify and the opt-in actual local/Docker resource probe. M7.3 adapters are in progress; the implementation PR remains draft until remaining integration and real authorized SDK acceptance are complete; Chief now uses a host-validated scoped action bridge. - - -### M7.4 — 2026-10-31 - -M7.3 PR #28 passed full verify at 034d86c, remains draft pending authenticated acceptance. M7.4 implementation supplies Owner runtime configuration/probes/private PTY and strengthens positive worker-stop confirmation; M7.5 cross-resource delivery and final acceptance remain next. - - -### M7.5 — 2026-10-31 - -M7 implementation PRs: #26 role Sessions, #27 resource pools, #28 official SDK Harnesses, #29 Owner setup, #30 handoff/integration. Full verify passed #26–#29 tips; final #30 validation/report is recorded in its PR. Real authenticated SDK/browser mixed-resource acceptance remains pending dedicated authorized profiles; M8 continuous CI/review and M9 persistent dogfood operations remain later work. +Completed M7.2–M7.5 implementation checkpoints are retained verbatim in +[the historical record](docs/history/2026-11-03-completed-m7-checkpoints.md). Current +role/resource/Session contracts and outstanding acceptance remain below and in the active roadmap. ### M7 validation update — 2026-11-01 @@ -421,6 +406,22 @@ capabilities. Native Task-role context restoration, parent waits, transactional and external command receipts remain dependent acceptance; no live-provider or deploy claim from controlled process tests. README/API rewrite remains last (#2189). + +### Collaboration contract convergence — 2026-11-03 (#2175) + +Expose durable Task handoffs through one scoped contract projection, with distinct +pending/accepted/processed/revoked/invalidated states, source/target context references, +actual claim/worker binding, initiative fences and predecessor/successor history. +Idempotent trusted offer/accept ports reuse pools; unresolved old writers cannot be +bypassed by Chief rerouting to another resource. Consultation is a separate private +protocol over retained transcripts, without Task creation or initiative transfer. +Chief prefers an active personal agent for user questions: safe answers require user +publication, watch answers use existing inbox rights; only committed Effects may wake +an authorized native watch. External clients have isolated mailboxes, not fabricated +provider sessions. SDK process/HTTP tests and real-provider acceptance remain distinct. +Native restoration/context bounds, transaction events and parent waits stay on their +original dependent issues; no control-plane, deployment or frontend changes. + Native prior-host reconciliation now reuses verified supervisor process-group facts; native v2 registered-work/lineage and shared adapter-exit regressions guard against premature initiative transfer. In-flight current-rights abort and composed-stack acceptance remain active. Canonical ongoing role/resource authority checks now reuse native step/SDK input and the existing scheduler + pool stop. Frozen role context remains stable; current revoked rights stop execution, with unknown exit held. Compose descendants and verify parent Task resume/API projections next. diff --git a/changelog.d/2175.added.md b/changelog.d/2175.added.md new file mode 100644 index 00000000..587a2343 --- /dev/null +++ b/changelog.d/2175.added.md @@ -0,0 +1 @@ +Expose scoped persistent handoff contracts and separate consultation APIs. Route Chief questions to an authorized personal agent before the human, retain safe/watch approval boundaries, and fence downstream claims until old writers confirm exit. Revalidate current target roles after source-exit inspection while retaining native/SDK exit proofs and pinned role contexts. diff --git a/core/src/host/authz.js b/core/src/host/authz.js index 6eeb9b8c..b4baf3c4 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -241,7 +241,7 @@ export function install(rt) { const ep = String(endpoint ?? '') if (ep === 'platform-login') return rt.platformIdentityEnabled ? ALLOW : deny('bot/forbidden', 'Platform identity is not configured') - if (['project-development', 'project-rooms', 'project-room-create', 'project-room-read', 'project-room-message', 'project-room-attachment', 'project-room-stop', 'project-agent-stop', 'workbench-state', 'project-resources', 'project-resource-environment-check', 'project-execution-input', 'project-runtime-check', 'project-terminal-open', 'project-terminal-poll', 'project-terminal-input', 'project-terminal-resize', 'project-terminal-close', 'project-queue-cancel', 'project-queue-retry', 'project-task-pools', 'project-github-read', 'project-chief-question', 'project-agent-mode', 'project-scheduled-task', 'project-chief-configure', 'project-chief-state', 'project-chief-inspect', 'project-attempt-stop', 'project-credential-status', 'project-github-comment', 'project-agents', 'project-agent-message', 'project-agent-history', 'project-agent-memory-write', 'project-role-memory-write', 'project-agent-definition', 'project-roles', 'project-role-proposals', 'project-role-decision', 'project-agent-proposal-apply', 'project-knowledge-write', 'project-task-run', 'project-task-detail', 'project-task-accept', 'project-task-reject', 'project-attempt-log', 'project-attempt-cleanup', 'project-attempt-publication-retry', 'project-members', 'project-invite', 'project-join', 'project-leave', 'project-member-remove', 'project-quota', 'project-workflow'].includes(ep) && !rt.sharedProjectMode?.()) return deny('bot/forbidden', 'Project workbench is not enabled') + if (['project-development', 'project-rooms', 'project-room-create', 'project-room-read', 'project-room-message', 'project-room-attachment', 'project-room-stop', 'project-agent-stop', 'workbench-state', 'project-resources', 'project-resource-environment-check', 'project-execution-input', 'project-runtime-check', 'project-terminal-open', 'project-terminal-poll', 'project-terminal-input', 'project-terminal-resize', 'project-terminal-close', 'project-queue-cancel', 'project-queue-retry', 'project-task-pools', 'project-github-read', 'project-consultation-reply', 'project-consultation-ask', 'project-consultation-get', 'project-consultation-list', 'project-handoff-get', 'project-handoff-list', 'project-chief-question', 'project-agent-mode', 'project-scheduled-task', 'project-chief-configure', 'project-chief-state', 'project-chief-inspect', 'project-attempt-stop', 'project-credential-status', 'project-github-comment', 'project-agents', 'project-agent-message', 'project-agent-history', 'project-agent-memory-write', 'project-role-memory-write', 'project-agent-definition', 'project-roles', 'project-role-proposals', 'project-role-decision', 'project-agent-proposal-apply', 'project-knowledge-write', 'project-task-run', 'project-task-detail', 'project-task-accept', 'project-task-reject', 'project-attempt-log', 'project-attempt-cleanup', 'project-attempt-publication-retry', 'project-members', 'project-invite', 'project-join', 'project-leave', 'project-member-remove', 'project-quota', 'project-workflow'].includes(ep) && !rt.sharedProjectMode?.()) return deny('bot/forbidden', 'Project workbench is not enabled') if (rt.platformIdentityEnabled && ['auth-invite', 'auth-redeem', 'auth-remove-member'].includes(ep)) return deny('bot/platform-auth', 'Use control-plane account authentication') if (!actor) { if (NO_ACTOR_ALLOWED.has(ep)) return ALLOW @@ -253,7 +253,7 @@ export function install(rt) { if (rt.sharedProjectMode?.()) { const agentSeats = [...(Array.isArray(body?.members) ? body.members : []), ...(Array.isArray(body?.add) ? body.add : []), ...(Array.isArray(body?.participants) ? body.participants.filter(row => row?.kind === 'bot').map(row => row.id) : [])] if (['create-room', 'update-room'].includes(ep) && agentSeats.some(id => state.bots?.[id]?.projectAgent)) return deny('bot/forbidden', 'Project agents cannot join group chats') - if (['project-development', 'project-rooms', 'project-room-create', 'project-room-read', 'project-room-message', 'project-room-attachment', 'project-room-stop', 'project-agent-stop', 'workbench-state', 'project-resources', 'project-resource-environment-check', 'project-execution-input', 'project-runtime-check', 'project-terminal-open', 'project-terminal-poll', 'project-terminal-input', 'project-terminal-resize', 'project-terminal-close', 'project-queue-cancel', 'project-queue-retry', 'project-task-pools', 'project-github-read', 'project-chief-question', 'project-agent-mode', 'project-scheduled-task', 'project-chief-configure', 'project-chief-state', 'project-chief-inspect', 'project-attempt-stop', 'project-credential-status', 'project-github-comment', 'project-agents', 'project-agent-message', 'project-agent-history', 'project-agent-memory-write', 'project-role-memory-write', 'project-agent-definition', 'project-roles', 'project-role-proposals', 'project-role-decision', 'project-agent-proposal-apply', 'project-knowledge-write', 'project-task-run', 'project-task-detail', 'project-task-accept', 'project-task-reject', 'project-attempt-log', 'project-attempt-cleanup', 'project-attempt-publication-retry', 'project-members', 'project-invite', 'project-join', 'project-leave', 'project-member-remove', 'project-quota', 'project-workflow'].includes(ep)) return ALLOW + if (['project-development', 'project-rooms', 'project-room-create', 'project-room-read', 'project-room-message', 'project-room-attachment', 'project-room-stop', 'project-agent-stop', 'workbench-state', 'project-resources', 'project-resource-environment-check', 'project-execution-input', 'project-runtime-check', 'project-terminal-open', 'project-terminal-poll', 'project-terminal-input', 'project-terminal-resize', 'project-terminal-close', 'project-queue-cancel', 'project-queue-retry', 'project-task-pools', 'project-github-read', 'project-consultation-reply', 'project-consultation-ask', 'project-consultation-get', 'project-consultation-list', 'project-handoff-get', 'project-handoff-list', 'project-chief-question', 'project-agent-mode', 'project-scheduled-task', 'project-chief-configure', 'project-chief-state', 'project-chief-inspect', 'project-attempt-stop', 'project-credential-status', 'project-github-comment', 'project-agents', 'project-agent-message', 'project-agent-history', 'project-agent-memory-write', 'project-role-memory-write', 'project-agent-definition', 'project-roles', 'project-role-proposals', 'project-role-decision', 'project-agent-proposal-apply', 'project-knowledge-write', 'project-task-run', 'project-task-detail', 'project-task-accept', 'project-task-reject', 'project-attempt-log', 'project-attempt-cleanup', 'project-attempt-publication-retry', 'project-members', 'project-invite', 'project-join', 'project-leave', 'project-member-remove', 'project-quota', 'project-workflow'].includes(ep)) return ALLOW if (ep.startsWith('flybot/') && !['flybot/bots', 'flybot/changes', 'flybot/decide'].includes(ep)) return deny('bot/project-required', 'Use a Project-scoped endpoint') if (['workflows', 'workflow-save', 'usage'].includes(ep)) return deny('bot/project-required', 'Use a Project-scoped endpoint') } diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 7fe2497d..7b3cb9ee 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -229,7 +229,7 @@ export function install(rt) { // What a host that only reads the team may still answer. // Updates change the dsh profile, not the team, so a reading host may run them too. // auth-whoami is a read of the already-resolved actor; invite/redeem/revoke/remove-member write. - const READS = new Set(['project-roles', 'project-role-proposals', 'personal-agent-token-list', 'personal-agent-history', 'personal-agent-read', 'personal-agent-list', 'personal-agent-participations', 'project-repositories', 'project-rooms', 'project-room-read', 'project-task-pools', 'project-chief-state', 'project-github-read', 'auth-mode', 'workbench-state', 'project-resources', 'projects', 'project-read', 'project-board', 'project-members', 'project-task-detail', 'project-attempt-log', 'project-detail', 'state', 'models', 'efforts', 'earlier', 'room-progress', 'room-history', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami', 'workflows']) + const READS = new Set(['project-roles', 'project-role-proposals', 'personal-agent-token-list', 'personal-agent-history', 'personal-agent-read', 'personal-agent-list', 'personal-agent-participations', 'project-repositories', 'project-rooms', 'project-room-read', 'project-handoff-get', 'project-handoff-list', 'project-consultation-get', 'project-consultation-list', 'project-task-pools', 'project-chief-state', 'project-github-read', 'auth-mode', 'workbench-state', 'project-resources', 'projects', 'project-read', 'project-board', 'project-members', 'project-task-detail', 'project-attempt-log', 'project-detail', 'state', 'models', 'efforts', 'earlier', 'room-progress', 'room-history', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami', 'workflows']) // A chat reference chip names a Session whose snapshot rides to every recipient of // the message, so the author must be able to read what they share (#53, #106). @@ -845,6 +845,12 @@ export function install(rt) { case 'project-close-confirm': case 'project-development': case 'project-task-pools': + case 'project-consultation-reply': + case 'project-consultation-ask': + case 'project-consultation-get': + case 'project-consultation-list': + case 'project-handoff-get': + case 'project-handoff-list': case 'project-chief-question': case 'project-github-read': case 'project-agent-mode': diff --git a/core/src/host/personal-agent-auth.js b/core/src/host/personal-agent-auth.js index 07959ce5..5e036246 100644 --- a/core/src/host/personal-agent-auth.js +++ b/core/src/host/personal-agent-auth.js @@ -7,7 +7,7 @@ import { WORKBENCH_ENDPOINTS } from './workbench-gateway.js' const hash = value => createHash('sha256').update(value).digest('hex') const refusal = (message, code = 'bot/forbidden') => Object.assign(new Error(message), { code }) const endpoints = new Set(['personal-agent-token-create', 'personal-agent-token-list', 'personal-agent-token-revoke', 'personal-agent-delegation-set', 'personal-agent-propose-task']) -const reads = new Set(['projects', 'project-read', 'project-detail', 'project-board', 'project-members', 'project-repositories', 'project-task-detail', 'project-attempt-log', 'project-task-pools', 'project-chief-state']) +const reads = new Set(['project-handoff-get', 'project-handoff-list', 'project-consultation-get', 'project-consultation-list', 'projects', 'project-read', 'project-detail', 'project-board', 'project-members', 'project-repositories', 'project-task-detail', 'project-attempt-log', 'project-task-pools', 'project-chief-state']) const reserved = new Set(['project-agent-mode', 'project-agent-proposal-apply', 'project-agent-message', 'project-agent-history', 'project-agent-memory-write', 'project-agent-stop', 'project-delete', 'project-archive', 'project-close-prepare', 'project-close-confirm']) const publicCredential = row => ({ version: row.version, id: row.id, agentId: row.agentId, projectIds: [...row.projectIds], createdAt: row.createdAt, expiresAt: row.expiresAt, status: row.status }) const bounded = (value, name, max) => { @@ -66,12 +66,12 @@ export function install(rt) { try { current(actor) if (['auth-whoami', 'auth-revoke', 'projects'].includes(endpoint)) return nativeAuthorize(actor, endpoint, body) - if (endpoint === 'personal-agent-propose-task' || endpoint === 'project-close-request') scope(actor, body.projectId) + if (['personal-agent-propose-task', 'project-close-request', 'project-consultation-ask', 'project-consultation-reply', 'project-chief-question'].includes(endpoint)) scope(actor, body.projectId) else if (reads.has(endpoint)) { const projectId = body.projectId ?? body.id ?? ownEntry(state.tasks, body.taskId)?.projectId ?? ownEntry(state.projectAttempts ?? {}, body.attemptId)?.projectId scope(actor, projectId) } else if (endpoint === 'project-agents' && body.view === 'summary') scope(actor, body.projectId) - else if (endpoint === 'project-development' && rt.projectDevelopment.isQuery(body)) scope(actor, body.projectId) + else if (endpoint === 'project-development' && (rt.projectDevelopment.isQuery(body) || body.kind === 'command' && ['consultation.ask', 'consultation.reply'].includes(body.name))) scope(actor, body.projectId) else { const projectId = body.projectId ?? ownEntry(state.tasks, body.id ?? body.taskId)?.projectId ?? ownEntry(state.projectAttempts ?? {}, body.attemptId)?.projectId ?? ownEntry(state.projectQueue ?? {}, body.queueId)?.projectId ?? body.id const { grant } = scope(actor, projectId) diff --git a/core/src/host/project-access.js b/core/src/host/project-access.js index ce417a32..1ec3c838 100644 --- a/core/src/host/project-access.js +++ b/core/src/host/project-access.js @@ -116,6 +116,9 @@ export function install(rt) { if (endpoint === 'project-rooms' || endpoint.startsWith('project-room-') || endpoint === 'project-agent-stop') return rt.projectAgents.endpoint(endpoint, body, actor) if (endpoint === 'project-github-read') return rt.projectGithubRead(body, actor) if (endpoint === 'project-chief-question') return rt.projectAgents.chiefQuestion(body, actor) + if (['project-consultation-reply', 'project-consultation-ask', 'project-consultation-get', 'project-consultation-list'].includes(endpoint)) return rt.projectAgents.consultation(endpoint, body, actor) + if (endpoint === 'project-handoff-get') return { handoff: rt.projectPools.get(body.taskId, body.handoffId, actor) } + if (endpoint === 'project-handoff-list') return rt.projectPools.list(body.taskId, actor, body) if (endpoint === 'project-credential-status') return rt.projectCredentials.endpoint(endpoint, body, actor) if (['project-agents', 'project-agent-message', 'project-agent-history', 'project-agent-memory-write', 'project-role-memory-write', 'project-agent-definition', 'project-roles', 'project-role-proposals', 'project-role-decision', 'project-knowledge-write'].includes(endpoint)) return rt.projectAgents.endpoint(endpoint, body, actor) if (['project-runtime-check', 'project-terminal-open', 'project-terminal-poll', 'project-terminal-input', 'project-terminal-resize', 'project-terminal-close'].includes(endpoint)) return rt.projectRuntimeSetup.endpoint(endpoint, body, actor) diff --git a/core/src/host/project-agents.js b/core/src/host/project-agents.js index fc46ae03..4ff5a046 100644 --- a/core/src/host/project-agents.js +++ b/core/src/host/project-agents.js @@ -6,6 +6,8 @@ import { output } from './tools.js' import { textOf } from './text.js' import { resolveAgentSkills } from './project-agent-skills.js' import { roleContext, readContextArchive, contextHash, KNOWLEDGE_CHECKPOINT_VERSION } from './project-role-context.js' +import { EffectCancelled, EffectDeferred } from './project-effects.js' +import { createProjectConsultations } from './project-consultations.js' import { createProjectRooms } from './project-rooms.js' import { deliveryGraph } from './project-delivery-template.js' import { createProjectRoleGovernance } from './project-role-governance.js' @@ -79,13 +81,14 @@ export function createProjectAgents(rt) { const rooms = createProjectRooms(rt, { roles: projectId => definitionSet(projectId).roles.map(row => row.role), send: (body, actor) => serial(() => send(body, actor)), stop: stopAgent }) let tail = Promise.resolve() const serial = fn => { const result = tail.then(fn, fn); tail = result.then(() => {}, () => {}); return result } + const consultations = createProjectConsultations(rt, { ensure, chiefQuestion, serial }) const keyOf = (projectId, memberId) => rt.personalAgents.carrierId(projectId, memberId) const logOf = id => { let rows = ownEntry(transcripts(), id) if (!rows) { rows = []; setEntry(transcripts(), id, rows) } return rows } - const recordForBot = id => Object.values(state.projectAgents ?? {}).find(agent => agent.botId === id) + const recordForBot = id => id ? Object.values(state.projectAgents ?? {}).find(agent => agent.botId === id) : undefined const actorOf = (bot, { admitting = false } = {}) => { const agent = recordForBot(bot?.id) if (!agent || agent.status === 'retired') throw refuse('This Session has no Project agent authority') @@ -393,6 +396,7 @@ export function createProjectAgents(rt) { if (agent.returnAgentId) { const parent = ownEntry(agents(), agent.returnAgentId) if (parent?.memberId !== agent.memberId || parent.projectId !== agent.projectId) throw refuse('Conversation return scope changed') + rt.personalAgents.requireParticipation(parent) logOf(parent.id).push({ ...entry, kind: 'agent', speaker: 'Chief', conversationHandoffId: agent.conversationHandoffId }) } } @@ -404,11 +408,11 @@ export function createProjectAgents(rt) { if (rt.botOf(agent.botId)) await rt.deleteBot(agent.botId) } async function chiefQuestion(body, actor) { return serial(async () => { - const parent = await ensure(body.projectId, actor) + const parent = await consultations.parent(body, actor, true) const text = clean(body.text), requestId = clean(body.requestId, 100) if (!text || !/^[A-Za-z0-9_-]{1,100}$/.test(requestId)) throw refuse('A Chief question needs text and a stable requestId') const prior = logOf(parent.id).find(row => row.kind === 'handoff' && row.requestId === requestId) - if (prior) { if (prior.text !== text) throw refuse('Conversation requestId was reused with different text'); return { handoff: structuredClone(prior), already: true } } + if (prior) { if (prior.text !== text) throw refuse('Conversation requestId was reused with different text'); return { handoff: structuredClone(prior), consultation: consultations.view(parent, prior), already: true } } const definitions = definitionSet(parent.projectId), definition = structuredClone(definitions.roles.find(row => row.role === 'chief')) const detail = await rt.projectEndpoint('project-detail', { id: parent.projectId }, actor) const snapshot = roleContext({ retrieval: true, projectId: parent.projectId, role: 'chief', revision: definitions.revision, definition: { ...definition, resolvedSkills: resolveAgentSkills(rt, definition.skills) }, memory: memoryFor(parent.projectId, 'chief', definition.memoryRefs), knowledge: ownEntry(state.projectKnowledge ?? {}, parent.projectId) ?? [], projectFacts: { chiefMemory: chiefMemory(parent.projectId), tasks: detail.tasks.slice(-20).map(({ id, title, status, criteria }) => ({ id, title, status, criteria: clean(criteria, 1000) })), runs: detail.runs.slice(-10).map(({ id, taskId, status }) => ({ id, taskId, status })) }, repository: await rt.projectDelivery.repositoryContext(parent.projectId) }) @@ -438,13 +442,20 @@ export function createProjectAgents(rt) { await rt.deliver({ toId: bot.id, sessionId: agent.sessionId, role: 'request', audience: 'user', text, fromName: actor.displayName ?? actor.memberId, untrusted: true, exchangeId: delegation.exchangeId, projectDelegation: { agentId: id, delegationId: delegation.id } }) } } catch (error) { delegations.delete(id); await finishConversation(agent, 'failed'); await rt.save(); throw error } - return { handoff: structuredClone(receipt), queued: true } + return { handoff: structuredClone(receipt), consultation: consultations.view(parent, receipt), queued: true } }) } async function suggestClosure(bot, body) { const { agent, actor, delegation } = await authorize(bot) if (agent.visibility !== 'private' || delegation.readOnly || delegation.chiefJob) throw refuse('Only an active personal agent may recommend closure') return rt.projectEndpoint('project-close-request', { projectId: agent.projectId, reason: body.reason }, { ...actor, personalAgentSuggestionId: agent.id }) } + async function agentConsultations(bot, body) { + const { agent, actor } = await authorize(bot) + if (agent.visibility !== 'private' || !agent.personalAgentId || agent.returnAgentId) throw refuse('Only a personal agent may use its consultation mailbox') + const operation = body.operation ?? 'list' + if (!['get', 'list', 'reply'].includes(operation)) throw refuse('Unsupported consultation operation') + return consultations.endpoint(`project-consultation-${operation}`, { ...body, projectId: agent.projectId, agentId: agent.personalAgentId }, { ...actor, consultationProxyAgentId: agent.personalAgentId }) + } async function askChief(bot, body) { const { agent, actor, delegation } = await authorize(bot); if (delegation.readOnly) throw refuse('Chief must answer this conversation instead of recursively forwarding it'); return chiefQuestion({ ...body, projectId: agent.projectId }, actor) } async function githubRead(bot, body) { const { agent, actor } = await authorize(bot); return rt.projectGithubRead({ ...body, projectId: agent.projectId }, actor) } async function chiefReply(agent, delegation, result) { @@ -575,10 +586,10 @@ export function createProjectAgents(rt) { audit.status = 'completed'; audit.finishedAt = Date.now(); await rt.save(); return result } catch (error) { audit.status = 'failed'; audit.finishedAt = Date.now(); await rt.save(); throw error } } - async function wakeWatch(projectId, memberId, text) { + async function wakeWatch(projectId, memberId, text, expectedCarrierId) { const actor = await rt.projectDelegations.actorFor(keyOf(projectId, memberId)) if (!actor) throw refuse('No persistent watch delegation is active') - return serial(() => send({ projectId, visibility: 'private', text }, actor)) + return serial(() => { if (expectedCarrierId && keyOf(projectId, memberId) !== expectedCarrierId) throw refuse('Personal-agent default changed before notification'); return send({ projectId, visibility: 'private', text }, actor) }) } async function stopAgent(body, actor) { const agent = body.roomId ? Object.values(agents()).find(row => row.roomId === body.roomId && row.role === body.role) : await ensure(body.projectId, actor, body.visibility === 'public' ? 'public' : 'private') @@ -730,12 +741,32 @@ export function createProjectAgents(rt) { } const isDelegationActive = (id, delegationId) => { const entry = delegations.get(id); return entry?.id === delegationId && !entry.revoked } const ensureProject = (projectId, actor) => serial(async () => { await ensure(projectId, actor, 'public'); return ensure(projectId, actor) }) - return { stream, liveFor, hasExchange: exchangeId => [...delegations.values()].some(row => row.exchangeId === exchangeId && !row.revoked), privateIdentity: keyOf, ensurePublic: (projectId, actor) => serial(() => ensure(projectId, actor, 'public')), contextExchange, chiefMemory, chiefBusy: projectId => delegations.has(`public-${projectId}`), startChiefJob, endpoint, chiefQuestion, askChief, suggestClosure, githubRead, action, wakeWatch, revokeWatchGrant, ensureProject, isDelegationActive, actorOf, context, memory, task, run, onEvent, revoke, revokeSession, removeProject, deliveryDefinitions, recordForBot, roles: roleGovernance, proposeRole, proposeRoleForJob, ROLES } + return { stream, liveFor, hasExchange: exchangeId => [...delegations.values()].some(row => row.exchangeId === exchangeId && !row.revoked), privateIdentity: keyOf, ensurePublic: (projectId, actor) => serial(() => ensure(projectId, actor, 'public')), contextExchange, chiefMemory, chiefBusy: projectId => delegations.has(`public-${projectId}`), startChiefJob, endpoint, consultation: consultations.endpoint, contactUser: consultations.contactUser, agentConsultations, chiefQuestion, askChief, suggestClosure, githubRead, action, wakeWatch, revokeWatchGrant, ensureProject, isDelegationActive, actorOf, context, memory, task, run, onEvent, revoke, revokeSession, removeProject, deliveryDefinitions, recordForBot, roles: roleGovernance, proposeRole, proposeRoleForJob, ROLES } } export function install(rt) { rt.projectAgents = createProjectAgents(rt) rt.projectRoleGovernance = rt.projectAgents.roles + rt.projectEffects?.register('consultation.notify', { + async run(effect) { + const carrier = ownEntry(rt.state.projectAgents ?? {}, effect.payload.carrierId) + const task = ownEntry(rt.state.tasks, effect.taskId) + try { + rt.personalAgents.requireParticipation(carrier) + rt.requireProjectAccess({ memberId: effect.payload.memberId }, effect.projectId) + } catch { throw new EffectCancelled('Personal-agent recipient is no longer authorized') } + if (!carrier || carrier.personalAgentId !== effect.payload.agentId || carrier.projectId !== effect.projectId || task?.chiefQuestion?.recipient?.consultationId !== effect.payload.consultationId) throw new EffectCancelled('Personal-agent question ended or changed') + const identity = rt.personalAgents.agentOf(effect.payload.agentId) + if (identity.kind === 'external' || !rt.projectDelegations.forAgent(carrier.id) || rt.personalAgents.carrierId(effect.projectId, carrier.memberId) !== carrier.id) return { consultationId: effect.payload.consultationId, status: 'private-mailbox' } + try { + const result = await rt.projectAgents.wakeWatch(effect.projectId, carrier.memberId, `Chief requests your advice. Read consultation ${effect.payload.consultationId} through project_agent_consultations. Reply only with explicit Task input in your current authority; Task closure remains Chief judgement.`, carrier.id) + return { consultationId: effect.payload.consultationId, status: 'watch-notified', agentId: result.agent?.id ?? carrier.id } + } catch (error) { + if (error.code === 'bot/busy') throw new EffectDeferred('Personal agent is busy', { waitingFor: 'agent' }) + throw error + } + }, + }) rt.ctx.on('llm/stream', (options, next) => options.purpose === undefined ? rt.projectAgents.stream(options.sessionId, next) : next()) rt.ctx.on('session/event', (session, event) => rt.projectAgents.onEvent(session, event)) rt.ctx.on('agent/pre-step', async (payload, next) => { @@ -758,6 +789,7 @@ export function install(rt) { ['project_agent_memory', { text: { type: 'string', required: true } }, 'Save a private user/project note; never publish it.', async (args, bot) => rt.projectAgents.memory(bot, args.text)], ['project_agent_task', { title: { type: 'string', required: true }, criteria: { type: 'string' }, note: { type: 'string' }, projectTaskKind: { type: 'string', description: 'code, decision or release; ordinary questions are conversation handoffs' } }, 'Propose a Task privately, or create from the current public request.', async (args, bot) => rt.projectAgents.task(bot, args)], ['project_agent_suggest_closure', { reason: { type: 'string', required: true } }, 'Recommend Project closure without creating a Task or granting closure rights. The Owner must personally double-confirm.', async (args, bot) => rt.projectAgents.suggestClosure(bot, args)], + ['project_agent_consultations', { operation: { type: 'string' }, consultationId: { type: 'string' }, requestId: { type: 'string' }, text: { type: 'string' }, publish: { type: 'boolean' } }, 'Read your own Chief questions or reply. Safe answers remain private until human approval; watch replies enter the existing Task inbox.', async (args, bot) => rt.projectAgents.agentConsultations(bot, args)], ['project_agent_chief', { text: { type: 'string', required: true }, requestId: { type: 'string', required: true } }, 'Ask Chief an ordinary question without creating a Task. The answer returns to this private conversation.', async (args, bot) => rt.projectAgents.askChief(bot, args)], ['project_agent_github', { operation: { type: 'string', required: true }, number: { type: 'number' } }, 'Read only the current Project repository issues, pulls, pull, reviews or checks through a scoped host credential.', async (args, bot) => rt.projectAgents.githubRead(bot, args)], ['project_agent_action', { endpoint: { type: 'string', required: true }, args: { type: 'object', properties: {}, additionalProperties: true, required: true } }, 'Use an existing scoped human operation with the current user permissions in watch mode. This grants no merge, release or extra repository authority.', async (args, bot) => rt.projectAgents.action(bot, args)], diff --git a/core/src/host/project-chief-control.js b/core/src/host/project-chief-control.js index 6e5d3faf..8ab99aa7 100644 --- a/core/src/host/project-chief-control.js +++ b/core/src/host/project-chief-control.js @@ -149,6 +149,7 @@ export function createProjectChiefControl(rt) { if (task.collaboration) { task.collaboration.status = 'blocked'; task.collaboration.block = { reason, chiefDecisionId: audit.id, at: Date.now() } } if (task.collaboration?.claim?.chiefRunId === run.id) task.collaboration.claim.status = 'waiting-human' task.chiefQuestion = { decisionId: audit.id, reason, at: Date.now() } + task.chiefQuestion.recipient = rt.projectAgents.contactUser?.(task, { id: audit.id, reason, requiresHuman: decision.requiresHuman === true }, actor) ?? { kind: 'human', memberId: task.collaboration?.initiatedBy ?? actor.memberId } } else if (decision.action === 'close') { if (!Array.isArray(decision.criteria) || !decision.criteria.length || decision.criteria.some(row => row?.passed !== true || typeof row.criterion !== 'string' || !row.criterion.trim() || typeof row.evidence !== 'string' || !row.evidence.trim())) throw refuse('Chief closure needs explicit acceptance-criteria evidence') await rt.projectDelivery.chiefAccept({ taskId: task.id, expectedSha: saved.candidateSha, decision: audit }, actor) diff --git a/core/src/host/project-consultations.js b/core/src/host/project-consultations.js new file mode 100644 index 00000000..532d8e74 --- /dev/null +++ b/core/src/host/project-consultations.js @@ -0,0 +1,98 @@ +// Consultation records reuse per-participation transcripts; no Task initiative transfer. +import { createHash } from 'node:crypto' +import { ownEntry, setEntry } from './store.js' +const refuse = message => Object.assign(new Error(message), { code: 'bot/forbidden' }) +export function createProjectConsultations(rt, { ensure, chiefQuestion, serial }) { + const state = rt.state + const agents = () => (state.projectAgents ??= {}) + const transcripts = () => (state.projectAgentTranscripts ??= {}) + const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') + const messages = id => { const rows = ownEntry(transcripts(), id); if (rows) return rows; const created = []; setEntry(transcripts(), id, created); return created } + async function parent(body, actor, create = false) { + rt.requireProjectAccess(actor, body.projectId) + if (actor.personalAgentCredentialId && body.agentId && body.agentId !== actor.agentId) throw refuse('Consultation belongs to another personal agent') + const selected = actor.personalAgentCredentialId ? actor.agentId : body.agentId + const participation = selected ? rt.personalAgents.participationFor(selected, body.projectId) : rt.personalAgents.defaultParticipation(body.projectId, actor.memberId) + if (!participation) { + if (selected) throw refuse('Personal agent participation is unavailable') + return create ? ensure(body.projectId, actor) : null + } + const identity = rt.personalAgents.agentOf(participation.agentId) + if (!identity || identity.ownerId !== actor.memberId || participation.ownerId !== actor.memberId) throw refuse('Only the personal-agent owner may read this consultation') + if (create && !selected && identity.kind === 'internal') return ensure(body.projectId, actor) + const carrier = { id: participation.carrierId, projectId: body.projectId, memberId: actor.memberId, personalAgentId: identity.id, participationId: participation.id, visibility: 'private', kind: 'consultation-mailbox' } + if (create) rt.personalAgents.requireParticipation(carrier) + const existing = ownEntry(agents(), carrier.id) + if (existing && (existing.memberId !== carrier.memberId || existing.projectId !== carrier.projectId || existing.personalAgentId !== carrier.personalAgentId)) throw refuse('Consultation carrier scope changed') + if (!existing && create) { rt.projects.requireActive(body.projectId); setEntry(agents(), carrier.id, carrier) } + return existing ?? carrier + } + function view(parent, receipt, { offset = 0, limit = 20, includeReplies = true } = {}) { + if (!receipt || receipt.projectId !== parent.projectId || receipt.initiatedBy !== parent.memberId || receipt.returnTo !== parent.id || receipt.version !== 'conversation-handoff/v1') throw refuse('Unknown private consultation') + if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw refuse('Invalid consultation reply page') + const replies = (ownEntry(transcripts(), parent.id) ?? []).filter(row => row.conversationHandoffId === receipt.id) + return { version: 'project-consultation/v1', transportVersion: receipt.version, id: receipt.id, projectId: receipt.projectId, + agentId: parent.personalAgentId ?? null, requestId: receipt.requestId, initiatedBy: receipt.initiatedBy, returnTo: receipt.returnTo, + target: { botId: receipt.to, role: receipt.direction === 'incoming' ? 'personal-agent' : 'chief' }, direction: receipt.direction ?? 'outgoing', linkedTaskId: receipt.linkedTaskId ?? null, requiresHuman: Boolean(receipt.requiresHuman), question: receipt.text, state: receipt.status, initiativeTransferred: false, + replies: includeReplies ? replies.slice(offset, offset + limit).map(({ id, text, at, speaker, state, inputId, approvedBy }) => ({ id, text, at, speaker, ...(state ? { state } : {}), ...(inputId ? { inputId } : {}), ...(approvedBy ? { approvedBy } : {}) })) : [], + replyCount: replies.length, nextReplyOffset: includeReplies && offset + limit < replies.length ? offset + limit : null, createdAt: receipt.at, finishedAt: receipt.finishedAt ?? null } + } + async function endpoint(endpoint, body, actor) { + if (!['project-consultation-ask', 'project-consultation-get', 'project-consultation-list', 'project-consultation-reply'].includes(endpoint)) throw refuse('Unknown consultation operation') + if (endpoint === 'project-consultation-ask') return chiefQuestion(body, actor) + if (endpoint === 'project-consultation-reply') return serial(() => reply(body, actor)) + return serial(async () => { + const carrier = await parent(body, actor) + const rows = carrier ? (ownEntry(transcripts(), carrier.id) ?? []).filter(row => row.version === 'conversation-handoff/v1' && row.returnTo === carrier.id && row.initiatedBy === actor.memberId) : [] + if (endpoint === 'project-consultation-get') return { consultation: view(carrier ?? {}, rows.find(row => row.id === body.consultationId), body) } + const limit = body.limit ?? 50 + if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw refuse('Invalid consultation page limit') + const index = body.after === undefined ? -1 : rows.findIndex(row => row.id === body.after) + if (body.after !== undefined && index < 0) throw refuse('Unknown consultation cursor') + const page = rows.slice(index + 1, index + 1 + limit) + return { items: page.map(receipt => view(carrier, receipt, { includeReplies: false })), next: index + 1 + page.length < rows.length ? page.at(-1).id : null } + }) + } + function contactUser(task, question, actor) { + rt.projectChiefControl.requireJobActor(actor, task.projectId) + const memberId = task.collaboration?.initiatedBy ?? task.requesterId ?? rt.readProject(task.projectId).ownerUserId + const participation = rt.personalAgents.defaultParticipation(task.projectId, memberId) + const identity = participation && rt.personalAgents.agentOf(participation.agentId) + if (!participation || participation.status !== 'active' || identity?.status !== 'active' || identity.ownerId !== memberId || !rt.projectCan({ memberId }, task.projectId)) return { kind: 'human', memberId, reason: 'No active authorized personal agent' } + const carrier = ownEntry(agents(), participation.carrierId) ?? { id: participation.carrierId, projectId: task.projectId, memberId, personalAgentId: identity.id, participationId: participation.id, visibility: 'private', kind: 'consultation-mailbox' } + rt.personalAgents.requireParticipation(carrier) + setEntry(agents(), carrier.id, carrier) + const id = `consultation-${hash([task.projectId, task.id, question.id, identity.id])}` + if (!messages(carrier.id).some(row => row.id === id)) messages(carrier.id).push({ id, kind: 'consultation', version: 'conversation-handoff/v1', direction: 'incoming', projectId: task.projectId, linkedTaskId: task.id, chiefDecisionId: question.id, requestedBy: { role: 'chief', runId: actor.chiefJobId }, initiatedBy: memberId, from: `${task.projectId}:chief`, to: carrier.id, returnTo: carrier.id, text: question.reason, requestId: question.id, status: 'accepted', requiresHuman: Boolean(question.requiresHuman), at: Date.now() }) + const token = rt.projectEffects?.stage({ kind: 'consultation.notify', key: id, projectId: task.projectId, taskId: task.id, payload: { consultationId: id, agentId: identity.id, carrierId: carrier.id, memberId } }) + return { kind: 'personal-agent', memberId, agentId: identity.id, participationId: participation.id, consultationId: id, ...(token ? { effectId: token.id } : {}) } + } + async function reply(body, actor) { + if (rt.readOnly?.() !== undefined) throw refuse('Consultations are read-only') + const carrier = await parent(body, actor) + if (!carrier) throw refuse('Unknown private consultation') + rt.personalAgents.requireParticipation(carrier) + const receipt = messages(carrier.id).find(row => row.id === body.consultationId) + view(carrier, receipt) + if (receipt.direction !== 'incoming') throw refuse('Only the delegated Chief executor may answer an outgoing consultation') + const task = ownEntry(state.tasks, receipt.linkedTaskId) + if (!task || task.projectId !== carrier.projectId || task.chiefQuestion?.decisionId !== receipt.chiefDecisionId) throw refuse('This Chief question is no longer current') + if (typeof body.requestId !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(body.requestId) || typeof body.text !== 'string' || !body.text.trim() || body.text.length > 4000) throw refuse('Reply needs a stable requestId and bounded text') + let answer = messages(carrier.id).find(row => row.conversationHandoffId === receipt.id && row.requestId === body.requestId) + if (answer && answer.text !== body.text) throw refuse('Reply requestId was reused with another answer') + if (!answer) { answer = { id: `consultation-reply-${hash([receipt.id, body.requestId])}`, kind: 'agent', speaker: 'Personal agent', text: body.text, conversationHandoffId: receipt.id, requestId: body.requestId, at: Date.now(), state: 'private-suggestion' }; messages(carrier.id).push(answer) } + const proxy = actor.personalAgentCredentialId || actor.consultationProxyAgentId + const delegated = proxy ? await rt.projectDelegations.actorFor(carrier.id) : null + const canPublish = (!proxy || Boolean(delegated)) && (!receipt.requiresHuman || !proxy) && rt.projectCan(actor, task.projectId, 'task-write', task) + if (body.publish !== true || !canPublish) { await rt.save(); return { consultation: view(carrier, receipt), replyId: answer.id, published: false, requiresUserApproval: true } } + // Existing Task inbox performs durable admission and Chief wake-up. A reply + // supplies explicit new input; it cannot close the Task or confirm a Project. + const result = await rt.projectInputs.publish({ projectId: task.projectId, taskId: task.id, requestId: answer.id, text: answer.text }, actor) + answer.state = 'published'; answer.inputId = result.input.id; answer.approvedBy = proxy ? null : actor.memberId + answer.publishedBy = { memberId: actor.memberId, agentId: proxy ? carrier.personalAgentId : null, credentialId: actor.personalAgentCredentialId ?? null } + receipt.status = 'completed'; receipt.finishedAt ??= Date.now() + await rt.save() + return { consultation: view(carrier, receipt), replyId: answer.id, published: true, input: result.input } + } + return { parent, view, endpoint, contactUser } +} diff --git a/core/src/host/project-development-service.js b/core/src/host/project-development-service.js index 3d462634..1a222ad8 100644 --- a/core/src/host/project-development-service.js +++ b/core/src/host/project-development-service.js @@ -11,6 +11,8 @@ const stableId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,100}$/. const sameTask = (row, task) => Boolean(row && row.projectId === task.projectId && row.taskId === task.id) const roles = new Set(['chief', 'developer', 'tester', 'reviewer']) const operations = Object.freeze({ + 'consultation.reply': 'project-consultation-reply', 'consultation.ask': 'project-consultation-ask', 'consultation.get': 'project-consultation-get', 'consultation.list': 'project-consultation-list', + 'handoff.get': 'project-handoff-get', 'handoff.list': 'project-handoff-list', 'task.run': 'project-task-run', 'task.detail': 'project-task-detail', 'task.accept': 'project-task-accept', 'task.reject': 'project-task-reject', 'attempt.stop': 'project-attempt-stop', 'attempt.cleanup': 'project-attempt-cleanup', @@ -25,7 +27,7 @@ const operations = Object.freeze({ 'execution.boundary': 'project-execution-boundary', 'pools': 'project-task-pools', 'effect.retry': 'project-effect-retry', 'comment.publish': 'project-github-comment', }) -const queries = new Set(['tasks', 'task', 'task.detail', 'inputs', 'sources', 'execution.boundary', 'effects', 'attempt.context', 'attempt.integration-state', 'attempt.log', 'chief.state', 'pools']) +const queries = new Set(['consultation.get', 'consultation.list', 'handoff.get', 'handoff.list', 'tasks', 'task', 'task.detail', 'inputs', 'sources', 'execution.boundary', 'effects', 'attempt.context', 'attempt.integration-state', 'attempt.log', 'chief.state', 'pools']) const legacy = new Set(Object.values(operations)) const readEndpoints = new Set([...queries].map(name => operations[name]).filter(Boolean)) export const developmentQuery = body => record(body) && body.version === DEVELOPMENT_PROTOCOL && body.kind === 'query' && queries.has(body.name) @@ -47,7 +49,7 @@ export function taskView(state, task, { activity = {} } = {}) { if (task.status === 'accepted') phase = 'accepted' else if (task.status === 'cancelled' || c?.status === 'cancelled' || attempt?.status === 'cancelled') phase = 'cancelled' else if (attempt?.cancelRequested || c?.status === 'stopping') { phase = 'stopping'; waitingFor = 'worker-exit' } - else if (task.chiefQuestion) { phase = 'awaiting-owner'; waitingFor = 'owner'; reason = task.chiefQuestion.reason } + else if (task.chiefQuestion) { phase = 'awaiting-owner'; waitingFor = task.chiefQuestion.recipient?.kind === 'personal-agent' ? 'personal-agent' : 'owner'; reason = task.chiefQuestion.reason } else if (['arbitration', 'chief-working'].includes(c?.status)) { phase = 'awaiting-chief'; waitingFor = 'chief' } else if (c?.status === 'blocked' || queue?.status === 'blocked') { phase = 'blocked'; reason = c?.block?.reason ?? queue?.reason ?? null } else if (queue) { phase = queue.status === 'starting' ? 'starting' : 'queued'; waitingFor = queue.reason ? 'resource' : 'capacity'; reason = queue.reason ?? null } @@ -166,6 +168,9 @@ export function createDevelopmentService(rt) { if (endpoint === 'project-execution-input') return rt.projectHarness.input(body, actor) if (endpoint === 'project-agent-proposal-apply') return rt.projectAgents.endpoint(endpoint, body, actor) if (endpoint === 'project-task-detail') return detail(body, actor) + if (['project-consultation-reply', 'project-consultation-ask', 'project-consultation-get', 'project-consultation-list'].includes(endpoint)) return rt.projectAgents.consultation(endpoint, body, actor) + if (endpoint === 'project-handoff-get') return { handoff: rt.projectPools.get(body.taskId, body.handoffId, actor) } + if (endpoint === 'project-handoff-list') return rt.projectPools.list(body.taskId, actor, body) if (endpoint === 'project-task-pools') { rt.requireProjectAccess(actor, body.projectId) return { pools: rt.projectAgents.deliveryDefinitions(body.projectId).roles.map(role => ({ botId: role.id, role: role.role, tasks: rt.projectPools.available(body.projectId, role.id, actor) })) } @@ -224,7 +229,9 @@ export function createDevelopmentService(rt) { // Trusted host ports. Public HTTP never accepts role tokens or model results. roleResult: row => rt.projectPools.result(row), roleCommand(body) { - if (body?.version !== DEVELOPMENT_PROTOCOL || !stableId(body.requestId) || !['claim', 'handoff', 'return', 'transfer', 'complete', 'arbitrate'].includes(body.name)) throw failure('Unsupported role command') + if (body?.version !== DEVELOPMENT_PROTOCOL || !stableId(body.requestId) || !['claim', 'handoff', 'return', 'transfer', 'complete', 'arbitrate', 'handoff.accept', 'handoff.offer'].includes(body.name)) throw failure('Unsupported role command') + if (body.name === 'handoff.accept') return rt.projectPools.accept(body.taskId, body.botId, body.handoffId, body.requestId) + if (body.name === 'handoff.offer') return rt.projectPools.decide(body.taskId, body.claim, { ...body.payload, requestId: body.requestId }) if (body.name === 'claim') return rt.projectPools.claim(body.taskId, body.botId) return rt.projectPools.decide(body.taskId, body.claim, { ...body.payload, action: body.name, requestId: body.requestId }) }, diff --git a/core/src/host/project-pools.js b/core/src/host/project-pools.js index b3866326..631d9c94 100644 --- a/core/src/host/project-pools.js +++ b/core/src/host/project-pools.js @@ -1,6 +1,7 @@ // Per-logical-Bot routing on existing Task records. Execution remains a host adapter. import { randomUUID, createHash } from 'node:crypto' import { ownEntry } from './store.js' +import { taskSessionId } from './project-task-sessions.js' import { EffectDeferred, EffectCancelled } from './project-effects.js' export const POOL_PROTOCOL = 'project-handoff/v1' const refuse = message => Object.assign(new Error(message), { code: 'bot/handoff' }) @@ -8,6 +9,50 @@ const clone = value => structuredClone(value) const intentHash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') const reasonOf = value => { if (typeof value !== 'string' || !value.trim() || value.length > 4000) throw refuse('A bounded handoff reason is required'); return value.trim() } + +export const HANDOFF_CONTRACT = 'project-handoff-contract/v1' +function contextReference(state, task, role) { + if (!role || role === 'chief') return null + const logicalSessionId = taskSessionId(task.projectId, task.id, role) + const logical = ownEntry(state.projectTaskSessions ?? {}, logicalSessionId) + const sessions = Object.values(state.projectAttempts ?? {}).filter(attempt => attempt.projectId === task.projectId && attempt.taskId === task.id) + .flatMap(attempt => [...(attempt.roleSessionHistory ?? []).filter(session => session.role === role), attempt.roleSessions?.[role]]) + .filter(session => session?.projectId === task.projectId && session.taskId === task.id && session.role === role) + .sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0)) + const session = sessions.at(-1) + return { logicalSessionId: session?.logicalSessionId ?? logicalSessionId, sessionId: session?.id ?? null, + definitionRef: session?.definitionRef ?? logical?.definitionRef ?? null, definitionRevision: session?.definitionRevision ?? null } +} +// A projection over the original durable receipt, not another workflow/store. +export function handoffContract(state, task, receipt) { + if (!receipt || receipt.projectId !== task.projectId || receipt.taskId !== task.id) throw refuse('Unknown Task handoff') + const roles = state.projectAgentDefinitions?.[task.projectId]?.roles ?? [] + const roleOf = id => roles.find(role => role.id === id)?.role ?? (typeof id === 'string' && id.startsWith(task.projectId + ':') ? id.slice(task.projectId.length + 1) : null) + const sourceRole = roleOf(receipt.from), targetRole = roleOf(receipt.to) + const previous = task.collaboration.history.find(row => row.id === receipt.predecessorId) + const execution = receipt.sourceExecution ?? previous?.execution + const successor = task.collaboration.history.find(row => row.predecessorId === receipt.id || row.requestId === receipt.execution?.id) + const currentClaim = task.collaboration.currentHandoffId === receipt.id ? task.collaboration.claim : null + const accepted = receipt.acceptance ?? (receipt.claimId ? { claimId: receipt.claimId, acceptedAt: receipt.acceptedAt, botId: receipt.to } : null) + const status = receipt.status === 'completed' ? 'processed' : receipt.status === 'intervened' ? 'revoked' : receipt.status === 'rejected' ? 'invalidated' : receipt.finishedAt && receipt.decision ? 'processed' : receipt.status + const context = receipt.contextRefs ?? { source: contextReference(state, task, sourceRole), target: contextReference(state, task, targetRole) } + const events = [{ type: 'offered', at: receipt.createdAt }] + if (accepted) events.push({ type: 'accepted', at: accepted.acceptedAt }) + if (receipt.finishedAt) events.push({ type: status, at: receipt.finishedAt }) + const ownsInitiative = status === 'accepted' && currentClaim?.status === 'active' + const fenced = status === 'accepted' && currentClaim && !ownsInitiative && currentClaim.status !== 'completed' + return { version: HANDOFF_CONTRACT, transportVersion: receipt.version, id: receipt.id, projectId: task.projectId, taskId: task.id, + requestId: receipt.requestId, attemptId: receipt.attemptId, generation: receipt.generation, state: status, purpose: receipt.purpose, + source: { botId: receipt.from, role: sourceRole, context: clone(context.source), execution: execution ? { claimId: execution.id, workerId: execution.workerId ?? null, generation: execution.generation, exitConfirmed: execution.exitConfirmed ?? null } : null }, + target: { botId: receipt.to, role: targetRole, context: clone(context.target) }, + summary: clone(receipt.summary ?? { completed: '', next: receipt.reason }), reason: receipt.reason, requirements: clone(receipt.requirements), + evidence: clone(receipt.evidence ?? []), claims: clone(receipt.claims ?? []), + acceptance: accepted ? { ...clone(accepted), workerId: receipt.execution?.workerId ?? currentClaim?.workerId ?? null } : null, + initiative: { kind: status === 'pending' ? 'pool' : ownsInitiative ? 'execution' : fenced ? 'fenced' : 'relinquished', botId: status === 'pending' || ownsInitiative ? receipt.to : null }, + predecessorId: receipt.predecessorId ?? null, successorId: successor?.id ?? null, + failure: !receipt.failure && currentClaim && task.collaboration.block ? { phase: 'recovery', reason: task.collaboration.block.reason } : receipt.failure ? { phase: receipt.execution?.failure ? 'execution-admission' : 'execution-or-evidence', reason: receipt.failure } : null, events } +} + export function createProjectPools(rt) { let adapter, tail = Promise.resolve() let recovered = false @@ -59,20 +104,25 @@ export function createProjectPools(rt) { if (!checked.ok) throw refuse(`Handoff refused (${checked.code}): ${checked.reason}`) return { fromRole, trustedEvidence } } - async function offer(task, { from, to, reason, requestId, attemptId, evidence = [], purpose = 'work', expectedGeneration }) { + async function offer(task, { from, to, reason, requestId, attemptId, evidence = [], purpose = 'work', expectedGeneration, summary }) { if (!adapter) throw refuse('Handoff execution adapter is unavailable') reason = reasonOf(reason) const c = task.collaboration, target = targetOf(task.projectId, to) if (typeof requestId !== 'string' || !/^[\w-]{1,120}$/.test(requestId)) throw refuse('A stable handoff requestId is required') const duplicate = c.history.find(row => row.requestId === requestId) if (duplicate) { - if (duplicate.from !== from || duplicate.to !== to || duplicate.reason !== reason) throw refuse('Handoff idempotency key was reused with different content') + if (duplicate.from !== from || duplicate.to !== to || duplicate.reason !== reason || duplicate.attemptId !== attemptId || duplicate.purpose !== purpose || summary && intentHash(duplicate.summary) !== intentHash(summary)) throw refuse('Handoff idempotency key was reused with different content') return clone(duplicate) } await authority(task) const { fromRole, trustedEvidence } = await checkAdmission(task, { from, to, attemptId, requestId, purpose }) if (expectedGeneration !== undefined && c.generation !== expectedGeneration) throw refuse('Claim generation changed before handoff admission') const row = { id: `handoff-${randomUUID()}`, version: POOL_PROTOCOL, policyVersion: 'project-role-policy/v1', definitionRevision: definitions(task.projectId).revision, projectId: task.projectId, taskId: task.id, attemptId, initiatedBy: c.initiatedBy, from, to, reason, requestId, purpose, evidence: clone(trustedEvidence), claims: clone(evidence), requirements: { title: task.title, criteria: task.criteria ?? '' }, status: 'pending', generation: c.generation + 1, createdAt: Date.now() } + const sourceClaim = c.claim?.botId === from ? c.claim : null + row.predecessorId = c.currentHandoffId ?? null + row.summary = summary ?? { completed: '', next: reason } + if (sourceClaim) row.sourceExecution = clone(sourceClaim) + row.contextRefs = { source: contextReference(rt.state, task, fromRole), target: contextReference(rt.state, task, target.role) } const before = rt.projectEffects ? { collaboration: clone(c), handoff: task.handoff, question: task.chiefQuestion } : null const input = rt.projectInputs?.stageHandoff(task, row) if (input) row.inputId = input.row.id @@ -113,11 +163,37 @@ export function createProjectPools(rt) { c.attemptId = attempt.id; c.kind = kind return offer(task, { from: null, to: target.id, reason: 'Authorized Task execution request', requestId: attempt.id, attemptId: attempt.id }) } - async function claim(taskId, botId, handoffId) { + async function claim(taskId, botId, handoffId, requestId) { const task = taskOf(taskId), c = task.collaboration + if (requestId !== undefined) { + if (!/^[A-Za-z0-9_-]{1,100}$/.test(requestId) || typeof requestId !== 'string' || !handoffId) throw refuse('Accept needs a stable requestId and handoffId') + await authority(task) + const prior = c?.history.find(row => row.acceptance?.requestId === requestId) + if (prior) { + if (prior.id !== handoffId || prior.to !== botId) throw refuse('Accept idempotency key was reused for another handoff') + const execution = prior.execution ?? (c.claim?.id === prior.claimId ? c.claim : null) + return execution ? clone(execution) : { ...clone(prior.acceptance), id: prior.claimId, status: 'historical' } + } + } if (!c || c.botId !== botId || c.status !== 'queued' || handoffId && c.currentHandoffId !== handoffId) return null await authority(task) - const definition = definitions(task.projectId).roles.find(row => row.id === botId) + let definition = definitions(task.projectId).roles.find(row => row.id === botId) + if (!definition) { c.status = 'blocked'; c.block = { reason: 'Target role is retired; Chief must explicitly select an active role', at: Date.now() }; await rt.save(); return null } + const target = definition, expectedGeneration = c.generation + if (target.role !== 'chief') { + // Arbitration may observe a failed writer, but a downstream execution must + // not bypass an unresolved old exit by selecting another resource. + const unknown = c.history.flatMap(row => [row.execution, row.sourceExecution]).filter(row => row?.workerId && row.exitConfirmed === false) + for (const execution of unknown) { + let exited = false + try { exited = await adapter.release(execution.workerId) } catch { /* Preserve unknown occupancy. */ } + if (!exited) throw refuse('Source execution exit is unconfirmed; handoff remains pending') + execution.exitConfirmed = true + } + await authority(task) + if (c.generation !== expectedGeneration || c.status !== 'queued' || c.botId !== botId) throw refuse('Handoff changed during source-exit confirmation') + } + definition = definitions(task.projectId).roles.find(row => row.id === botId) if (!definition) { c.status = 'blocked'; c.block = { reason: 'Target role is retired; Chief must explicitly select an active role', at: Date.now() }; await rt.save(); return null } if (Object.values(rt.state.tasks).some(row => row.collaboration?.claim?.botId === botId && row.collaboration.claim.status === 'active')) return null if (adapter.ready && !adapter.ready({ task, definition })) return null @@ -128,6 +204,7 @@ export function createProjectPools(rt) { const claim = { id: `claim-${randomUUID()}`, botId, role: definition.role, policyRole: definition.policyRole ?? definition.role, generation: ++c.generation, taskId: task.id, projectId: task.projectId, attemptId: c.attemptId, status: 'active', createdAt: Date.now() } c.usage ??= {}; c.usage.steps = (c.usage.steps ?? 0) + 1 c.claim = claim; c.status = 'claimed'; receipt.status = 'accepted'; receipt.acceptedAt = Date.now(); receipt.claimId = claim.id + receipt.acceptance = { claimId: claim.id, botId: claim.botId, generation: claim.generation, acceptedAt: receipt.acceptedAt, ...(requestId ? { requestId } : {}), ...(claim.chiefRunId ? { chiefRunId: claim.chiefRunId } : {}) } await rt.save() return clone(claim) } @@ -143,11 +220,15 @@ export function createProjectPools(rt) { const claim = await current(task, token) const action = decision?.action, reason = reasonOf(decision?.reason) if (!['handoff', 'return', 'transfer', 'complete', 'arbitrate'].includes(action)) throw refuse('Bot must hand off, return, transfer or request Chief judgement') + const summary = { completed: decision.completed === undefined ? '' : reasonOf(decision.completed), next: decision.next === undefined ? reason : reasonOf(decision.next) } const role = sourceOf(task, claim.botId).role const next = ['complete', 'arbitrate'].includes(action) ? definitions(task.projectId).roles.find(row => row.role === 'chief') : targetOf(task.projectId, decision.to) if (!next) throw refuse('Chief is not configured') // Stop confirmation precedes relinquishing the claim and capacity. - if (claim.workerId && !await adapter.release(claim.workerId)) throw refuse('Worker exit is unconfirmed; claim and capacity remain held') + if (claim.workerId) { + if (!await adapter.release(claim.workerId)) throw refuse('Worker exit is unconfirmed; claim and capacity remain held') + claim.exitConfirmed = true + } await current(task, token) if (action === 'handoff' || action === 'complete') await adapter.complete({ task, claim, role, outputs: decision.outputs ?? {} }) await current(task, token) @@ -162,7 +243,7 @@ export function createProjectPools(rt) { c.usage.tokens = (c.usage.tokens ?? 0) + (usage?.input_tokens ?? 0) + (usage?.output_tokens ?? 0) if ((next.policyRole ?? next.role) === 'developer' && c.kind === 'code') c.attemptId = await adapter.rework?.({ task, claim, reason }) ?? c.attemptId try { - return await offer(task, { from: claim.botId, to: next.id, reason, requestId: decision.requestId ?? claim.id, attemptId: c.attemptId, evidence: decision.evidence ?? [], purpose: action, expectedGeneration: claim.generation }) + return await offer(task, { from: claim.botId, to: next.id, reason, requestId: decision.requestId ?? claim.id, attemptId: c.attemptId, evidence: decision.evidence ?? [], purpose: action, expectedGeneration: claim.generation, summary }) } catch (error) { c.status = 'blocked'; c.block = { reason: error.message, at: Date.now() }; await rt.save(); throw error } @@ -171,6 +252,21 @@ export function createProjectPools(rt) { rt.requireProjectAccess(actor, projectId); targetOf(projectId, botId) return Object.values(rt.state.tasks).filter(task => task.projectId === projectId && task.collaboration?.botId === botId && ['queued', 'arbitration'].includes(task.collaboration.status)).map(clone) } + function get(taskId, handoffId, actor) { + const task = taskOf(taskId) + rt.requireProjectAccess(actor, task.projectId) + return handoffContract(rt.state, task, task.collaboration?.history.find(row => row.id === handoffId)) + } + function list(taskId, actor, { after, limit = 50 } = {}) { + const task = taskOf(taskId) + rt.requireProjectAccess(actor, task.projectId) + if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw refuse('Invalid handoff page limit') + const rows = task.collaboration?.history ?? [] + const index = after === undefined ? -1 : rows.findIndex(row => row.id === after) + if (after !== undefined && index < 0) throw refuse('Unknown handoff cursor') + const page = rows.slice(index + 1, index + 1 + limit) + return { items: page.map(row => handoffContract(rt.state, task, row)), next: index + 1 + page.length < rows.length ? page.at(-1).id : null } + } const owns = botId => Object.values(rt.state.tasks).some(task => task.collaboration?.claim?.workerId === botId || task.collaboration?.history.some(row => row.execution?.workerId === botId)) async function result({ botId, ok, text }) { const task = Object.values(rt.state.tasks).find(task => task.collaboration?.claim?.workerId === botId && task.collaboration.claim.status === 'active') @@ -210,7 +306,12 @@ export function createProjectPools(rt) { if (!token) continue try { const workerId = await adapter.execute({ task, claim: token, definition: targetOf(task.projectId, token.botId) }) - await serial(async () => { const claim = await current(task, token); claim.workerId = workerId; await rt.save() }) + await serial(async () => { + const claim = await current(task, token); claim.workerId = workerId + const receipt = task.collaboration.history.find(row => row.id === task.collaboration.currentHandoffId) + if (receipt?.acceptance?.claimId === claim.id) Object.assign(receipt.acceptance, { workerId, boundAt: Date.now(), context: contextReference(rt.state, task, targetOf(task.projectId, claim.botId).role) }) + await rt.save() + }) await adapter.dispatch({ task, claim: token, workerId }) } catch (error) { let failedClaim @@ -282,6 +383,7 @@ export function createProjectPools(rt) { if (!receipt || receipt.status !== 'pending') throw refuse('Chief handoff receipt is stale') const claim = { id: `chief-claim-${randomUUID()}`, taskId, projectId: task.projectId, attemptId: c.attemptId, botId: c.botId, chiefRunId: actor.chiefJobId, generation: ++c.generation, status: 'active', createdAt: Date.now() } c.claim = claim; c.status = 'chief-working'; receipt.status = 'accepted'; receipt.acceptedAt = Date.now(); receipt.claimId = claim.id + receipt.acceptance = { claimId: claim.id, botId: claim.botId, generation: claim.generation, acceptedAt: receipt.acceptedAt, ...(claim.chiefRunId ? { chiefRunId: claim.chiefRunId } : {}) } await rt.save(); return clone(claim) } async function chiefRoute(body, actor) { @@ -342,7 +444,7 @@ export function createProjectPools(rt) { } return changed } - return { connect(value) { adapter = value; return () => { if (adapter === value) adapter = null } }, admit: (attempt, actor, body) => serial(() => admit(attempt, actor, body)), available, owns, + return { connect(value) { adapter = value; return () => { if (adapter === value) adapter = null } }, get, list, accept: (taskId, botId, handoffId, requestId) => serial(() => claim(taskId, botId, handoffId, requestId)), admit: (attempt, actor, body) => serial(() => admit(attempt, actor, body)), available, owns, claim: (id, botId) => serial(() => claim(id, botId)), decide: (id, token, decision) => serial(() => decide(id, token, decision)), result: row => serial(() => result(row)), pump, recover, revokePrincipal: (projectId, memberId) => serial(() => revokePrincipal(projectId, memberId)), revokeExecution: (id, claimId, reason) => serial(() => revokeExecution(id, claimId, reason)), validateClosure, retryChief: (id, actor) => serial(() => retryChief(id, actor)), chiefClaim: (id, actor) => serial(() => chiefClaim(id, actor)), chiefRoute: (body, actor) => serial(() => chiefRoute(body, actor)) } } export function install(rt) { diff --git a/core/src/host/workbench-gateway.js b/core/src/host/workbench-gateway.js index e117a3e6..588c58bc 100644 --- a/core/src/host/workbench-gateway.js +++ b/core/src/host/workbench-gateway.js @@ -18,7 +18,7 @@ export const WORKBENCH_ENDPOINTS = new Set([ 'project-attempt-stop', 'project-attempt-log', 'project-attempt-cleanup', 'project-attempt-publication-retry', 'project-rooms', 'project-room-create', 'project-room-read', 'project-room-message', 'project-room-attachment', 'project-room-stop', 'project-agent-stop', 'project-agents', 'project-agent-message', 'project-agent-history', 'project-agent-memory-write', 'project-role-memory-write', 'project-agent-definition', 'project-roles', 'project-role-proposals', 'project-role-decision', - 'project-development', 'project-task-pools', 'project-chief-question', 'project-github-read', 'project-agent-mode', + 'project-development', 'project-task-pools', 'project-consultation-reply', 'project-consultation-ask', 'project-consultation-get', 'project-consultation-list', 'project-handoff-get', 'project-handoff-list', 'project-chief-question', 'project-github-read', 'project-agent-mode', 'project-chief-state', 'project-chief-configure', 'project-chief-inspect', 'project-scheduled-task', 'project-agent-proposal-apply', 'project-knowledge-write', 'project-credential-status', 'project-github-comment', ]) diff --git a/core/test/personal-agent-auth.test.js b/core/test/personal-agent-auth.test.js index b8e40c07..f0d4d96a 100644 --- a/core/test/personal-agent-auth.test.js +++ b/core/test/personal-agent-auth.test.js @@ -219,3 +219,67 @@ test('credential pages are bounded and malformed or future credential protocols credential.version = 'future/v2' assert.equal((await http(one.token, 'projects')).ok, false) }) + +test('safe external consultation uses the authenticated agent mailbox and cannot borrow another agent history', async t => { + const { rt, http } = await setup(t) + rt.deliver = async ({ sessionId, exchangeId }) => rt.projectAgents.onEvent({ id: sessionId }, { type: 'exchange/start', data: { exchangeId } }) + const connection = await external(rt) + const response = await http(connection.token, 'project-consultation-ask', { projectId: 'p1', requestId: 'safe-question', text: 'What is the shared project goal?' }) + assert.equal(response.ok, true, JSON.stringify(response)) + assert.equal(response.value.consultation.agentId, connection.agent.id) + assert.equal(response.value.consultation.initiativeTransferred, false) + assert.equal(Object.keys(rt.state.tasks).length, 0) + const other = await external(rt) + const query = { projectId: 'p1', consultationId: response.value.consultation.id, agentId: connection.agent.id } + assert.equal((await http(other.token, 'project-consultation-get', query)).ok, false) + assert.equal((await http(connection.token, 'project-consultation-get', query)).ok, true) + const envelope = await http(connection.token, 'project-development', { version: 'project-development/v1', kind: 'query', projectId: 'p1', name: 'consultation.get', payload: { consultationId: query.consultationId } }) + assert.equal(envelope.ok, true) + assert.equal(envelope.value.result.consultation.id, query.consultationId) + assert.equal((await http(connection.token, 'project-consultation-ask', { ...query, projectId: 'p2', requestId: 'foreign-question', text: 'Cross project?' })).ok, false) + await api(rt, 'personal-agent-token-revoke', { credentialId: connection.credential.id, agentId: connection.agent.id }) + assert.equal((await http(connection.token, 'project-consultation-get', query)).ok, false) +}) + +test('Chief contacts a selected personal agent first; safe suggestions require human approval and watch replies use existing input admission', async t => { + const { rt, http } = await setup(t) + const connection = await external(rt) + await api(rt, 'personal-agent-default', { projectId: 'p1', agentId: connection.agent.id }) + rt.projectChiefControl.requireJobActor = actor => { assert.equal(actor.chiefJobId, 'test-chief-run') } + const task = { id: 't', projectId: 'p1', requesterId: 'alice', title: 'Implement the selected solution', status: 'open', collaboration: { status: 'blocked', initiatedBy: 'alice', history: [], generation: 1 } } + rt.state.tasks.t = task + const ask = async (id, requiresHuman = false) => { + const recipient = rt.projectAgents.contactUser(task, { id, reason: 'Which approved option should we use?', requiresHuman }, { memberId: 'alice', chiefJobId: 'test-chief-run' }) + task.chiefQuestion = { decisionId: id, reason: 'Which option?', recipient } + await rt.save() + return recipient + } + const recipient = await ask('decision-one') + assert.equal(recipient.kind, 'personal-agent') + assert.equal(recipient.agentId, connection.agent.id) + const inbox = await http(connection.token, 'project-consultation-list', { projectId: 'p1' }) + assert.equal(inbox.value.items[0].direction, 'incoming') + assert.equal(inbox.value.items[0].linkedTaskId, 't') + const answer = { projectId: 'p1', consultationId: recipient.consultationId, requestId: 'one-answer', text: 'Choose the smaller approved change', publish: true } + const safe = await http(connection.token, 'project-consultation-reply', answer) + assert.equal(safe.ok, true, JSON.stringify(safe)) + assert.equal(safe.value.published, false) + assert.equal(safe.value.requiresUserApproval, true) + assert.equal(Object.keys(rt.state.projectTaskInputs ?? {}).length, 0) + const approved = await api(rt, 'project-consultation-reply', { ...answer, agentId: connection.agent.id }) + assert.equal(approved.published, true) + assert.equal((await api(rt, 'project-consultation-reply', { ...answer, agentId: connection.agent.id })).input.id, approved.input.id) + assert.equal(Object.keys(rt.state.projectTaskInputs).length, 1) + assert.equal(task.collaboration.status, 'blocked', 'Explicit advice is new input; Chief still decides the next execution') + await api(rt, 'personal-agent-delegation-set', { projectId: 'p1', agentId: connection.agent.id, mode: 'watch' }) + const second = await ask('decision-two') + const watched = await http(connection.token, 'project-consultation-reply', { ...answer, consultationId: second.consultationId }) + assert.equal(watched.ok, true, JSON.stringify(watched)) + assert.equal(watched.value.published, true) + const third = await ask('decision-personal', true) + const personalOnly = await http(connection.token, 'project-consultation-reply', { ...answer, consultationId: third.consultationId }) + assert.equal(personalOnly.value.published, false) + assert.equal(personalOnly.value.requiresUserApproval, true) + assert.equal(task.status, 'open') + assert.equal(Object.keys(rt.state.tasks).length, 1) +}) diff --git a/core/test/project-agents.test.js b/core/test/project-agents.test.js index 59f93248..4768b712 100644 --- a/core/test/project-agents.test.js +++ b/core/test/project-agents.test.js @@ -586,6 +586,131 @@ test('a safe personal agent can recommend closure without a Task or Owner confir assert.equal(rt.readProject('p1').status, 'active') }) +test('consultation APIs expose private durable question/reply records without Task initiative transfer', async t => { + const { rt } = await setup(t) + const original = await get(rt, alice) + const request = { projectId: 'p1', requestId: 'consultation-query', text: 'Explain the current architecture' } + const result = await rt.projectEndpoint('project-consultation-ask', request, alice) + assert.equal(result.consultation.version, 'project-consultation/v1') + assert.equal(result.consultation.initiativeTransferred, false) + assert.equal(Object.keys(rt.state.tasks).length, 0) + const id = result.consultation.id + const chief = Object.values(rt.state.projectAgents).find(row => row.conversationHandoffId === id) + rt.projectAgents.onEvent({ id: chief.sessionId }, { type: 'assistant/message', data: { message: { content: [{ type: 'text', text: 'Scoped architecture answer' }] } } }) + const view = await rt.projectEndpoint('project-consultation-get', { projectId: 'p1', consultationId: id }, alice) + assert.equal(view.consultation.replies.at(-1).text, 'Scoped architecture answer') + assert.equal(view.consultation.returnTo, original.mine.id) + const list = await rt.projectEndpoint('project-consultation-list', { projectId: 'p1' }, alice) + assert.equal(list.items[0].id, id) + await assert.rejects(rt.projectEndpoint('project-consultation-get', { projectId: 'p1', consultationId: id }, bob), /Unknown/) + assert.equal((await rt.projectEndpoint('project-consultation-list', { projectId: 'p1' }, bob)).items.length, 0) + const replay = await rt.projectEndpoint('project-consultation-ask', request, alice) + assert.equal(replay.consultation.id, id) + assert.equal(replay.already, true) + assert.equal(Object.keys(rt.state.tasks).length, 0) +}) + +test('an external personal agent can consult Chief through its own mailbox without a native agent or private-history borrowing', async t => { + const { rt } = await setup(t) + await get(rt, alice) + await rt.projectEndpoint('project-agent-memory-write', { projectId: 'p1', text: 'DEFAULT_AGENT_PRIVATE_SECRET' }, alice) + const { agent } = await rt.projectEndpoint('personal-agent-create', { name: 'External client', kind: 'external', provider: 'client', endpoint: 'https://example.test/agent' }, alice) + await rt.projectEndpoint('personal-agent-join', { agentId: agent.id, projectId: 'p1' }, alice) + const request = { projectId: 'p1', agentId: agent.id, requestId: 'external-consult', text: 'Which shared goal is active?' } + const reply = await rt.projectEndpoint('project-consultation-ask', request, alice) + const carrier = rt.state.projectAgents[reply.consultation.returnTo] + assert.equal(carrier.personalAgentId, agent.id) + assert.equal(carrier.botId, undefined) + assert.equal(JSON.stringify(reply).includes('DEFAULT_AGENT_PRIVATE_SECRET'), false) + const chief = Object.values(rt.state.projectAgents).find(row => row.conversationHandoffId === reply.consultation.id) + rt.projectAgents.onEvent({ id: chief.sessionId }, { type: 'assistant/message', data: { message: { content: [{ type: 'text', text: 'Shared goal reply' }] } } }) + const view = await rt.projectEndpoint('project-consultation-get', { ...request, consultationId: reply.consultation.id }, alice) + assert.equal(view.consultation.replies.at(-1).text, 'Shared goal reply') + await assert.rejects(rt.projectEndpoint('project-consultation-get', { ...request, consultationId: reply.consultation.id }, bob), /owner|access|Unknown/i) + assert.equal(Object.keys(rt.state.tasks).length, 0) +}) + +test('consultation queries do not allocate missing agents and reject malformed pages, stale or forged replies', async t => { + const { rt } = await setup(t) + await assert.rejects(rt.projectAgents.consultation('unsupported', { projectId: 'p1' }, alice), /Unknown/ ) + const before = JSON.stringify(rt.state.projectAgents) + assert.deepEqual((await rt.projectEndpoint('project-consultation-list', { projectId: 'p1' }, bob)).items, []) + assert.equal(JSON.stringify(rt.state.projectAgents), before) + await assert.rejects(rt.projectEndpoint('project-consultation-get', { projectId: 'p1', consultationId: 'missing' }, bob), /Unknown/) + const request = { projectId: 'p1', requestId: 'bounded-consult', text: 'Explain the current goal' } + const { consultation } = await rt.projectEndpoint('project-consultation-ask', request, alice) + for (const body of [{ limit: 0 }, { limit: 101 }, { after: 'foreign' }]) await assert.rejects(rt.projectEndpoint('project-consultation-list', { projectId: 'p1', ...body }, alice), /limit|cursor/) + await assert.rejects(rt.projectEndpoint('project-consultation-get', { projectId: 'p1', consultationId: consultation.id, offset: -1 }, alice), /page/) + await assert.rejects(rt.projectEndpoint('project-consultation-reply', { projectId: 'p1', consultationId: consultation.id, requestId: 'spoof', text: 'Pretend to be Chief', publish: true }, alice), /Chief executor/) + await assert.rejects(rt.projectEndpoint('project-consultation-list', { projectId: 'p1', agentId: 'unknown' }, alice), /participation/) + const peer = await get(rt, bob) + await assert.rejects(rt.projectEndpoint('project-consultation-list', { projectId: 'p1', agentId: peer.mine.personalAgentId }, alice), /owner/) +}) + +test('personal-agent notifications run only after commit, reuse watch authority and do not borrow a changed default', async t => { + const { rt } = await setup(t) + const original = await get(rt, alice) + await rt.projectEndpoint('project-agent-mode', { projectId: 'p1', mode: 'watch' }, alice) + rt.projectChiefControl.requireJobActor = actor => { assert.equal(actor.chiefJobId, 'chief-run') } + const actor = { ...alice, chiefJobId: 'chief-run' } + const task = { id: 'notify-task', projectId: 'p1', status: 'open', requesterId: alice.memberId } + rt.state.tasks[task.id] = task + let wakes = 0 + rt.projectAgents.wakeWatch = async (projectId, memberId, text, carrierId) => { + wakes++ + assert.equal(projectId, 'p1') + assert.equal(memberId, alice.memberId) + assert.equal(carrierId, original.mine.id) + assert.match(text, /project_agent_consultations/) + return { agent: { id: carrierId } } + } + const question = { id: 'question-one', reason: 'Clarify the planned behavior' } + const recipient = rt.projectAgents.contactUser(task, question, actor) + assert.equal(rt.projectAgents.contactUser(task, question, actor).consultationId, recipient.consultationId) + task.chiefQuestion = { decisionId: question.id, recipient } + await rt.projectEffects.drain() + assert.equal(wakes, 0) + await rt.save() + await rt.projectEffects.drain() + assert.equal(wakes, 1) + await rt.projectEffects.drain() + assert.equal(wakes, 1) + const next = rt.projectAgents.contactUser(task, { id: 'question-two', reason: 'Another scoped question' }, actor) + task.chiefQuestion = { decisionId: 'question-two', recipient: next } + await rt.save() + const { agent } = await rt.projectEndpoint('personal-agent-create', { name: 'Another assistant' }, alice) + await rt.projectEndpoint('personal-agent-join', { projectId: 'p1', agentId: agent.id }, alice) + await rt.projectEndpoint('personal-agent-default', { projectId: 'p1', agentId: agent.id }, alice) + await rt.projectEffects.drain() + assert.equal(wakes, 1) + const ended = rt.projectAgents.contactUser(task, { id: 'question-ended', reason: 'This question will be cancelled' }, actor) + task.chiefQuestion = { decisionId: 'question-ended', recipient: ended } + await rt.save() + delete task.chiefQuestion + await rt.projectEffects.drain() + assert.equal(rt.state.projectEffects[ended.effectId].status, 'cancelled') + const noAgent = { ...task, requesterId: bob.memberId } + assert.equal(rt.projectAgents.contactUser(noAgent, { id: 'no-agent', reason: 'Ask the current human' }, actor).kind, 'human') +}) + +test('a busy native watch defers its committed notification without exhausting retries', async t => { + const { rt } = await setup(t) + await get(rt, alice) + await rt.projectEndpoint('project-agent-mode', { projectId: 'p1', mode: 'watch' }, alice) + rt.projectChiefControl.requireJobActor = () => {} + const task = { id: 'busy-notify', projectId: 'p1', requesterId: alice.memberId, status: 'open' } + rt.state.tasks[task.id] = task + const recipient = rt.projectAgents.contactUser(task, { id: 'busy-question', reason: 'Clarify a current choice' }, { ...alice, chiefJobId: 'chief-run' }) + task.chiefQuestion = { decisionId: 'busy-question', recipient } + rt.projectAgents.wakeWatch = async () => { throw Object.assign(new Error('Already answering'), { code: 'bot/busy' }) } + await rt.save() + await rt.projectEffects.drain() + const effect = rt.state.projectEffects[recipient.effectId] + assert.equal(effect.status, 'pending') + assert.equal(effect.failures, 0) + assert.equal(effect.waitingFor, 'agent') +}) + test('resident Chief role proposals require separate Owner approval and cannot be forged by private Bots or HTTP fields', async t => { const { rt } = await setup(t), initial = await get(rt, alice) await message(rt, bob, 'public', 'Suggest an additional analysis role') diff --git a/core/test/project-chief-control.test.js b/core/test/project-chief-control.test.js index 12161309..4bbedeae 100644 --- a/core/test/project-chief-control.test.js +++ b/core/test/project-chief-control.test.js @@ -133,6 +133,21 @@ test('inspection compacts only proven released history and preserves current, un assert.equal(inspectionResources(state, task).releasedResourceHistory, undefined) }) +test('a needs-human decision records the personal-agent recipient without transferring Task initiative', async () => { + const f = await controlFixture() + f.rt.projectAgents.contactUser = (task, question, actor) => { + assert.equal(task.id, 't') + assert.equal(question.reason, 'Need the user preference') + assert.equal(actor.chiefJobId, f.request.runId) + return { kind: 'personal-agent', memberId: 'owner', agentId: 'personal', consultationId: 'consult' } + } + await f.request.onResult(f.result({ action: 'needs-human', reason: 'Need the user preference' })) + assert.equal(f.task.chiefQuestion.recipient.agentId, 'personal') + assert.equal(f.task.collaboration.status, 'blocked') + assert.equal(f.task.collaboration.botId, 'p:chief') + assert.equal(f.accepted(), 0) +}) + test('validated Chief completion can publish bounded role proposals but cannot apply role changes', async () => { const f = await controlFixture(), calls = [] diff --git a/core/test/project-pools.test.js b/core/test/project-pools.test.js index 3cd70ec3..ba873ddd 100644 --- a/core/test/project-pools.test.js +++ b/core/test/project-pools.test.js @@ -223,6 +223,108 @@ test('Chief intervention does not reset global usage or bypass quota', async () assert.deepEqual(f.task, before) }) +test('handoff queries distinguish pending, accepted and processed initiative without closing the Task', async () => { + const f = setup(), actor = { memberId: 'owner' } + const offered = await f.admit() + const pending = f.pool.get('task', offered.id, actor) + assert.equal(pending.state, 'pending') + assert.equal(pending.initiative.kind, 'pool') + assert.equal(pending.target.role, 'developer') + assert.equal(pending.acceptance, null) + const claim = await f.claim() + const accepted = f.pool.get('task', offered.id, actor) + assert.equal(accepted.state, 'accepted') + assert.equal(accepted.acceptance.claimId, claim.id) + assert.equal(accepted.initiative.kind, 'execution') + const next = await f.pool.decide('task', claim, { action: 'transfer', to: 'p:tester', reason: 'Inspect the code before continuing', completed: 'Located the affected module', next: 'Check the regression strategy' }) + const processed = f.pool.get('task', offered.id, actor) + assert.equal(processed.state, 'processed') + assert.equal(processed.successorId, next.id) + assert.equal(processed.initiative.kind, 'relinquished') + const target = f.pool.get('task', next.id, actor) + assert.equal(target.source.execution.claimId, claim.id) + assert.equal(target.summary.completed, 'Located the affected module') + assert.equal(target.summary.next, 'Check the regression strategy') + target.summary.next = 'Caller mutation' + assert.equal(f.pool.get('task', next.id, actor).summary.next, 'Check the regression strategy') + assert.deepEqual(target.events.map(row => row.type), ['offered']) + assert.notEqual(f.task.status, 'accepted') + assert.equal(f.pool.list('task', actor, { limit: 1 }).items.length, 1) + assert.equal(f.pool.list('task', actor, { after: offered.id }).items[0].id, next.id) + assert.equal(f.pool.list('task', actor, { after: next.id }).items.length, 0) + assert.throws(() => f.pool.get('task', offered.id, { memberId: 'stranger' }), /authority/) + assert.throws(() => f.pool.get('task', 'missing', actor), /Unknown/) + assert.throws(() => f.pool.list('task', actor, { after: 'foreign' }), /cursor/) + f.rt.revoked = true + assert.throws(() => f.pool.get('task', offered.id, actor), /authority/) +}) + +test('handoff contracts resolve scoped context references without copying private or other-role history', async () => { + const f = setup(), actor = { memberId: 'owner' } + await f.admit() + const claim = await f.claim() + const scope = { projectId: 'p', taskId: 'task', attemptId: 'attempt' } + f.rt.state.projectAttempts = { attempt: { ...scope, roleSessions: { + developer: { ...scope, id: 'dev-session', role: 'developer', logicalSessionId: 'dev-logical', definitionRevision: 7, context: { text: 'DO NOT COPY PRIVATE TRANSCRIPT' } }, + tester: { ...scope, id: 'test-session', role: 'tester', logicalSessionId: 'test-logical', definitionRevision: 5 }, + } } } + const receipt = await f.pool.decide('task', claim, { action: 'return', to: 'p:tester', reason: 'Return with a focused question', context: { sessionId: 'forged' } }) + const view = f.pool.get('task', receipt.id, actor) + assert.equal(view.source.context.sessionId, 'dev-session') + assert.equal(view.target.context.sessionId, 'test-session') + assert.equal(JSON.stringify(view).includes('DO NOT COPY'), false) + assert.equal(JSON.stringify(view).includes('forged'), false) + f.rt.state.projectAttempts.attempt.roleSessions.tester.taskId = 'foreign' + assert.equal(f.pool.get('task', receipt.id, actor).target.context.sessionId, 'test-session', 'The original handoff reference remains immutable') + const tester = await f.pool.claim('task', 'p:tester') + const returned = await f.pool.decide('task', tester, { action: 'return', to: 'p:developer', reason: 'Need another code change' }) + assert.equal(f.pool.get('task', returned.id, actor).source.context.sessionId, null, 'A new contract cannot resolve a foreign-scoped Session') +}) + +test('explicit handoff acceptance replays a lost response but never reactivates completed execution', async () => { + const f = setup(), offered = await f.admit() + const first = await f.pool.accept('task', 'p:developer', offered.id, 'accept-original') + assert.equal((await f.pool.accept('task', 'p:developer', offered.id, 'accept-original')).id, first.id) + const next = await f.pool.decide('task', first, { action: 'transfer', to: 'p:tester', reason: 'Check the code' }) + const replay = await f.pool.accept('task', 'p:developer', offered.id, 'accept-original') + assert.equal(replay.status, 'completed') + assert.equal(f.task.collaboration.currentHandoffId, next.id) + assert.equal(f.task.collaboration.claim, undefined) + await assert.rejects(f.pool.accept('task', 'p:tester', next.id, 'accept-original'), /idempotency/) + await assert.rejects(f.pool.accept('task', 'p:tester', next.id, ''), /stable/) + const accepted = await f.pool.accept('task', 'p:tester', next.id, 'accept-next') + assert.equal(accepted.botId, 'p:tester') + assert.equal(f.pool.get('task', next.id, { memberId: 'owner' }).acceptance.requestId, 'accept-next') +}) + +test('Chief observation cannot let a later pool bypass an earlier writer with unknown exit', async () => { + const f = setup(); await f.admit() + f.adapter.execute = async () => 'unknown-writer' + f.adapter.dispatch = async () => { throw new Error('Lost response') } + f.adapter.release = async () => false + await f.pool.pump() + f.rt.projectChiefControl = { requireJobActor() {}, validateActor: async () => true } + f.rt.state.projectAttempts = { attempt: { status: 'running' } } + await f.pool.chiefRoute({ taskId: 'task', targetBotId: 'p:tester', expectedGeneration: f.task.collaboration.generation, reason: 'Inspect after a dispatch failure', decisionId: 'chief-reroute' }, { memberId: 'owner' }) + await assert.rejects(f.pool.claim('task', 'p:tester'), /Source execution exit is unconfirmed/) + assert.equal(f.task.collaboration.status, 'queued') + assert.equal(f.task.collaboration.claim, undefined) + f.adapter.release = async () => true + assert.equal((await f.pool.claim('task', 'p:tester')).botId, 'p:tester') +}) + +test('an interrupted acceptance exposes a fenced initiative and recovery cause', async () => { + const f = setup(), offered = await f.admit() + await f.claim() + f.task.collaboration.claim.workerId = 'unknown-writer' + f.pool.recover() + const view = f.pool.get('task', offered.id, { memberId: 'owner' }) + assert.equal(view.state, 'accepted') + assert.equal(view.initiative.kind, 'fenced') + assert.equal(view.failure.phase, 'recovery') + assert.match(view.failure.reason, /restarted/) +}) + test('a retired source can finish its frozen active claim but cannot receive new offers', async () => { const f = setup(); await f.admit(); const claim = await f.claim() @@ -260,3 +362,17 @@ test('host execution revocation fences the exact claim before stopping and never assert.equal(await f.pool.revokeExecution('task', token.id, 'Repeated observation'), false) await assert.rejects(f.pool.decide('task', token, { action: 'handoff', to: 'p:tester', reason: 'Stale callback' }), /stale/) }) + +test('a role retired while source exit is inspected cannot acquire a new handoff claim', async () => { + const f = setup(); await f.admit() + f.task.collaboration.history[0].sourceExecution = { workerId: 'old-worker', exitConfirmed: false } + f.adapter.release = async () => { + const roles = f.rt.projectAgents.deliveryDefinitions().roles + roles.splice(roles.findIndex(role => role.role === 'developer'), 1) + return true + } + assert.equal(await f.claim(), null) + assert.equal(f.task.collaboration.status, 'blocked') + assert.equal(f.task.collaboration.claim, undefined) + assert.match(f.task.collaboration.block.reason, /retired/) +}) diff --git a/docs/history/2026-11-03-completed-m7-checkpoints.md b/docs/history/2026-11-03-completed-m7-checkpoints.md new file mode 100644 index 00000000..5a331f2a --- /dev/null +++ b/docs/history/2026-11-03-completed-m7-checkpoints.md @@ -0,0 +1,37 @@ +# Completed M7 implementation checkpoints + +Verbatim prior checkpoint narrative; current authorization and outstanding validation remain in ROADMAP.md. + +### M7.2 — 2026-10-31 + +M7.1 PR #26 passed full verify and actual DSH context/lifecycle checks. M7.2 implementation on feat/m7-resource-pools adds resource capabilities/leases and real local+Docker execution probes; integration acceptance with SDKs remains in M7.5. + + +### M7.3 checkpoint — 2026-10-31 + +M7.2 PR #27 passed full verify and the opt-in actual local/Docker resource probe. M7.3 adapters are in progress; the implementation PR remains draft until remaining integration and real authorized SDK acceptance are complete; Chief now uses a host-validated scoped action bridge. + + +### M7.4 — 2026-10-31 + +M7.3 PR #28 passed full verify at 034d86c, remains draft pending authenticated acceptance. M7.4 implementation supplies Owner runtime configuration/probes/private PTY and strengthens positive worker-stop confirmation; M7.5 cross-resource delivery and final acceptance remain next. + + +### M7.5 — 2026-10-31 + +M7 implementation PRs: #26 role Sessions, #27 resource pools, #28 official SDK Harnesses, #29 Owner setup, #30 handoff/integration. Full verify passed #26–#29 tips; final #30 validation/report is recorded in its PR. Real authenticated SDK/browser mixed-resource acceptance remains pending dedicated authorized profiles; M8 continuous CI/review and M9 persistent dogfood operations remain later work. + + +## Original PROJECT.md checkpoints + +### M7.2 — 2026-10-31 + +M7.2 adds operator-granted local/Docker execution resources, safe environment observations, candidate pools, sticky resource leases and shared queue capacity accounting. The actual two-runtime probe passed with distinct hostnames/filesystems and confirmed cleanup. SDK authentication and mixed-resource Task delivery remain M7.3–M7.5. See docs/subsystems/project-resources.md. + + +### M7.3 checkpoint — 2026-10-31 + +M7.3 work in progress adds pinned official Codex/Claude Agent SDK temporary worker adapters through the existing Workflow seam, explicit per-resource/principal profiles, streamed evidence and exit-confirmed resource release. Chief SDK actions now use a host-validated scoped JSON bridge; cross-resource dispatch remains pending; no full real-SDK acceptance is claimed. + + +End of verbatim checkpoint excerpts. diff --git a/docs/history/2026-11-03-service-integration.md b/docs/history/2026-11-03-service-integration.md new file mode 100644 index 00000000..ceedf66e --- /dev/null +++ b/docs/history/2026-11-03-service-integration.md @@ -0,0 +1,193 @@ +# Archived service implementation evidence — 2026-11-03 + +The following completed/historical implementation narrative is preserved verbatim from PROJECT.md. Current implementation and authorization remain in active repository memory. + +### Service recovery acceptance — S6 / issue55, 2026-11-02 + +The final stage composes the verified S5 service with the native frontend candidate +from original Task40/PR48, preserving both source histories. An operator CLI repeats +isolated real-store/Git/controlled-adapter recovery scenarios; it never injects +production faults or represents mocks as vendor execution. Real GitHub feedback +has automatically reached the original Task's actual Codex Developer, with explicit +read/handled receipts and downstream Tester handoff, without manual relay/new Task. +Matching candidate test/Review/PR and Chief decisions remain separately recorded. +See [runbook](docs/service-recovery-demo.md). Integration/real UI screenshots and +#41's remaining visual/dual-mode matrix are not established by this CLI. + +### Trusted execution boundary — S5 / issue54, 2026-11-02 + +The compatible profile explicitly supports trusted private execution on one +dedicated service account, without mandatory per-Bot users/containers. A new +read-only execution.boundary query reports current human rights/delegation, exact +credential selection, authorized resources and honest preflight/isolation limits. +Unknown profiles refuse repository/resource admission; watch grants never elevate +current Project rights. No live account, credential or sandbox change is made. +See [decision](decisions/2026-11-02-trusted-execution-service.md) and +[boundary architecture](docs/subsystems/project-execution-boundary.md). +Private-memory APIs, resource/lease/generation/confirmed-exit/workspace coordination, +same-candidate tests/Review and Chief adjudication remain mandatory. + +### GitHub feedback subscriptions — S4 / issue53, 2026-11-02 + +Source/Thread/TaskSubscription now feeds the existing durable inbox through scoped +GitHub reads and configurable native schedules. Stable revisions, cursor replay, +atomic event/input/Effect persistence and current-Task routing preserve Bot handoff +choice. Old-SHA checks/Review and remote closure never grant current evidence or +local closure. Default activation keeps earlier discussion as context, explicit +history backfill is available; stopped/context Tasks never wake. Long feedback is +retained losslessly in bounded parts, and unsubmitted Review drafts are excluded. +See [Source architecture](docs/subsystems/project-sources.md). Source tests and a +real UID998 scoped GitHub read are recorded; complete live feedback/handoff +acceptance remains separate until actually run. + +Browser-offline polling uses a host-only current-principal Project read port, +not a browser-session bypass flag. Repository identity is captured before async +credential lookup; in-flight changes refuse rather than borrowing another scope. + +S1/S2/S3 and the #60 host-seal compatibility repair are deployed together at +e20262ed, with4384 full checks and actual three-account authorization probes. +Original frontend Task40/PR48 resumed through Chief and sealed a new candidate; +the native Tester handoff is active, not final UI acceptance. Current backend +development remains in the foreground session, without frontend Task replacement. + +### Approved integration sealing — issue60, 2026-11-02 + +S3/PR61 is verified (4374 tests and exact-source real Codex inbox→Chief-pool +acceptance), pending compatible rollout with S2. The autonomous frontend exposed a +separate host preparation gap: an approved operator merge remained staged while +SDK shared-ref writes were correctly denied; host sealing only recognized the +published upstream and attempted a soft reset during that extra merge. + +The foreground repair adds manager-only, durable source admission with exact +operator ref/SHA, workspace snapshot and generation fences. Host sealing retains +the staged tree and trusted parents without opening refs to SDKs or claiming test/ +Review evidence. Failed/no-op saves cannot authorize a parent. The original +Task40/PR48/Attempt and merge draft stay intact; resume through Chief after verified +service deployment. No backend Bot Task or control-plane/sandbox/credential change. + +The first compatible S2/S3/60 rollout passed three-account live reads, but source +admission exposed over-broad historical Chief occupancy. Admission now requires +exit proof for the current Chief claim, every unreleased Project Chief and this +Attempt's workers, without treating unrelated released historical Chief turns as +workspace owners. Their old records remain intact; unknown current exits still block. + +### Durable Task inputs — S3 / issue52, 2026-11-02 + +The service now distinguishes persisted input, durable role inbox, adapter delivery, +SDK output observation, declared reading and validated handling. Current-claim +receipts and bounded same-thread safe turns preserve existing Bot handoff authority. +Chief receipt plans cannot consume inputs on failed decisions; closure and input +handling share the final state commit. New pending feedback, unknown protocols, +revoked source and changed hashes keep their fences. See [input contract](docs/subsystems/project-inputs.md). + +S1/PR56 and #57/PR59 are deployed with original UI preserved; S2/PR58 passed4350 +checks and fork CI. S3 source/real Codex receipt validation is in progress, not +live delivery or final UI acceptance. The original frontend Task40/PR48 continues +through its own Chief/Dev/Reviewer/Tester flow. No backend development Task, +control-plane edit, merge/queue/release or new per-Bot sandbox is authorized here. + +### Verified supervisor exit compatibility — 2026-11-02 + +Foreground repair #57 unifies workspace and logical Task-role resumption for +host-confirmed SDK exit and prior systemd invocation exit. The latter must match +the exact released worker/lease, generation, role, resource and process scope. +Unknown exits remain blocked; no state or lock deletion is a recovery mechanism. +Task40/PR48 retains its current Attempt and all draft/history records. Verification +and safe-checkpoint deployment precede Chief resumption; no frontend code is edited. + +### Direct backend recovery — 2026-11-01 + +The owner requires backend repairs in the foreground Codex session; do not dispatch +backend development through FlyBot. Preserve the existing autonomous frontend work +while implementing backend changes independently. The [owner decision](decisions/2026-11-01-direct-backend-recovery.md) +still governs; completed R6–R8 implementation/probe evidence is archived in +[execution history](docs/history/2026-11-03-execution-evidence.md#direct-backend-recovery--2026-11-01). + +### Automatic development service — 2026-11-01 + +Owner approved [#49–#55](https://github.com/zy95-12/FlyBot/issues/49), sequential +backend issue/PR delivery alongside existing frontend Task40/PR48. S1 ([PR56](https://github.com/zy95-12/FlyBot/pull/56)) implements +one versioned service facade, legacy adapters and a shared derived TaskView; +[contract](docs/subsystems/project-development-service.md), +[decision](decisions/2026-11-01-development-service.md). Durable Effects, input +receipts, external subscriptions, trusted deployment boundaries and recovery +acceptance remain S2–S6. Source checks and staged live rollout are separate; +healthy frontend work and native schedules must not be interrupted for deployment. +The initial673c741b service facade was deployed at a proven idle boundary, with +51 mixed-source UID998 checks and unchanged native UI assets. Live verification +found the old25-Attempt detail response exceeded8MiB. Follow-up b9cceb02 preserves +full history with paged summaries/hash-bound context retrieval: full UID1000 +Node24 verify4333 passed,0fail/skip. It was deployed at a tested, released Chief-wait checkpoint. Owner/Maintainer/Member +actual HTTP probes passed, including full-context paging and denied foreign/mutating +requests; the original detail response is now about1.45MiB. Native UI bytes remain +unchanged; frontend integration and final UI acceptance stay with Task40/PR48. + +### Durable service actions — S2/#51 + +S2 stages handoff dispatch and GitHub PR/comment actions with existing state, +requires an actual rename acknowledgement before I/O, and retains bounded retry/ +reconciliation receipts across restart. Current principals, claim generations, +worker exit and candidate-specific tests/review remain authoritative. Private +comment bodies are immutable artifact references, not public context or tokens. +[Effect architecture](docs/subsystems/project-effects.md). Source implementation +and fault-injection checks are in progress; no S2 live deployment, real GitHub +outbox acceptance or independent repository review is claimed by those checks. + + + +### Task40 S1 compatibility integration — 2026-11-02 + +The preserved `0e72dc60` frontend is integrated with trusted operator backend +`b920cae8` (including S1/34918fb3 and the shared verified-exit-proof repair). +Legacy delivery/Chief/schedule/delegation requests enter the S1 facade; native +personal/room/attachment routes and retained-root behavior remain intact. The +compact board now supplies live native activity to the shared status mapper: +stored Bot existence alone still cannot establish running execution. The new +integration regression failed before that adaptation; focused integrated suites +pass 83/83. Neither parent's prior validation is an integration PASS. Host full +verification, independent Review and actual deployed three-account/weak-network/ +screenshot acceptance remain required. No deployment, push or PR replacement occurs. + + +Issue60 live follow-up: a completed arbitration deferral now leaves its own Chief +claim waiting-human instead of active. Actual released SDK/lease proof remains +required before source admission. This fixes the observed current-Attempt operator +checkpoint without changing Task history, source trust or workspace ownership. + + +S6 dependency preparation uses the complete deployed release graph, including native +React/UI packages, rather than mixing the old private native cache. The versioned +`dev/demo/prepare-worktree.mjs` adapter refuses unknown existing entries. For legacy +managed workspaces, first prove SDK/leases released and preserve Git HEAD/index/status; +back up their untracked dependency directory outside the worktree, then prepare as +the service UID. Preserve the original Task/PR and source-admit the current Attempt +through the Owner API. This operator migration does not edit task state or Git code. + + +S6 live recovery also fences SDK-owned Chief delegations before asynchronous worker +registration. Dormant native DSH turn/end events must not consume the scheduled SDK +job during that setup window; only its fenced SDK callback may finish it. The +regression injects that event before a worker ID exists, then requires exactly one +completion from the actual bound callback. Native-only execution retains its events. + + +A late result-handler failure cannot overwrite a confirmed released SDK lifecycle. +Historical failed-status rows with a successful process exit may still prove exit +only when worker ID/generation, Project/Attempt/role/resource and both worker/lease +stop proofs match a released SDK lease exactly. Unknown/unreachable exits, missing +proofs and mismatched leases remain refused. This preserves historical failure +attribution without blocking every future integration admission for the same Task. + + +Chief route with rework=false resumes an interrupted allocated pre-candidate +Attempt after all role workers/leases/native occupancy are confirmed released, +whether its worktree is clean or dirty. A clean operator-restart checkpoint must +not manufacture a successor and discard its Attempt-scoped integration admission. +Explicit returns/rework and unknown worker occupancy retain existing isolation. + + +Delivery branch documentation now includes a source-matched API reference for all +12 v1 queries/21 commands and selected native authentication/Project/private-agent +entrypoints, plus a workflow/private safe-watch/state guide. It distinguishes +TaskView from stored lifecycle, actual records from fixture validation and ordinary +questions from impactful Tasks. This does not change runtime, enable watch or add UI. diff --git a/docs/subsystems/project-access.md b/docs/subsystems/project-access.md index 03e073c6..ee93950d 100644 --- a/docs/subsystems/project-access.md +++ b/docs/subsystems/project-access.md @@ -253,4 +253,12 @@ Independent personal-agent endpoints enforce their own current-user ownership an Agent connection authority intersects current human Project role, explicit credential scopes, active participation and the existing safe/watch mandate. Scope filtering applies to Project listings and reads, not only commands; body identity and default routing do not grant rights. +Consultation/handoff query endpoints use the shared channel/gateway allowlist and +current Project checks. Scoped personal-agent tokens can read handoff contracts and +their own participation's consultation mailbox. Safe consultation ask/reply does not +grant Task publication: replies remain private unless a human approves or existing +watch delegation and current permissions authorize explicit publishing. Agent id in a +request cannot override the authenticated connection identity. A Task-role token is +never a public HTTP actor. Legacy Chief question/consultation records remain private. + #2171 exposes project-roles/project-role-proposals as current-member reads and project-role-decision as a configure operation. The domain service independently checks exact proposal hash, current Maintainer/Owner authority and Chief self-approval restrictions after gateway admission. diff --git a/docs/subsystems/project-agents.md b/docs/subsystems/project-agents.md index 94df2920..8d4d2544 100644 --- a/docs/subsystems/project-agents.md +++ b/docs/subsystems/project-agents.md @@ -1,6 +1,8 @@ --- subsystem: project-agents owned_paths: + - core/src/host/project-consultations.js + - core/src/host/project-role-governance.js - core/test/project-role-governance.test.js - core/src/host/project-agents.js @@ -371,6 +373,50 @@ Independent personal identities (#2170) now own per-Project participations/carri Private-agent persona v5 describes an independent identity/isolated participation and adds project_agent_suggest_closure. Safe private agents can recommend closure without a Task; host-derived participation is required, and Owner confirmation stays personal. Approved external private proposals retain persisted origin/approval provenance. +## Consultation protocol — #2175 + +Ordinary questions are `project-consultation/v1` views over the original private +`conversation-handoff/v1` transcript records. `project-chief-question` remains a +compatible ask alias, including its old `handoff` response field. It now also +returns `consultation`; historical records remain readable without becoming Task +handoffs. A consultation never transfers Task initiative or creates a Task. + +| Development operation | Native endpoint | Authority/result | +| --- | --- | --- | +| `consultation.ask` | `project-consultation-ask` | Current member/own scoped agent asks Chief | +| `consultation.get` | `project-consultation-get` | Own selected participation, bounded reply page | +| `consultation.list` | `project-consultation-list` | Own selected participation, receipt cursor | +| `consultation.reply` | `project-consultation-reply` | Reply to an incoming Chief question only | + +Use `agentId` to select an owned participation; scoped connection credentials bind +that selection to their own agent. A missing mailbox read does not allocate a Bot. +External agents have transcript mailboxes, without pretending to log into a provider +or silently starting a native personal agent. Outgoing answers are accepted only +from the existing delegated Chief executor; callers cannot impersonate Chief through +the reply endpoint. Full private notes/other-agent transcripts are never forwarded. + +Chief `needs-human` first addresses the initiating user's active default personal +agent, or the Task requester when no initiation binding exists. The durable incoming +question retains Task/decision/Chief-job ids in that participation's existing private +transcript. No valid agent means explicit human fallback. `requiresHuman` adds a +personal-only response boundary; Project closure still independently requires Owner +confirmation. A changed default does not transfer the old question, grant or context. + +Safe replies remain private suggestions; an explicit current user publish is needed +to send their text to the Project. Watch replies intersect current user/participation +rights and the existing validated delegation. Published answers use the original +Task inbox/Effects admission, retain approval/proxy provenance in the private receipt, +and wake Chief through that inbox. Reply publication does not close/requeue a Task +itself; Chief decides the next action. Exact requestId/text replay reuses the answer +and Task-input record; altered text is refused. Stale decision replies are refused. + +The existing Effects dispatcher sends notifications only after the question snapshot +commits. An already authorized native watch can be woken through `wakeWatch`; safe +and external agents consume their own mailbox without an invented provider transport. +`project_agent_consultations` is the native personal-agent tool for get/list/reply. +An Effects attempt or SDK send is not a read/handling receipt. External responses +still need a connected client; no official grok/dots integration is claimed. + ## Project role governance (#2171) The active catalog starts with Chief, Developer, Tester and Reviewer. Its logical role diff --git a/docs/subsystems/project-chief-control.md b/docs/subsystems/project-chief-control.md index 55b008ad..d4bf4f81 100644 --- a/docs/subsystems/project-chief-control.md +++ b/docs/subsystems/project-chief-control.md @@ -148,6 +148,14 @@ current claims still block through their existing checks. Task/Attempt/generatio audit and workspace remain intact; no ownership is granted by the status change. +Needs-human decisions now retain a private consultation recipient: prefer the +initiator's active default personal agent, fall back to the current human, and +retain any explicit personal-only answer restriction. Notification is staged in the +existing Effects service with the decision; native watch authority is revalidated at +execution. Safe suggestions require explicit user publication, while watch answers +use ordinary Task input admission. Chief retains Task initiative and decides whether +the answer resolves the block. See [consultation protocol](project-agents.md). + Validated Chief completion accepts up to four role proposals alongside Task decisions. The host supplies stable run-scoped proposal keys and current Chief-job authority; proposals cannot apply role changes. A malformed proposal list fails the job rather than granting configuration rights. diff --git a/docs/subsystems/project-development-service.md b/docs/subsystems/project-development-service.md index ad59534c..d112106c 100644 --- a/docs/subsystems/project-development-service.md +++ b/docs/subsystems/project-development-service.md @@ -172,3 +172,12 @@ and [workflow/private-agent/state guide](../development-service-guide.md). Their operation index follows this module's 12 queries and21 commands; native creation and personal-agent endpoints are labeled separately. Documentation adds no role result/claim HTTP route, new state writer or additional authority. + + +#2175 adds query operations `handoff.get`, `handoff.list`, `consultation.get`, +`consultation.list` and commands `consultation.ask`, `consultation.reply` to the same +v1 envelope. Their native aliases call identical services and keep current Project, +Task and owned-participation checks. Safe external agents may ask questions or save +private advice; publishing an answer requires human approval or current watch rights. +Role `handoff.offer/accept` are trusted host ports, never public commands. See +[pool contracts](project-pools.md) and [consultations](project-agents.md). diff --git a/docs/subsystems/project-pools.md b/docs/subsystems/project-pools.md index 6c2dd864..6488d339 100644 --- a/docs/subsystems/project-pools.md +++ b/docs/subsystems/project-pools.md @@ -123,4 +123,39 @@ Each offered handoff saves its exact pool.dispatch intent in the same state snap S3 stores a handoff input with each pool.dispatch offer, rolls it back only with an uncommitted offer and migrates old pending offers without changing ids. Current-claim injected-batch acknowledgements follow successful completed-role gates. Routing receipts resolve handoff transport without manufacturing a model read; remaining feedback follows current responsibility. Closure checks unhandled inputs. See [inputs](project-inputs.md). +## Durable handoff contract projection — #2175 + +`project-handoff-contract/v1` is a scoped view over Task.collaboration.history, +not another queue. `project-handoff-get {taskId,handoffId}` and +`project-handoff-list {taskId,after?,limit?}` require current Project read access; +list limits are1–100 with receipt-id cursors. Development v1 query names are +`handoff.get` and `handoff.list`. Future durable event streaming remains #2185; +the per-receipt events returned here describe existing persisted timestamps. + +States distinguish pending, accepted, processed, revoked and invalidated. Processing +one handoff never means Task completion. Initiative is held by the target pool before +acceptance, then by its active claim; interrupted acceptance is fenced for recovery. +Failure phase/reason is separate from acceptance. Source/target role contexts are +server-resolved references, with immutable original references and actual receiving +context/worker recorded at binding. Private transcripts and caller-supplied Session +references are not copied. Summaries hold bounded completed/next requirements; +trusted evidence is separate from model claims. Predecessor/successor ids retain +normal handoff, return, transfer and Chief intervention chains. + +Trusted `roleCommand` adds `handoff.accept` (taskId,botId,handoffId,requestId) and +`handoff.offer` (taskId,claim,requestId,payload.action/reason/to/outputs). These map to +the same original claim/decision services. They are host execution ports; public HTTP +cannot submit role tokens or invent a receiving identity. Accept response replay +returns its original claim or historical outcome and cannot revive completed work. +Old `claim`/`handoff`/`return`/`transfer`/`complete`/`arbitrate` ports remain compatible. + +Confirmed source exit precedes relinquishing an execution claim. Even after Chief +observes a failed writer, a downstream non-Chief claim rechecks unresolved historical +exits; selecting another resource cannot bypass them. Chief may still observe and +arbitrate a blocked Task. Unknown occupancy remains visible and pending; a timeout +is not release evidence. Native exit/restoration and transaction-wide recovery still +follow the execution/storage acceptance work, not fabricated from these view states. + #2184 records logical role and pinned capability class on a claim. Active targets alone can receive new offers/claims; a removed queued target becomes blocked with a Chief-routing reason while its Task/initiative/history remain intact. An already acquired source may finish its retained frozen claim after retirement, then explicitly hand off to an active target. Developer-capability destinations use existing rework semantics even when named differently. Artifact responsibilities stay in the existing Attempt's roleAssignments; no new queue or scheduler is added. + +Composition with #2184 rechecks the committed target role after asynchronous source-exit confirmation, before claiming. Retirement during inspection leaves the original Task blocked for Chief routing; it cannot acquire a new execution claim from the earlier catalog snapshot. diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 50adaefb..725e44fd 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -511,7 +511,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js // Updates change the dsh profile, not the team, so a reading host may run them too. - const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer']) + // auth-whoami is a read of the already-resolved actor; invite/redeem/revoke/remove-member write. -+ const READS = new Set(['project-roles', 'project-role-proposals', 'personal-agent-token-list', 'personal-agent-history', 'personal-agent-read', 'personal-agent-list', 'personal-agent-participations', 'project-repositories', 'project-rooms', 'project-room-read', 'project-task-pools', 'project-chief-state', 'project-github-read', 'auth-mode', 'workbench-state', 'project-resources', 'projects', 'project-read', 'project-board', 'project-members', 'project-task-detail', 'project-attempt-log', 'project-detail', 'state', 'models', 'efforts', 'earlier', 'room-progress', 'room-history', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami', 'workflows']) ++ const READS = new Set(['project-roles', 'project-role-proposals', 'personal-agent-token-list', 'personal-agent-history', 'personal-agent-read', 'personal-agent-list', 'personal-agent-participations', 'project-repositories', 'project-rooms', 'project-room-read', 'project-handoff-get', 'project-handoff-list', 'project-consultation-get', 'project-consultation-list', 'project-task-pools', 'project-chief-state', 'project-github-read', 'auth-mode', 'workbench-state', 'project-resources', 'projects', 'project-read', 'project-board', 'project-members', 'project-task-detail', 'project-attempt-log', 'project-detail', 'state', 'models', 'efforts', 'earlier', 'room-progress', 'room-history', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami', 'workflows']) + + // A chat reference chip names a Session whose snapshot rides to every recipient of + // the message, so the author must be able to read what they share (#53, #106). @@ -993,7 +993,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js content: [{ type: 'text', text }], }, AbortSignal.timeout(30_000)) return ok(true) -@@ -319,32 +319,215 @@ export function install(rt) { +@@ -319,32 +319,221 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) @@ -1048,6 +1048,12 @@ diff --git a/src/host/channel.js b/src/host/channel.js + case 'project-close-confirm': + case 'project-development': + case 'project-task-pools': ++ case 'project-consultation-reply': ++ case 'project-consultation-ask': ++ case 'project-consultation-get': ++ case 'project-consultation-list': ++ case 'project-handoff-get': ++ case 'project-handoff-list': + case 'project-chief-question': + case 'project-github-read': + case 'project-agent-mode': diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index 6eeb9b8c..b4baf3c4 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -241,7 +241,7 @@ export function install(rt) { const ep = String(endpoint ?? '') if (ep === 'platform-login') return rt.platformIdentityEnabled ? ALLOW : deny('bot/forbidden', 'Platform identity is not configured') - if (['project-development', 'project-rooms', 'project-room-create', 'project-room-read', 'project-room-message', 'project-room-attachment', 'project-room-stop', 'project-agent-stop', 'workbench-state', 'project-resources', 'project-resource-environment-check', 'project-execution-input', 'project-runtime-check', 'project-terminal-open', 'project-terminal-poll', 'project-terminal-input', 'project-terminal-resize', 'project-terminal-close', 'project-queue-cancel', 'project-queue-retry', 'project-task-pools', 'project-github-read', 'project-chief-question', 'project-agent-mode', 'project-scheduled-task', 'project-chief-configure', 'project-chief-state', 'project-chief-inspect', 'project-attempt-stop', 'project-credential-status', 'project-github-comment', 'project-agents', 'project-agent-message', 'project-agent-history', 'project-agent-memory-write', 'project-role-memory-write', 'project-agent-definition', 'project-roles', 'project-role-proposals', 'project-role-decision', 'project-agent-proposal-apply', 'project-knowledge-write', 'project-task-run', 'project-task-detail', 'project-task-accept', 'project-task-reject', 'project-attempt-log', 'project-attempt-cleanup', 'project-attempt-publication-retry', 'project-members', 'project-invite', 'project-join', 'project-leave', 'project-member-remove', 'project-quota', 'project-workflow'].includes(ep) && !rt.sharedProjectMode?.()) return deny('bot/forbidden', 'Project workbench is not enabled') + if (['project-development', 'project-rooms', 'project-room-create', 'project-room-read', 'project-room-message', 'project-room-attachment', 'project-room-stop', 'project-agent-stop', 'workbench-state', 'project-resources', 'project-resource-environment-check', 'project-execution-input', 'project-runtime-check', 'project-terminal-open', 'project-terminal-poll', 'project-terminal-input', 'project-terminal-resize', 'project-terminal-close', 'project-queue-cancel', 'project-queue-retry', 'project-task-pools', 'project-github-read', 'project-consultation-reply', 'project-consultation-ask', 'project-consultation-get', 'project-consultation-list', 'project-handoff-get', 'project-handoff-list', 'project-chief-question', 'project-agent-mode', 'project-scheduled-task', 'project-chief-configure', 'project-chief-state', 'project-chief-inspect', 'project-attempt-stop', 'project-credential-status', 'project-github-comment', 'project-agents', 'project-agent-message', 'project-agent-history', 'project-agent-memory-write', 'project-role-memory-write', 'project-agent-definition', 'project-roles', 'project-role-proposals', 'project-role-decision', 'project-agent-proposal-apply', 'project-knowledge-write', 'project-task-run', 'project-task-detail', 'project-task-accept', 'project-task-reject', 'project-attempt-log', 'project-attempt-cleanup', 'project-attempt-publication-retry', 'project-members', 'project-invite', 'project-join', 'project-leave', 'project-member-remove', 'project-quota', 'project-workflow'].includes(ep) && !rt.sharedProjectMode?.()) return deny('bot/forbidden', 'Project workbench is not enabled') if (rt.platformIdentityEnabled && ['auth-invite', 'auth-redeem', 'auth-remove-member'].includes(ep)) return deny('bot/platform-auth', 'Use control-plane account authentication') if (!actor) { if (NO_ACTOR_ALLOWED.has(ep)) return ALLOW @@ -253,7 +253,7 @@ export function install(rt) { if (rt.sharedProjectMode?.()) { const agentSeats = [...(Array.isArray(body?.members) ? body.members : []), ...(Array.isArray(body?.add) ? body.add : []), ...(Array.isArray(body?.participants) ? body.participants.filter(row => row?.kind === 'bot').map(row => row.id) : [])] if (['create-room', 'update-room'].includes(ep) && agentSeats.some(id => state.bots?.[id]?.projectAgent)) return deny('bot/forbidden', 'Project agents cannot join group chats') - if (['project-development', 'project-rooms', 'project-room-create', 'project-room-read', 'project-room-message', 'project-room-attachment', 'project-room-stop', 'project-agent-stop', 'workbench-state', 'project-resources', 'project-resource-environment-check', 'project-execution-input', 'project-runtime-check', 'project-terminal-open', 'project-terminal-poll', 'project-terminal-input', 'project-terminal-resize', 'project-terminal-close', 'project-queue-cancel', 'project-queue-retry', 'project-task-pools', 'project-github-read', 'project-chief-question', 'project-agent-mode', 'project-scheduled-task', 'project-chief-configure', 'project-chief-state', 'project-chief-inspect', 'project-attempt-stop', 'project-credential-status', 'project-github-comment', 'project-agents', 'project-agent-message', 'project-agent-history', 'project-agent-memory-write', 'project-role-memory-write', 'project-agent-definition', 'project-roles', 'project-role-proposals', 'project-role-decision', 'project-agent-proposal-apply', 'project-knowledge-write', 'project-task-run', 'project-task-detail', 'project-task-accept', 'project-task-reject', 'project-attempt-log', 'project-attempt-cleanup', 'project-attempt-publication-retry', 'project-members', 'project-invite', 'project-join', 'project-leave', 'project-member-remove', 'project-quota', 'project-workflow'].includes(ep)) return ALLOW + if (['project-development', 'project-rooms', 'project-room-create', 'project-room-read', 'project-room-message', 'project-room-attachment', 'project-room-stop', 'project-agent-stop', 'workbench-state', 'project-resources', 'project-resource-environment-check', 'project-execution-input', 'project-runtime-check', 'project-terminal-open', 'project-terminal-poll', 'project-terminal-input', 'project-terminal-resize', 'project-terminal-close', 'project-queue-cancel', 'project-queue-retry', 'project-task-pools', 'project-github-read', 'project-consultation-reply', 'project-consultation-ask', 'project-consultation-get', 'project-consultation-list', 'project-handoff-get', 'project-handoff-list', 'project-chief-question', 'project-agent-mode', 'project-scheduled-task', 'project-chief-configure', 'project-chief-state', 'project-chief-inspect', 'project-attempt-stop', 'project-credential-status', 'project-github-comment', 'project-agents', 'project-agent-message', 'project-agent-history', 'project-agent-memory-write', 'project-role-memory-write', 'project-agent-definition', 'project-roles', 'project-role-proposals', 'project-role-decision', 'project-agent-proposal-apply', 'project-knowledge-write', 'project-task-run', 'project-task-detail', 'project-task-accept', 'project-task-reject', 'project-attempt-log', 'project-attempt-cleanup', 'project-attempt-publication-retry', 'project-members', 'project-invite', 'project-join', 'project-leave', 'project-member-remove', 'project-quota', 'project-workflow'].includes(ep)) return ALLOW if (ep.startsWith('flybot/') && !['flybot/bots', 'flybot/changes', 'flybot/decide'].includes(ep)) return deny('bot/project-required', 'Use a Project-scoped endpoint') if (['workflows', 'workflow-save', 'usage'].includes(ep)) return deny('bot/project-required', 'Use a Project-scoped endpoint') } diff --git a/patches/ds-bot/files/src/host/personal-agent-auth.js b/patches/ds-bot/files/src/host/personal-agent-auth.js index 07959ce5..5e036246 100644 --- a/patches/ds-bot/files/src/host/personal-agent-auth.js +++ b/patches/ds-bot/files/src/host/personal-agent-auth.js @@ -7,7 +7,7 @@ import { WORKBENCH_ENDPOINTS } from './workbench-gateway.js' const hash = value => createHash('sha256').update(value).digest('hex') const refusal = (message, code = 'bot/forbidden') => Object.assign(new Error(message), { code }) const endpoints = new Set(['personal-agent-token-create', 'personal-agent-token-list', 'personal-agent-token-revoke', 'personal-agent-delegation-set', 'personal-agent-propose-task']) -const reads = new Set(['projects', 'project-read', 'project-detail', 'project-board', 'project-members', 'project-repositories', 'project-task-detail', 'project-attempt-log', 'project-task-pools', 'project-chief-state']) +const reads = new Set(['project-handoff-get', 'project-handoff-list', 'project-consultation-get', 'project-consultation-list', 'projects', 'project-read', 'project-detail', 'project-board', 'project-members', 'project-repositories', 'project-task-detail', 'project-attempt-log', 'project-task-pools', 'project-chief-state']) const reserved = new Set(['project-agent-mode', 'project-agent-proposal-apply', 'project-agent-message', 'project-agent-history', 'project-agent-memory-write', 'project-agent-stop', 'project-delete', 'project-archive', 'project-close-prepare', 'project-close-confirm']) const publicCredential = row => ({ version: row.version, id: row.id, agentId: row.agentId, projectIds: [...row.projectIds], createdAt: row.createdAt, expiresAt: row.expiresAt, status: row.status }) const bounded = (value, name, max) => { @@ -66,12 +66,12 @@ export function install(rt) { try { current(actor) if (['auth-whoami', 'auth-revoke', 'projects'].includes(endpoint)) return nativeAuthorize(actor, endpoint, body) - if (endpoint === 'personal-agent-propose-task' || endpoint === 'project-close-request') scope(actor, body.projectId) + if (['personal-agent-propose-task', 'project-close-request', 'project-consultation-ask', 'project-consultation-reply', 'project-chief-question'].includes(endpoint)) scope(actor, body.projectId) else if (reads.has(endpoint)) { const projectId = body.projectId ?? body.id ?? ownEntry(state.tasks, body.taskId)?.projectId ?? ownEntry(state.projectAttempts ?? {}, body.attemptId)?.projectId scope(actor, projectId) } else if (endpoint === 'project-agents' && body.view === 'summary') scope(actor, body.projectId) - else if (endpoint === 'project-development' && rt.projectDevelopment.isQuery(body)) scope(actor, body.projectId) + else if (endpoint === 'project-development' && (rt.projectDevelopment.isQuery(body) || body.kind === 'command' && ['consultation.ask', 'consultation.reply'].includes(body.name))) scope(actor, body.projectId) else { const projectId = body.projectId ?? ownEntry(state.tasks, body.id ?? body.taskId)?.projectId ?? ownEntry(state.projectAttempts ?? {}, body.attemptId)?.projectId ?? ownEntry(state.projectQueue ?? {}, body.queueId)?.projectId ?? body.id const { grant } = scope(actor, projectId) diff --git a/patches/ds-bot/files/src/host/project-access.js b/patches/ds-bot/files/src/host/project-access.js index ce417a32..1ec3c838 100644 --- a/patches/ds-bot/files/src/host/project-access.js +++ b/patches/ds-bot/files/src/host/project-access.js @@ -116,6 +116,9 @@ export function install(rt) { if (endpoint === 'project-rooms' || endpoint.startsWith('project-room-') || endpoint === 'project-agent-stop') return rt.projectAgents.endpoint(endpoint, body, actor) if (endpoint === 'project-github-read') return rt.projectGithubRead(body, actor) if (endpoint === 'project-chief-question') return rt.projectAgents.chiefQuestion(body, actor) + if (['project-consultation-reply', 'project-consultation-ask', 'project-consultation-get', 'project-consultation-list'].includes(endpoint)) return rt.projectAgents.consultation(endpoint, body, actor) + if (endpoint === 'project-handoff-get') return { handoff: rt.projectPools.get(body.taskId, body.handoffId, actor) } + if (endpoint === 'project-handoff-list') return rt.projectPools.list(body.taskId, actor, body) if (endpoint === 'project-credential-status') return rt.projectCredentials.endpoint(endpoint, body, actor) if (['project-agents', 'project-agent-message', 'project-agent-history', 'project-agent-memory-write', 'project-role-memory-write', 'project-agent-definition', 'project-roles', 'project-role-proposals', 'project-role-decision', 'project-knowledge-write'].includes(endpoint)) return rt.projectAgents.endpoint(endpoint, body, actor) if (['project-runtime-check', 'project-terminal-open', 'project-terminal-poll', 'project-terminal-input', 'project-terminal-resize', 'project-terminal-close'].includes(endpoint)) return rt.projectRuntimeSetup.endpoint(endpoint, body, actor) diff --git a/patches/ds-bot/files/src/host/project-agents.js b/patches/ds-bot/files/src/host/project-agents.js index fc46ae03..4ff5a046 100644 --- a/patches/ds-bot/files/src/host/project-agents.js +++ b/patches/ds-bot/files/src/host/project-agents.js @@ -6,6 +6,8 @@ import { output } from './tools.js' import { textOf } from './text.js' import { resolveAgentSkills } from './project-agent-skills.js' import { roleContext, readContextArchive, contextHash, KNOWLEDGE_CHECKPOINT_VERSION } from './project-role-context.js' +import { EffectCancelled, EffectDeferred } from './project-effects.js' +import { createProjectConsultations } from './project-consultations.js' import { createProjectRooms } from './project-rooms.js' import { deliveryGraph } from './project-delivery-template.js' import { createProjectRoleGovernance } from './project-role-governance.js' @@ -79,13 +81,14 @@ export function createProjectAgents(rt) { const rooms = createProjectRooms(rt, { roles: projectId => definitionSet(projectId).roles.map(row => row.role), send: (body, actor) => serial(() => send(body, actor)), stop: stopAgent }) let tail = Promise.resolve() const serial = fn => { const result = tail.then(fn, fn); tail = result.then(() => {}, () => {}); return result } + const consultations = createProjectConsultations(rt, { ensure, chiefQuestion, serial }) const keyOf = (projectId, memberId) => rt.personalAgents.carrierId(projectId, memberId) const logOf = id => { let rows = ownEntry(transcripts(), id) if (!rows) { rows = []; setEntry(transcripts(), id, rows) } return rows } - const recordForBot = id => Object.values(state.projectAgents ?? {}).find(agent => agent.botId === id) + const recordForBot = id => id ? Object.values(state.projectAgents ?? {}).find(agent => agent.botId === id) : undefined const actorOf = (bot, { admitting = false } = {}) => { const agent = recordForBot(bot?.id) if (!agent || agent.status === 'retired') throw refuse('This Session has no Project agent authority') @@ -393,6 +396,7 @@ export function createProjectAgents(rt) { if (agent.returnAgentId) { const parent = ownEntry(agents(), agent.returnAgentId) if (parent?.memberId !== agent.memberId || parent.projectId !== agent.projectId) throw refuse('Conversation return scope changed') + rt.personalAgents.requireParticipation(parent) logOf(parent.id).push({ ...entry, kind: 'agent', speaker: 'Chief', conversationHandoffId: agent.conversationHandoffId }) } } @@ -404,11 +408,11 @@ export function createProjectAgents(rt) { if (rt.botOf(agent.botId)) await rt.deleteBot(agent.botId) } async function chiefQuestion(body, actor) { return serial(async () => { - const parent = await ensure(body.projectId, actor) + const parent = await consultations.parent(body, actor, true) const text = clean(body.text), requestId = clean(body.requestId, 100) if (!text || !/^[A-Za-z0-9_-]{1,100}$/.test(requestId)) throw refuse('A Chief question needs text and a stable requestId') const prior = logOf(parent.id).find(row => row.kind === 'handoff' && row.requestId === requestId) - if (prior) { if (prior.text !== text) throw refuse('Conversation requestId was reused with different text'); return { handoff: structuredClone(prior), already: true } } + if (prior) { if (prior.text !== text) throw refuse('Conversation requestId was reused with different text'); return { handoff: structuredClone(prior), consultation: consultations.view(parent, prior), already: true } } const definitions = definitionSet(parent.projectId), definition = structuredClone(definitions.roles.find(row => row.role === 'chief')) const detail = await rt.projectEndpoint('project-detail', { id: parent.projectId }, actor) const snapshot = roleContext({ retrieval: true, projectId: parent.projectId, role: 'chief', revision: definitions.revision, definition: { ...definition, resolvedSkills: resolveAgentSkills(rt, definition.skills) }, memory: memoryFor(parent.projectId, 'chief', definition.memoryRefs), knowledge: ownEntry(state.projectKnowledge ?? {}, parent.projectId) ?? [], projectFacts: { chiefMemory: chiefMemory(parent.projectId), tasks: detail.tasks.slice(-20).map(({ id, title, status, criteria }) => ({ id, title, status, criteria: clean(criteria, 1000) })), runs: detail.runs.slice(-10).map(({ id, taskId, status }) => ({ id, taskId, status })) }, repository: await rt.projectDelivery.repositoryContext(parent.projectId) }) @@ -438,13 +442,20 @@ export function createProjectAgents(rt) { await rt.deliver({ toId: bot.id, sessionId: agent.sessionId, role: 'request', audience: 'user', text, fromName: actor.displayName ?? actor.memberId, untrusted: true, exchangeId: delegation.exchangeId, projectDelegation: { agentId: id, delegationId: delegation.id } }) } } catch (error) { delegations.delete(id); await finishConversation(agent, 'failed'); await rt.save(); throw error } - return { handoff: structuredClone(receipt), queued: true } + return { handoff: structuredClone(receipt), consultation: consultations.view(parent, receipt), queued: true } }) } async function suggestClosure(bot, body) { const { agent, actor, delegation } = await authorize(bot) if (agent.visibility !== 'private' || delegation.readOnly || delegation.chiefJob) throw refuse('Only an active personal agent may recommend closure') return rt.projectEndpoint('project-close-request', { projectId: agent.projectId, reason: body.reason }, { ...actor, personalAgentSuggestionId: agent.id }) } + async function agentConsultations(bot, body) { + const { agent, actor } = await authorize(bot) + if (agent.visibility !== 'private' || !agent.personalAgentId || agent.returnAgentId) throw refuse('Only a personal agent may use its consultation mailbox') + const operation = body.operation ?? 'list' + if (!['get', 'list', 'reply'].includes(operation)) throw refuse('Unsupported consultation operation') + return consultations.endpoint(`project-consultation-${operation}`, { ...body, projectId: agent.projectId, agentId: agent.personalAgentId }, { ...actor, consultationProxyAgentId: agent.personalAgentId }) + } async function askChief(bot, body) { const { agent, actor, delegation } = await authorize(bot); if (delegation.readOnly) throw refuse('Chief must answer this conversation instead of recursively forwarding it'); return chiefQuestion({ ...body, projectId: agent.projectId }, actor) } async function githubRead(bot, body) { const { agent, actor } = await authorize(bot); return rt.projectGithubRead({ ...body, projectId: agent.projectId }, actor) } async function chiefReply(agent, delegation, result) { @@ -575,10 +586,10 @@ export function createProjectAgents(rt) { audit.status = 'completed'; audit.finishedAt = Date.now(); await rt.save(); return result } catch (error) { audit.status = 'failed'; audit.finishedAt = Date.now(); await rt.save(); throw error } } - async function wakeWatch(projectId, memberId, text) { + async function wakeWatch(projectId, memberId, text, expectedCarrierId) { const actor = await rt.projectDelegations.actorFor(keyOf(projectId, memberId)) if (!actor) throw refuse('No persistent watch delegation is active') - return serial(() => send({ projectId, visibility: 'private', text }, actor)) + return serial(() => { if (expectedCarrierId && keyOf(projectId, memberId) !== expectedCarrierId) throw refuse('Personal-agent default changed before notification'); return send({ projectId, visibility: 'private', text }, actor) }) } async function stopAgent(body, actor) { const agent = body.roomId ? Object.values(agents()).find(row => row.roomId === body.roomId && row.role === body.role) : await ensure(body.projectId, actor, body.visibility === 'public' ? 'public' : 'private') @@ -730,12 +741,32 @@ export function createProjectAgents(rt) { } const isDelegationActive = (id, delegationId) => { const entry = delegations.get(id); return entry?.id === delegationId && !entry.revoked } const ensureProject = (projectId, actor) => serial(async () => { await ensure(projectId, actor, 'public'); return ensure(projectId, actor) }) - return { stream, liveFor, hasExchange: exchangeId => [...delegations.values()].some(row => row.exchangeId === exchangeId && !row.revoked), privateIdentity: keyOf, ensurePublic: (projectId, actor) => serial(() => ensure(projectId, actor, 'public')), contextExchange, chiefMemory, chiefBusy: projectId => delegations.has(`public-${projectId}`), startChiefJob, endpoint, chiefQuestion, askChief, suggestClosure, githubRead, action, wakeWatch, revokeWatchGrant, ensureProject, isDelegationActive, actorOf, context, memory, task, run, onEvent, revoke, revokeSession, removeProject, deliveryDefinitions, recordForBot, roles: roleGovernance, proposeRole, proposeRoleForJob, ROLES } + return { stream, liveFor, hasExchange: exchangeId => [...delegations.values()].some(row => row.exchangeId === exchangeId && !row.revoked), privateIdentity: keyOf, ensurePublic: (projectId, actor) => serial(() => ensure(projectId, actor, 'public')), contextExchange, chiefMemory, chiefBusy: projectId => delegations.has(`public-${projectId}`), startChiefJob, endpoint, consultation: consultations.endpoint, contactUser: consultations.contactUser, agentConsultations, chiefQuestion, askChief, suggestClosure, githubRead, action, wakeWatch, revokeWatchGrant, ensureProject, isDelegationActive, actorOf, context, memory, task, run, onEvent, revoke, revokeSession, removeProject, deliveryDefinitions, recordForBot, roles: roleGovernance, proposeRole, proposeRoleForJob, ROLES } } export function install(rt) { rt.projectAgents = createProjectAgents(rt) rt.projectRoleGovernance = rt.projectAgents.roles + rt.projectEffects?.register('consultation.notify', { + async run(effect) { + const carrier = ownEntry(rt.state.projectAgents ?? {}, effect.payload.carrierId) + const task = ownEntry(rt.state.tasks, effect.taskId) + try { + rt.personalAgents.requireParticipation(carrier) + rt.requireProjectAccess({ memberId: effect.payload.memberId }, effect.projectId) + } catch { throw new EffectCancelled('Personal-agent recipient is no longer authorized') } + if (!carrier || carrier.personalAgentId !== effect.payload.agentId || carrier.projectId !== effect.projectId || task?.chiefQuestion?.recipient?.consultationId !== effect.payload.consultationId) throw new EffectCancelled('Personal-agent question ended or changed') + const identity = rt.personalAgents.agentOf(effect.payload.agentId) + if (identity.kind === 'external' || !rt.projectDelegations.forAgent(carrier.id) || rt.personalAgents.carrierId(effect.projectId, carrier.memberId) !== carrier.id) return { consultationId: effect.payload.consultationId, status: 'private-mailbox' } + try { + const result = await rt.projectAgents.wakeWatch(effect.projectId, carrier.memberId, `Chief requests your advice. Read consultation ${effect.payload.consultationId} through project_agent_consultations. Reply only with explicit Task input in your current authority; Task closure remains Chief judgement.`, carrier.id) + return { consultationId: effect.payload.consultationId, status: 'watch-notified', agentId: result.agent?.id ?? carrier.id } + } catch (error) { + if (error.code === 'bot/busy') throw new EffectDeferred('Personal agent is busy', { waitingFor: 'agent' }) + throw error + } + }, + }) rt.ctx.on('llm/stream', (options, next) => options.purpose === undefined ? rt.projectAgents.stream(options.sessionId, next) : next()) rt.ctx.on('session/event', (session, event) => rt.projectAgents.onEvent(session, event)) rt.ctx.on('agent/pre-step', async (payload, next) => { @@ -758,6 +789,7 @@ export function install(rt) { ['project_agent_memory', { text: { type: 'string', required: true } }, 'Save a private user/project note; never publish it.', async (args, bot) => rt.projectAgents.memory(bot, args.text)], ['project_agent_task', { title: { type: 'string', required: true }, criteria: { type: 'string' }, note: { type: 'string' }, projectTaskKind: { type: 'string', description: 'code, decision or release; ordinary questions are conversation handoffs' } }, 'Propose a Task privately, or create from the current public request.', async (args, bot) => rt.projectAgents.task(bot, args)], ['project_agent_suggest_closure', { reason: { type: 'string', required: true } }, 'Recommend Project closure without creating a Task or granting closure rights. The Owner must personally double-confirm.', async (args, bot) => rt.projectAgents.suggestClosure(bot, args)], + ['project_agent_consultations', { operation: { type: 'string' }, consultationId: { type: 'string' }, requestId: { type: 'string' }, text: { type: 'string' }, publish: { type: 'boolean' } }, 'Read your own Chief questions or reply. Safe answers remain private until human approval; watch replies enter the existing Task inbox.', async (args, bot) => rt.projectAgents.agentConsultations(bot, args)], ['project_agent_chief', { text: { type: 'string', required: true }, requestId: { type: 'string', required: true } }, 'Ask Chief an ordinary question without creating a Task. The answer returns to this private conversation.', async (args, bot) => rt.projectAgents.askChief(bot, args)], ['project_agent_github', { operation: { type: 'string', required: true }, number: { type: 'number' } }, 'Read only the current Project repository issues, pulls, pull, reviews or checks through a scoped host credential.', async (args, bot) => rt.projectAgents.githubRead(bot, args)], ['project_agent_action', { endpoint: { type: 'string', required: true }, args: { type: 'object', properties: {}, additionalProperties: true, required: true } }, 'Use an existing scoped human operation with the current user permissions in watch mode. This grants no merge, release or extra repository authority.', async (args, bot) => rt.projectAgents.action(bot, args)], diff --git a/patches/ds-bot/files/src/host/project-chief-control.js b/patches/ds-bot/files/src/host/project-chief-control.js index 6e5d3faf..8ab99aa7 100644 --- a/patches/ds-bot/files/src/host/project-chief-control.js +++ b/patches/ds-bot/files/src/host/project-chief-control.js @@ -149,6 +149,7 @@ export function createProjectChiefControl(rt) { if (task.collaboration) { task.collaboration.status = 'blocked'; task.collaboration.block = { reason, chiefDecisionId: audit.id, at: Date.now() } } if (task.collaboration?.claim?.chiefRunId === run.id) task.collaboration.claim.status = 'waiting-human' task.chiefQuestion = { decisionId: audit.id, reason, at: Date.now() } + task.chiefQuestion.recipient = rt.projectAgents.contactUser?.(task, { id: audit.id, reason, requiresHuman: decision.requiresHuman === true }, actor) ?? { kind: 'human', memberId: task.collaboration?.initiatedBy ?? actor.memberId } } else if (decision.action === 'close') { if (!Array.isArray(decision.criteria) || !decision.criteria.length || decision.criteria.some(row => row?.passed !== true || typeof row.criterion !== 'string' || !row.criterion.trim() || typeof row.evidence !== 'string' || !row.evidence.trim())) throw refuse('Chief closure needs explicit acceptance-criteria evidence') await rt.projectDelivery.chiefAccept({ taskId: task.id, expectedSha: saved.candidateSha, decision: audit }, actor) diff --git a/patches/ds-bot/files/src/host/project-consultations.js b/patches/ds-bot/files/src/host/project-consultations.js new file mode 100644 index 00000000..532d8e74 --- /dev/null +++ b/patches/ds-bot/files/src/host/project-consultations.js @@ -0,0 +1,98 @@ +// Consultation records reuse per-participation transcripts; no Task initiative transfer. +import { createHash } from 'node:crypto' +import { ownEntry, setEntry } from './store.js' +const refuse = message => Object.assign(new Error(message), { code: 'bot/forbidden' }) +export function createProjectConsultations(rt, { ensure, chiefQuestion, serial }) { + const state = rt.state + const agents = () => (state.projectAgents ??= {}) + const transcripts = () => (state.projectAgentTranscripts ??= {}) + const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') + const messages = id => { const rows = ownEntry(transcripts(), id); if (rows) return rows; const created = []; setEntry(transcripts(), id, created); return created } + async function parent(body, actor, create = false) { + rt.requireProjectAccess(actor, body.projectId) + if (actor.personalAgentCredentialId && body.agentId && body.agentId !== actor.agentId) throw refuse('Consultation belongs to another personal agent') + const selected = actor.personalAgentCredentialId ? actor.agentId : body.agentId + const participation = selected ? rt.personalAgents.participationFor(selected, body.projectId) : rt.personalAgents.defaultParticipation(body.projectId, actor.memberId) + if (!participation) { + if (selected) throw refuse('Personal agent participation is unavailable') + return create ? ensure(body.projectId, actor) : null + } + const identity = rt.personalAgents.agentOf(participation.agentId) + if (!identity || identity.ownerId !== actor.memberId || participation.ownerId !== actor.memberId) throw refuse('Only the personal-agent owner may read this consultation') + if (create && !selected && identity.kind === 'internal') return ensure(body.projectId, actor) + const carrier = { id: participation.carrierId, projectId: body.projectId, memberId: actor.memberId, personalAgentId: identity.id, participationId: participation.id, visibility: 'private', kind: 'consultation-mailbox' } + if (create) rt.personalAgents.requireParticipation(carrier) + const existing = ownEntry(agents(), carrier.id) + if (existing && (existing.memberId !== carrier.memberId || existing.projectId !== carrier.projectId || existing.personalAgentId !== carrier.personalAgentId)) throw refuse('Consultation carrier scope changed') + if (!existing && create) { rt.projects.requireActive(body.projectId); setEntry(agents(), carrier.id, carrier) } + return existing ?? carrier + } + function view(parent, receipt, { offset = 0, limit = 20, includeReplies = true } = {}) { + if (!receipt || receipt.projectId !== parent.projectId || receipt.initiatedBy !== parent.memberId || receipt.returnTo !== parent.id || receipt.version !== 'conversation-handoff/v1') throw refuse('Unknown private consultation') + if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw refuse('Invalid consultation reply page') + const replies = (ownEntry(transcripts(), parent.id) ?? []).filter(row => row.conversationHandoffId === receipt.id) + return { version: 'project-consultation/v1', transportVersion: receipt.version, id: receipt.id, projectId: receipt.projectId, + agentId: parent.personalAgentId ?? null, requestId: receipt.requestId, initiatedBy: receipt.initiatedBy, returnTo: receipt.returnTo, + target: { botId: receipt.to, role: receipt.direction === 'incoming' ? 'personal-agent' : 'chief' }, direction: receipt.direction ?? 'outgoing', linkedTaskId: receipt.linkedTaskId ?? null, requiresHuman: Boolean(receipt.requiresHuman), question: receipt.text, state: receipt.status, initiativeTransferred: false, + replies: includeReplies ? replies.slice(offset, offset + limit).map(({ id, text, at, speaker, state, inputId, approvedBy }) => ({ id, text, at, speaker, ...(state ? { state } : {}), ...(inputId ? { inputId } : {}), ...(approvedBy ? { approvedBy } : {}) })) : [], + replyCount: replies.length, nextReplyOffset: includeReplies && offset + limit < replies.length ? offset + limit : null, createdAt: receipt.at, finishedAt: receipt.finishedAt ?? null } + } + async function endpoint(endpoint, body, actor) { + if (!['project-consultation-ask', 'project-consultation-get', 'project-consultation-list', 'project-consultation-reply'].includes(endpoint)) throw refuse('Unknown consultation operation') + if (endpoint === 'project-consultation-ask') return chiefQuestion(body, actor) + if (endpoint === 'project-consultation-reply') return serial(() => reply(body, actor)) + return serial(async () => { + const carrier = await parent(body, actor) + const rows = carrier ? (ownEntry(transcripts(), carrier.id) ?? []).filter(row => row.version === 'conversation-handoff/v1' && row.returnTo === carrier.id && row.initiatedBy === actor.memberId) : [] + if (endpoint === 'project-consultation-get') return { consultation: view(carrier ?? {}, rows.find(row => row.id === body.consultationId), body) } + const limit = body.limit ?? 50 + if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw refuse('Invalid consultation page limit') + const index = body.after === undefined ? -1 : rows.findIndex(row => row.id === body.after) + if (body.after !== undefined && index < 0) throw refuse('Unknown consultation cursor') + const page = rows.slice(index + 1, index + 1 + limit) + return { items: page.map(receipt => view(carrier, receipt, { includeReplies: false })), next: index + 1 + page.length < rows.length ? page.at(-1).id : null } + }) + } + function contactUser(task, question, actor) { + rt.projectChiefControl.requireJobActor(actor, task.projectId) + const memberId = task.collaboration?.initiatedBy ?? task.requesterId ?? rt.readProject(task.projectId).ownerUserId + const participation = rt.personalAgents.defaultParticipation(task.projectId, memberId) + const identity = participation && rt.personalAgents.agentOf(participation.agentId) + if (!participation || participation.status !== 'active' || identity?.status !== 'active' || identity.ownerId !== memberId || !rt.projectCan({ memberId }, task.projectId)) return { kind: 'human', memberId, reason: 'No active authorized personal agent' } + const carrier = ownEntry(agents(), participation.carrierId) ?? { id: participation.carrierId, projectId: task.projectId, memberId, personalAgentId: identity.id, participationId: participation.id, visibility: 'private', kind: 'consultation-mailbox' } + rt.personalAgents.requireParticipation(carrier) + setEntry(agents(), carrier.id, carrier) + const id = `consultation-${hash([task.projectId, task.id, question.id, identity.id])}` + if (!messages(carrier.id).some(row => row.id === id)) messages(carrier.id).push({ id, kind: 'consultation', version: 'conversation-handoff/v1', direction: 'incoming', projectId: task.projectId, linkedTaskId: task.id, chiefDecisionId: question.id, requestedBy: { role: 'chief', runId: actor.chiefJobId }, initiatedBy: memberId, from: `${task.projectId}:chief`, to: carrier.id, returnTo: carrier.id, text: question.reason, requestId: question.id, status: 'accepted', requiresHuman: Boolean(question.requiresHuman), at: Date.now() }) + const token = rt.projectEffects?.stage({ kind: 'consultation.notify', key: id, projectId: task.projectId, taskId: task.id, payload: { consultationId: id, agentId: identity.id, carrierId: carrier.id, memberId } }) + return { kind: 'personal-agent', memberId, agentId: identity.id, participationId: participation.id, consultationId: id, ...(token ? { effectId: token.id } : {}) } + } + async function reply(body, actor) { + if (rt.readOnly?.() !== undefined) throw refuse('Consultations are read-only') + const carrier = await parent(body, actor) + if (!carrier) throw refuse('Unknown private consultation') + rt.personalAgents.requireParticipation(carrier) + const receipt = messages(carrier.id).find(row => row.id === body.consultationId) + view(carrier, receipt) + if (receipt.direction !== 'incoming') throw refuse('Only the delegated Chief executor may answer an outgoing consultation') + const task = ownEntry(state.tasks, receipt.linkedTaskId) + if (!task || task.projectId !== carrier.projectId || task.chiefQuestion?.decisionId !== receipt.chiefDecisionId) throw refuse('This Chief question is no longer current') + if (typeof body.requestId !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(body.requestId) || typeof body.text !== 'string' || !body.text.trim() || body.text.length > 4000) throw refuse('Reply needs a stable requestId and bounded text') + let answer = messages(carrier.id).find(row => row.conversationHandoffId === receipt.id && row.requestId === body.requestId) + if (answer && answer.text !== body.text) throw refuse('Reply requestId was reused with another answer') + if (!answer) { answer = { id: `consultation-reply-${hash([receipt.id, body.requestId])}`, kind: 'agent', speaker: 'Personal agent', text: body.text, conversationHandoffId: receipt.id, requestId: body.requestId, at: Date.now(), state: 'private-suggestion' }; messages(carrier.id).push(answer) } + const proxy = actor.personalAgentCredentialId || actor.consultationProxyAgentId + const delegated = proxy ? await rt.projectDelegations.actorFor(carrier.id) : null + const canPublish = (!proxy || Boolean(delegated)) && (!receipt.requiresHuman || !proxy) && rt.projectCan(actor, task.projectId, 'task-write', task) + if (body.publish !== true || !canPublish) { await rt.save(); return { consultation: view(carrier, receipt), replyId: answer.id, published: false, requiresUserApproval: true } } + // Existing Task inbox performs durable admission and Chief wake-up. A reply + // supplies explicit new input; it cannot close the Task or confirm a Project. + const result = await rt.projectInputs.publish({ projectId: task.projectId, taskId: task.id, requestId: answer.id, text: answer.text }, actor) + answer.state = 'published'; answer.inputId = result.input.id; answer.approvedBy = proxy ? null : actor.memberId + answer.publishedBy = { memberId: actor.memberId, agentId: proxy ? carrier.personalAgentId : null, credentialId: actor.personalAgentCredentialId ?? null } + receipt.status = 'completed'; receipt.finishedAt ??= Date.now() + await rt.save() + return { consultation: view(carrier, receipt), replyId: answer.id, published: true, input: result.input } + } + return { parent, view, endpoint, contactUser } +} diff --git a/patches/ds-bot/files/src/host/project-development-service.js b/patches/ds-bot/files/src/host/project-development-service.js index 3d462634..1a222ad8 100644 --- a/patches/ds-bot/files/src/host/project-development-service.js +++ b/patches/ds-bot/files/src/host/project-development-service.js @@ -11,6 +11,8 @@ const stableId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,100}$/. const sameTask = (row, task) => Boolean(row && row.projectId === task.projectId && row.taskId === task.id) const roles = new Set(['chief', 'developer', 'tester', 'reviewer']) const operations = Object.freeze({ + 'consultation.reply': 'project-consultation-reply', 'consultation.ask': 'project-consultation-ask', 'consultation.get': 'project-consultation-get', 'consultation.list': 'project-consultation-list', + 'handoff.get': 'project-handoff-get', 'handoff.list': 'project-handoff-list', 'task.run': 'project-task-run', 'task.detail': 'project-task-detail', 'task.accept': 'project-task-accept', 'task.reject': 'project-task-reject', 'attempt.stop': 'project-attempt-stop', 'attempt.cleanup': 'project-attempt-cleanup', @@ -25,7 +27,7 @@ const operations = Object.freeze({ 'execution.boundary': 'project-execution-boundary', 'pools': 'project-task-pools', 'effect.retry': 'project-effect-retry', 'comment.publish': 'project-github-comment', }) -const queries = new Set(['tasks', 'task', 'task.detail', 'inputs', 'sources', 'execution.boundary', 'effects', 'attempt.context', 'attempt.integration-state', 'attempt.log', 'chief.state', 'pools']) +const queries = new Set(['consultation.get', 'consultation.list', 'handoff.get', 'handoff.list', 'tasks', 'task', 'task.detail', 'inputs', 'sources', 'execution.boundary', 'effects', 'attempt.context', 'attempt.integration-state', 'attempt.log', 'chief.state', 'pools']) const legacy = new Set(Object.values(operations)) const readEndpoints = new Set([...queries].map(name => operations[name]).filter(Boolean)) export const developmentQuery = body => record(body) && body.version === DEVELOPMENT_PROTOCOL && body.kind === 'query' && queries.has(body.name) @@ -47,7 +49,7 @@ export function taskView(state, task, { activity = {} } = {}) { if (task.status === 'accepted') phase = 'accepted' else if (task.status === 'cancelled' || c?.status === 'cancelled' || attempt?.status === 'cancelled') phase = 'cancelled' else if (attempt?.cancelRequested || c?.status === 'stopping') { phase = 'stopping'; waitingFor = 'worker-exit' } - else if (task.chiefQuestion) { phase = 'awaiting-owner'; waitingFor = 'owner'; reason = task.chiefQuestion.reason } + else if (task.chiefQuestion) { phase = 'awaiting-owner'; waitingFor = task.chiefQuestion.recipient?.kind === 'personal-agent' ? 'personal-agent' : 'owner'; reason = task.chiefQuestion.reason } else if (['arbitration', 'chief-working'].includes(c?.status)) { phase = 'awaiting-chief'; waitingFor = 'chief' } else if (c?.status === 'blocked' || queue?.status === 'blocked') { phase = 'blocked'; reason = c?.block?.reason ?? queue?.reason ?? null } else if (queue) { phase = queue.status === 'starting' ? 'starting' : 'queued'; waitingFor = queue.reason ? 'resource' : 'capacity'; reason = queue.reason ?? null } @@ -166,6 +168,9 @@ export function createDevelopmentService(rt) { if (endpoint === 'project-execution-input') return rt.projectHarness.input(body, actor) if (endpoint === 'project-agent-proposal-apply') return rt.projectAgents.endpoint(endpoint, body, actor) if (endpoint === 'project-task-detail') return detail(body, actor) + if (['project-consultation-reply', 'project-consultation-ask', 'project-consultation-get', 'project-consultation-list'].includes(endpoint)) return rt.projectAgents.consultation(endpoint, body, actor) + if (endpoint === 'project-handoff-get') return { handoff: rt.projectPools.get(body.taskId, body.handoffId, actor) } + if (endpoint === 'project-handoff-list') return rt.projectPools.list(body.taskId, actor, body) if (endpoint === 'project-task-pools') { rt.requireProjectAccess(actor, body.projectId) return { pools: rt.projectAgents.deliveryDefinitions(body.projectId).roles.map(role => ({ botId: role.id, role: role.role, tasks: rt.projectPools.available(body.projectId, role.id, actor) })) } @@ -224,7 +229,9 @@ export function createDevelopmentService(rt) { // Trusted host ports. Public HTTP never accepts role tokens or model results. roleResult: row => rt.projectPools.result(row), roleCommand(body) { - if (body?.version !== DEVELOPMENT_PROTOCOL || !stableId(body.requestId) || !['claim', 'handoff', 'return', 'transfer', 'complete', 'arbitrate'].includes(body.name)) throw failure('Unsupported role command') + if (body?.version !== DEVELOPMENT_PROTOCOL || !stableId(body.requestId) || !['claim', 'handoff', 'return', 'transfer', 'complete', 'arbitrate', 'handoff.accept', 'handoff.offer'].includes(body.name)) throw failure('Unsupported role command') + if (body.name === 'handoff.accept') return rt.projectPools.accept(body.taskId, body.botId, body.handoffId, body.requestId) + if (body.name === 'handoff.offer') return rt.projectPools.decide(body.taskId, body.claim, { ...body.payload, requestId: body.requestId }) if (body.name === 'claim') return rt.projectPools.claim(body.taskId, body.botId) return rt.projectPools.decide(body.taskId, body.claim, { ...body.payload, action: body.name, requestId: body.requestId }) }, diff --git a/patches/ds-bot/files/src/host/project-pools.js b/patches/ds-bot/files/src/host/project-pools.js index b3866326..631d9c94 100644 --- a/patches/ds-bot/files/src/host/project-pools.js +++ b/patches/ds-bot/files/src/host/project-pools.js @@ -1,6 +1,7 @@ // Per-logical-Bot routing on existing Task records. Execution remains a host adapter. import { randomUUID, createHash } from 'node:crypto' import { ownEntry } from './store.js' +import { taskSessionId } from './project-task-sessions.js' import { EffectDeferred, EffectCancelled } from './project-effects.js' export const POOL_PROTOCOL = 'project-handoff/v1' const refuse = message => Object.assign(new Error(message), { code: 'bot/handoff' }) @@ -8,6 +9,50 @@ const clone = value => structuredClone(value) const intentHash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') const reasonOf = value => { if (typeof value !== 'string' || !value.trim() || value.length > 4000) throw refuse('A bounded handoff reason is required'); return value.trim() } + +export const HANDOFF_CONTRACT = 'project-handoff-contract/v1' +function contextReference(state, task, role) { + if (!role || role === 'chief') return null + const logicalSessionId = taskSessionId(task.projectId, task.id, role) + const logical = ownEntry(state.projectTaskSessions ?? {}, logicalSessionId) + const sessions = Object.values(state.projectAttempts ?? {}).filter(attempt => attempt.projectId === task.projectId && attempt.taskId === task.id) + .flatMap(attempt => [...(attempt.roleSessionHistory ?? []).filter(session => session.role === role), attempt.roleSessions?.[role]]) + .filter(session => session?.projectId === task.projectId && session.taskId === task.id && session.role === role) + .sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0)) + const session = sessions.at(-1) + return { logicalSessionId: session?.logicalSessionId ?? logicalSessionId, sessionId: session?.id ?? null, + definitionRef: session?.definitionRef ?? logical?.definitionRef ?? null, definitionRevision: session?.definitionRevision ?? null } +} +// A projection over the original durable receipt, not another workflow/store. +export function handoffContract(state, task, receipt) { + if (!receipt || receipt.projectId !== task.projectId || receipt.taskId !== task.id) throw refuse('Unknown Task handoff') + const roles = state.projectAgentDefinitions?.[task.projectId]?.roles ?? [] + const roleOf = id => roles.find(role => role.id === id)?.role ?? (typeof id === 'string' && id.startsWith(task.projectId + ':') ? id.slice(task.projectId.length + 1) : null) + const sourceRole = roleOf(receipt.from), targetRole = roleOf(receipt.to) + const previous = task.collaboration.history.find(row => row.id === receipt.predecessorId) + const execution = receipt.sourceExecution ?? previous?.execution + const successor = task.collaboration.history.find(row => row.predecessorId === receipt.id || row.requestId === receipt.execution?.id) + const currentClaim = task.collaboration.currentHandoffId === receipt.id ? task.collaboration.claim : null + const accepted = receipt.acceptance ?? (receipt.claimId ? { claimId: receipt.claimId, acceptedAt: receipt.acceptedAt, botId: receipt.to } : null) + const status = receipt.status === 'completed' ? 'processed' : receipt.status === 'intervened' ? 'revoked' : receipt.status === 'rejected' ? 'invalidated' : receipt.finishedAt && receipt.decision ? 'processed' : receipt.status + const context = receipt.contextRefs ?? { source: contextReference(state, task, sourceRole), target: contextReference(state, task, targetRole) } + const events = [{ type: 'offered', at: receipt.createdAt }] + if (accepted) events.push({ type: 'accepted', at: accepted.acceptedAt }) + if (receipt.finishedAt) events.push({ type: status, at: receipt.finishedAt }) + const ownsInitiative = status === 'accepted' && currentClaim?.status === 'active' + const fenced = status === 'accepted' && currentClaim && !ownsInitiative && currentClaim.status !== 'completed' + return { version: HANDOFF_CONTRACT, transportVersion: receipt.version, id: receipt.id, projectId: task.projectId, taskId: task.id, + requestId: receipt.requestId, attemptId: receipt.attemptId, generation: receipt.generation, state: status, purpose: receipt.purpose, + source: { botId: receipt.from, role: sourceRole, context: clone(context.source), execution: execution ? { claimId: execution.id, workerId: execution.workerId ?? null, generation: execution.generation, exitConfirmed: execution.exitConfirmed ?? null } : null }, + target: { botId: receipt.to, role: targetRole, context: clone(context.target) }, + summary: clone(receipt.summary ?? { completed: '', next: receipt.reason }), reason: receipt.reason, requirements: clone(receipt.requirements), + evidence: clone(receipt.evidence ?? []), claims: clone(receipt.claims ?? []), + acceptance: accepted ? { ...clone(accepted), workerId: receipt.execution?.workerId ?? currentClaim?.workerId ?? null } : null, + initiative: { kind: status === 'pending' ? 'pool' : ownsInitiative ? 'execution' : fenced ? 'fenced' : 'relinquished', botId: status === 'pending' || ownsInitiative ? receipt.to : null }, + predecessorId: receipt.predecessorId ?? null, successorId: successor?.id ?? null, + failure: !receipt.failure && currentClaim && task.collaboration.block ? { phase: 'recovery', reason: task.collaboration.block.reason } : receipt.failure ? { phase: receipt.execution?.failure ? 'execution-admission' : 'execution-or-evidence', reason: receipt.failure } : null, events } +} + export function createProjectPools(rt) { let adapter, tail = Promise.resolve() let recovered = false @@ -59,20 +104,25 @@ export function createProjectPools(rt) { if (!checked.ok) throw refuse(`Handoff refused (${checked.code}): ${checked.reason}`) return { fromRole, trustedEvidence } } - async function offer(task, { from, to, reason, requestId, attemptId, evidence = [], purpose = 'work', expectedGeneration }) { + async function offer(task, { from, to, reason, requestId, attemptId, evidence = [], purpose = 'work', expectedGeneration, summary }) { if (!adapter) throw refuse('Handoff execution adapter is unavailable') reason = reasonOf(reason) const c = task.collaboration, target = targetOf(task.projectId, to) if (typeof requestId !== 'string' || !/^[\w-]{1,120}$/.test(requestId)) throw refuse('A stable handoff requestId is required') const duplicate = c.history.find(row => row.requestId === requestId) if (duplicate) { - if (duplicate.from !== from || duplicate.to !== to || duplicate.reason !== reason) throw refuse('Handoff idempotency key was reused with different content') + if (duplicate.from !== from || duplicate.to !== to || duplicate.reason !== reason || duplicate.attemptId !== attemptId || duplicate.purpose !== purpose || summary && intentHash(duplicate.summary) !== intentHash(summary)) throw refuse('Handoff idempotency key was reused with different content') return clone(duplicate) } await authority(task) const { fromRole, trustedEvidence } = await checkAdmission(task, { from, to, attemptId, requestId, purpose }) if (expectedGeneration !== undefined && c.generation !== expectedGeneration) throw refuse('Claim generation changed before handoff admission') const row = { id: `handoff-${randomUUID()}`, version: POOL_PROTOCOL, policyVersion: 'project-role-policy/v1', definitionRevision: definitions(task.projectId).revision, projectId: task.projectId, taskId: task.id, attemptId, initiatedBy: c.initiatedBy, from, to, reason, requestId, purpose, evidence: clone(trustedEvidence), claims: clone(evidence), requirements: { title: task.title, criteria: task.criteria ?? '' }, status: 'pending', generation: c.generation + 1, createdAt: Date.now() } + const sourceClaim = c.claim?.botId === from ? c.claim : null + row.predecessorId = c.currentHandoffId ?? null + row.summary = summary ?? { completed: '', next: reason } + if (sourceClaim) row.sourceExecution = clone(sourceClaim) + row.contextRefs = { source: contextReference(rt.state, task, fromRole), target: contextReference(rt.state, task, target.role) } const before = rt.projectEffects ? { collaboration: clone(c), handoff: task.handoff, question: task.chiefQuestion } : null const input = rt.projectInputs?.stageHandoff(task, row) if (input) row.inputId = input.row.id @@ -113,11 +163,37 @@ export function createProjectPools(rt) { c.attemptId = attempt.id; c.kind = kind return offer(task, { from: null, to: target.id, reason: 'Authorized Task execution request', requestId: attempt.id, attemptId: attempt.id }) } - async function claim(taskId, botId, handoffId) { + async function claim(taskId, botId, handoffId, requestId) { const task = taskOf(taskId), c = task.collaboration + if (requestId !== undefined) { + if (!/^[A-Za-z0-9_-]{1,100}$/.test(requestId) || typeof requestId !== 'string' || !handoffId) throw refuse('Accept needs a stable requestId and handoffId') + await authority(task) + const prior = c?.history.find(row => row.acceptance?.requestId === requestId) + if (prior) { + if (prior.id !== handoffId || prior.to !== botId) throw refuse('Accept idempotency key was reused for another handoff') + const execution = prior.execution ?? (c.claim?.id === prior.claimId ? c.claim : null) + return execution ? clone(execution) : { ...clone(prior.acceptance), id: prior.claimId, status: 'historical' } + } + } if (!c || c.botId !== botId || c.status !== 'queued' || handoffId && c.currentHandoffId !== handoffId) return null await authority(task) - const definition = definitions(task.projectId).roles.find(row => row.id === botId) + let definition = definitions(task.projectId).roles.find(row => row.id === botId) + if (!definition) { c.status = 'blocked'; c.block = { reason: 'Target role is retired; Chief must explicitly select an active role', at: Date.now() }; await rt.save(); return null } + const target = definition, expectedGeneration = c.generation + if (target.role !== 'chief') { + // Arbitration may observe a failed writer, but a downstream execution must + // not bypass an unresolved old exit by selecting another resource. + const unknown = c.history.flatMap(row => [row.execution, row.sourceExecution]).filter(row => row?.workerId && row.exitConfirmed === false) + for (const execution of unknown) { + let exited = false + try { exited = await adapter.release(execution.workerId) } catch { /* Preserve unknown occupancy. */ } + if (!exited) throw refuse('Source execution exit is unconfirmed; handoff remains pending') + execution.exitConfirmed = true + } + await authority(task) + if (c.generation !== expectedGeneration || c.status !== 'queued' || c.botId !== botId) throw refuse('Handoff changed during source-exit confirmation') + } + definition = definitions(task.projectId).roles.find(row => row.id === botId) if (!definition) { c.status = 'blocked'; c.block = { reason: 'Target role is retired; Chief must explicitly select an active role', at: Date.now() }; await rt.save(); return null } if (Object.values(rt.state.tasks).some(row => row.collaboration?.claim?.botId === botId && row.collaboration.claim.status === 'active')) return null if (adapter.ready && !adapter.ready({ task, definition })) return null @@ -128,6 +204,7 @@ export function createProjectPools(rt) { const claim = { id: `claim-${randomUUID()}`, botId, role: definition.role, policyRole: definition.policyRole ?? definition.role, generation: ++c.generation, taskId: task.id, projectId: task.projectId, attemptId: c.attemptId, status: 'active', createdAt: Date.now() } c.usage ??= {}; c.usage.steps = (c.usage.steps ?? 0) + 1 c.claim = claim; c.status = 'claimed'; receipt.status = 'accepted'; receipt.acceptedAt = Date.now(); receipt.claimId = claim.id + receipt.acceptance = { claimId: claim.id, botId: claim.botId, generation: claim.generation, acceptedAt: receipt.acceptedAt, ...(requestId ? { requestId } : {}), ...(claim.chiefRunId ? { chiefRunId: claim.chiefRunId } : {}) } await rt.save() return clone(claim) } @@ -143,11 +220,15 @@ export function createProjectPools(rt) { const claim = await current(task, token) const action = decision?.action, reason = reasonOf(decision?.reason) if (!['handoff', 'return', 'transfer', 'complete', 'arbitrate'].includes(action)) throw refuse('Bot must hand off, return, transfer or request Chief judgement') + const summary = { completed: decision.completed === undefined ? '' : reasonOf(decision.completed), next: decision.next === undefined ? reason : reasonOf(decision.next) } const role = sourceOf(task, claim.botId).role const next = ['complete', 'arbitrate'].includes(action) ? definitions(task.projectId).roles.find(row => row.role === 'chief') : targetOf(task.projectId, decision.to) if (!next) throw refuse('Chief is not configured') // Stop confirmation precedes relinquishing the claim and capacity. - if (claim.workerId && !await adapter.release(claim.workerId)) throw refuse('Worker exit is unconfirmed; claim and capacity remain held') + if (claim.workerId) { + if (!await adapter.release(claim.workerId)) throw refuse('Worker exit is unconfirmed; claim and capacity remain held') + claim.exitConfirmed = true + } await current(task, token) if (action === 'handoff' || action === 'complete') await adapter.complete({ task, claim, role, outputs: decision.outputs ?? {} }) await current(task, token) @@ -162,7 +243,7 @@ export function createProjectPools(rt) { c.usage.tokens = (c.usage.tokens ?? 0) + (usage?.input_tokens ?? 0) + (usage?.output_tokens ?? 0) if ((next.policyRole ?? next.role) === 'developer' && c.kind === 'code') c.attemptId = await adapter.rework?.({ task, claim, reason }) ?? c.attemptId try { - return await offer(task, { from: claim.botId, to: next.id, reason, requestId: decision.requestId ?? claim.id, attemptId: c.attemptId, evidence: decision.evidence ?? [], purpose: action, expectedGeneration: claim.generation }) + return await offer(task, { from: claim.botId, to: next.id, reason, requestId: decision.requestId ?? claim.id, attemptId: c.attemptId, evidence: decision.evidence ?? [], purpose: action, expectedGeneration: claim.generation, summary }) } catch (error) { c.status = 'blocked'; c.block = { reason: error.message, at: Date.now() }; await rt.save(); throw error } @@ -171,6 +252,21 @@ export function createProjectPools(rt) { rt.requireProjectAccess(actor, projectId); targetOf(projectId, botId) return Object.values(rt.state.tasks).filter(task => task.projectId === projectId && task.collaboration?.botId === botId && ['queued', 'arbitration'].includes(task.collaboration.status)).map(clone) } + function get(taskId, handoffId, actor) { + const task = taskOf(taskId) + rt.requireProjectAccess(actor, task.projectId) + return handoffContract(rt.state, task, task.collaboration?.history.find(row => row.id === handoffId)) + } + function list(taskId, actor, { after, limit = 50 } = {}) { + const task = taskOf(taskId) + rt.requireProjectAccess(actor, task.projectId) + if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw refuse('Invalid handoff page limit') + const rows = task.collaboration?.history ?? [] + const index = after === undefined ? -1 : rows.findIndex(row => row.id === after) + if (after !== undefined && index < 0) throw refuse('Unknown handoff cursor') + const page = rows.slice(index + 1, index + 1 + limit) + return { items: page.map(row => handoffContract(rt.state, task, row)), next: index + 1 + page.length < rows.length ? page.at(-1).id : null } + } const owns = botId => Object.values(rt.state.tasks).some(task => task.collaboration?.claim?.workerId === botId || task.collaboration?.history.some(row => row.execution?.workerId === botId)) async function result({ botId, ok, text }) { const task = Object.values(rt.state.tasks).find(task => task.collaboration?.claim?.workerId === botId && task.collaboration.claim.status === 'active') @@ -210,7 +306,12 @@ export function createProjectPools(rt) { if (!token) continue try { const workerId = await adapter.execute({ task, claim: token, definition: targetOf(task.projectId, token.botId) }) - await serial(async () => { const claim = await current(task, token); claim.workerId = workerId; await rt.save() }) + await serial(async () => { + const claim = await current(task, token); claim.workerId = workerId + const receipt = task.collaboration.history.find(row => row.id === task.collaboration.currentHandoffId) + if (receipt?.acceptance?.claimId === claim.id) Object.assign(receipt.acceptance, { workerId, boundAt: Date.now(), context: contextReference(rt.state, task, targetOf(task.projectId, claim.botId).role) }) + await rt.save() + }) await adapter.dispatch({ task, claim: token, workerId }) } catch (error) { let failedClaim @@ -282,6 +383,7 @@ export function createProjectPools(rt) { if (!receipt || receipt.status !== 'pending') throw refuse('Chief handoff receipt is stale') const claim = { id: `chief-claim-${randomUUID()}`, taskId, projectId: task.projectId, attemptId: c.attemptId, botId: c.botId, chiefRunId: actor.chiefJobId, generation: ++c.generation, status: 'active', createdAt: Date.now() } c.claim = claim; c.status = 'chief-working'; receipt.status = 'accepted'; receipt.acceptedAt = Date.now(); receipt.claimId = claim.id + receipt.acceptance = { claimId: claim.id, botId: claim.botId, generation: claim.generation, acceptedAt: receipt.acceptedAt, ...(claim.chiefRunId ? { chiefRunId: claim.chiefRunId } : {}) } await rt.save(); return clone(claim) } async function chiefRoute(body, actor) { @@ -342,7 +444,7 @@ export function createProjectPools(rt) { } return changed } - return { connect(value) { adapter = value; return () => { if (adapter === value) adapter = null } }, admit: (attempt, actor, body) => serial(() => admit(attempt, actor, body)), available, owns, + return { connect(value) { adapter = value; return () => { if (adapter === value) adapter = null } }, get, list, accept: (taskId, botId, handoffId, requestId) => serial(() => claim(taskId, botId, handoffId, requestId)), admit: (attempt, actor, body) => serial(() => admit(attempt, actor, body)), available, owns, claim: (id, botId) => serial(() => claim(id, botId)), decide: (id, token, decision) => serial(() => decide(id, token, decision)), result: row => serial(() => result(row)), pump, recover, revokePrincipal: (projectId, memberId) => serial(() => revokePrincipal(projectId, memberId)), revokeExecution: (id, claimId, reason) => serial(() => revokeExecution(id, claimId, reason)), validateClosure, retryChief: (id, actor) => serial(() => retryChief(id, actor)), chiefClaim: (id, actor) => serial(() => chiefClaim(id, actor)), chiefRoute: (body, actor) => serial(() => chiefRoute(body, actor)) } } export function install(rt) { diff --git a/patches/ds-bot/files/src/host/workbench-gateway.js b/patches/ds-bot/files/src/host/workbench-gateway.js index e117a3e6..588c58bc 100644 --- a/patches/ds-bot/files/src/host/workbench-gateway.js +++ b/patches/ds-bot/files/src/host/workbench-gateway.js @@ -18,7 +18,7 @@ export const WORKBENCH_ENDPOINTS = new Set([ 'project-attempt-stop', 'project-attempt-log', 'project-attempt-cleanup', 'project-attempt-publication-retry', 'project-rooms', 'project-room-create', 'project-room-read', 'project-room-message', 'project-room-attachment', 'project-room-stop', 'project-agent-stop', 'project-agents', 'project-agent-message', 'project-agent-history', 'project-agent-memory-write', 'project-role-memory-write', 'project-agent-definition', 'project-roles', 'project-role-proposals', 'project-role-decision', - 'project-development', 'project-task-pools', 'project-chief-question', 'project-github-read', 'project-agent-mode', + 'project-development', 'project-task-pools', 'project-consultation-reply', 'project-consultation-ask', 'project-consultation-get', 'project-consultation-list', 'project-handoff-get', 'project-handoff-list', 'project-chief-question', 'project-github-read', 'project-agent-mode', 'project-chief-state', 'project-chief-configure', 'project-chief-inspect', 'project-scheduled-task', 'project-agent-proposal-apply', 'project-knowledge-write', 'project-credential-status', 'project-github-comment', ]) diff --git a/patches/ds-bot/files/test/personal-agent-auth.test.js b/patches/ds-bot/files/test/personal-agent-auth.test.js index b8e40c07..f0d4d96a 100644 --- a/patches/ds-bot/files/test/personal-agent-auth.test.js +++ b/patches/ds-bot/files/test/personal-agent-auth.test.js @@ -219,3 +219,67 @@ test('credential pages are bounded and malformed or future credential protocols credential.version = 'future/v2' assert.equal((await http(one.token, 'projects')).ok, false) }) + +test('safe external consultation uses the authenticated agent mailbox and cannot borrow another agent history', async t => { + const { rt, http } = await setup(t) + rt.deliver = async ({ sessionId, exchangeId }) => rt.projectAgents.onEvent({ id: sessionId }, { type: 'exchange/start', data: { exchangeId } }) + const connection = await external(rt) + const response = await http(connection.token, 'project-consultation-ask', { projectId: 'p1', requestId: 'safe-question', text: 'What is the shared project goal?' }) + assert.equal(response.ok, true, JSON.stringify(response)) + assert.equal(response.value.consultation.agentId, connection.agent.id) + assert.equal(response.value.consultation.initiativeTransferred, false) + assert.equal(Object.keys(rt.state.tasks).length, 0) + const other = await external(rt) + const query = { projectId: 'p1', consultationId: response.value.consultation.id, agentId: connection.agent.id } + assert.equal((await http(other.token, 'project-consultation-get', query)).ok, false) + assert.equal((await http(connection.token, 'project-consultation-get', query)).ok, true) + const envelope = await http(connection.token, 'project-development', { version: 'project-development/v1', kind: 'query', projectId: 'p1', name: 'consultation.get', payload: { consultationId: query.consultationId } }) + assert.equal(envelope.ok, true) + assert.equal(envelope.value.result.consultation.id, query.consultationId) + assert.equal((await http(connection.token, 'project-consultation-ask', { ...query, projectId: 'p2', requestId: 'foreign-question', text: 'Cross project?' })).ok, false) + await api(rt, 'personal-agent-token-revoke', { credentialId: connection.credential.id, agentId: connection.agent.id }) + assert.equal((await http(connection.token, 'project-consultation-get', query)).ok, false) +}) + +test('Chief contacts a selected personal agent first; safe suggestions require human approval and watch replies use existing input admission', async t => { + const { rt, http } = await setup(t) + const connection = await external(rt) + await api(rt, 'personal-agent-default', { projectId: 'p1', agentId: connection.agent.id }) + rt.projectChiefControl.requireJobActor = actor => { assert.equal(actor.chiefJobId, 'test-chief-run') } + const task = { id: 't', projectId: 'p1', requesterId: 'alice', title: 'Implement the selected solution', status: 'open', collaboration: { status: 'blocked', initiatedBy: 'alice', history: [], generation: 1 } } + rt.state.tasks.t = task + const ask = async (id, requiresHuman = false) => { + const recipient = rt.projectAgents.contactUser(task, { id, reason: 'Which approved option should we use?', requiresHuman }, { memberId: 'alice', chiefJobId: 'test-chief-run' }) + task.chiefQuestion = { decisionId: id, reason: 'Which option?', recipient } + await rt.save() + return recipient + } + const recipient = await ask('decision-one') + assert.equal(recipient.kind, 'personal-agent') + assert.equal(recipient.agentId, connection.agent.id) + const inbox = await http(connection.token, 'project-consultation-list', { projectId: 'p1' }) + assert.equal(inbox.value.items[0].direction, 'incoming') + assert.equal(inbox.value.items[0].linkedTaskId, 't') + const answer = { projectId: 'p1', consultationId: recipient.consultationId, requestId: 'one-answer', text: 'Choose the smaller approved change', publish: true } + const safe = await http(connection.token, 'project-consultation-reply', answer) + assert.equal(safe.ok, true, JSON.stringify(safe)) + assert.equal(safe.value.published, false) + assert.equal(safe.value.requiresUserApproval, true) + assert.equal(Object.keys(rt.state.projectTaskInputs ?? {}).length, 0) + const approved = await api(rt, 'project-consultation-reply', { ...answer, agentId: connection.agent.id }) + assert.equal(approved.published, true) + assert.equal((await api(rt, 'project-consultation-reply', { ...answer, agentId: connection.agent.id })).input.id, approved.input.id) + assert.equal(Object.keys(rt.state.projectTaskInputs).length, 1) + assert.equal(task.collaboration.status, 'blocked', 'Explicit advice is new input; Chief still decides the next execution') + await api(rt, 'personal-agent-delegation-set', { projectId: 'p1', agentId: connection.agent.id, mode: 'watch' }) + const second = await ask('decision-two') + const watched = await http(connection.token, 'project-consultation-reply', { ...answer, consultationId: second.consultationId }) + assert.equal(watched.ok, true, JSON.stringify(watched)) + assert.equal(watched.value.published, true) + const third = await ask('decision-personal', true) + const personalOnly = await http(connection.token, 'project-consultation-reply', { ...answer, consultationId: third.consultationId }) + assert.equal(personalOnly.value.published, false) + assert.equal(personalOnly.value.requiresUserApproval, true) + assert.equal(task.status, 'open') + assert.equal(Object.keys(rt.state.tasks).length, 1) +}) diff --git a/patches/ds-bot/files/test/project-agents.test.js b/patches/ds-bot/files/test/project-agents.test.js index 59f93248..4768b712 100644 --- a/patches/ds-bot/files/test/project-agents.test.js +++ b/patches/ds-bot/files/test/project-agents.test.js @@ -586,6 +586,131 @@ test('a safe personal agent can recommend closure without a Task or Owner confir assert.equal(rt.readProject('p1').status, 'active') }) +test('consultation APIs expose private durable question/reply records without Task initiative transfer', async t => { + const { rt } = await setup(t) + const original = await get(rt, alice) + const request = { projectId: 'p1', requestId: 'consultation-query', text: 'Explain the current architecture' } + const result = await rt.projectEndpoint('project-consultation-ask', request, alice) + assert.equal(result.consultation.version, 'project-consultation/v1') + assert.equal(result.consultation.initiativeTransferred, false) + assert.equal(Object.keys(rt.state.tasks).length, 0) + const id = result.consultation.id + const chief = Object.values(rt.state.projectAgents).find(row => row.conversationHandoffId === id) + rt.projectAgents.onEvent({ id: chief.sessionId }, { type: 'assistant/message', data: { message: { content: [{ type: 'text', text: 'Scoped architecture answer' }] } } }) + const view = await rt.projectEndpoint('project-consultation-get', { projectId: 'p1', consultationId: id }, alice) + assert.equal(view.consultation.replies.at(-1).text, 'Scoped architecture answer') + assert.equal(view.consultation.returnTo, original.mine.id) + const list = await rt.projectEndpoint('project-consultation-list', { projectId: 'p1' }, alice) + assert.equal(list.items[0].id, id) + await assert.rejects(rt.projectEndpoint('project-consultation-get', { projectId: 'p1', consultationId: id }, bob), /Unknown/) + assert.equal((await rt.projectEndpoint('project-consultation-list', { projectId: 'p1' }, bob)).items.length, 0) + const replay = await rt.projectEndpoint('project-consultation-ask', request, alice) + assert.equal(replay.consultation.id, id) + assert.equal(replay.already, true) + assert.equal(Object.keys(rt.state.tasks).length, 0) +}) + +test('an external personal agent can consult Chief through its own mailbox without a native agent or private-history borrowing', async t => { + const { rt } = await setup(t) + await get(rt, alice) + await rt.projectEndpoint('project-agent-memory-write', { projectId: 'p1', text: 'DEFAULT_AGENT_PRIVATE_SECRET' }, alice) + const { agent } = await rt.projectEndpoint('personal-agent-create', { name: 'External client', kind: 'external', provider: 'client', endpoint: 'https://example.test/agent' }, alice) + await rt.projectEndpoint('personal-agent-join', { agentId: agent.id, projectId: 'p1' }, alice) + const request = { projectId: 'p1', agentId: agent.id, requestId: 'external-consult', text: 'Which shared goal is active?' } + const reply = await rt.projectEndpoint('project-consultation-ask', request, alice) + const carrier = rt.state.projectAgents[reply.consultation.returnTo] + assert.equal(carrier.personalAgentId, agent.id) + assert.equal(carrier.botId, undefined) + assert.equal(JSON.stringify(reply).includes('DEFAULT_AGENT_PRIVATE_SECRET'), false) + const chief = Object.values(rt.state.projectAgents).find(row => row.conversationHandoffId === reply.consultation.id) + rt.projectAgents.onEvent({ id: chief.sessionId }, { type: 'assistant/message', data: { message: { content: [{ type: 'text', text: 'Shared goal reply' }] } } }) + const view = await rt.projectEndpoint('project-consultation-get', { ...request, consultationId: reply.consultation.id }, alice) + assert.equal(view.consultation.replies.at(-1).text, 'Shared goal reply') + await assert.rejects(rt.projectEndpoint('project-consultation-get', { ...request, consultationId: reply.consultation.id }, bob), /owner|access|Unknown/i) + assert.equal(Object.keys(rt.state.tasks).length, 0) +}) + +test('consultation queries do not allocate missing agents and reject malformed pages, stale or forged replies', async t => { + const { rt } = await setup(t) + await assert.rejects(rt.projectAgents.consultation('unsupported', { projectId: 'p1' }, alice), /Unknown/ ) + const before = JSON.stringify(rt.state.projectAgents) + assert.deepEqual((await rt.projectEndpoint('project-consultation-list', { projectId: 'p1' }, bob)).items, []) + assert.equal(JSON.stringify(rt.state.projectAgents), before) + await assert.rejects(rt.projectEndpoint('project-consultation-get', { projectId: 'p1', consultationId: 'missing' }, bob), /Unknown/) + const request = { projectId: 'p1', requestId: 'bounded-consult', text: 'Explain the current goal' } + const { consultation } = await rt.projectEndpoint('project-consultation-ask', request, alice) + for (const body of [{ limit: 0 }, { limit: 101 }, { after: 'foreign' }]) await assert.rejects(rt.projectEndpoint('project-consultation-list', { projectId: 'p1', ...body }, alice), /limit|cursor/) + await assert.rejects(rt.projectEndpoint('project-consultation-get', { projectId: 'p1', consultationId: consultation.id, offset: -1 }, alice), /page/) + await assert.rejects(rt.projectEndpoint('project-consultation-reply', { projectId: 'p1', consultationId: consultation.id, requestId: 'spoof', text: 'Pretend to be Chief', publish: true }, alice), /Chief executor/) + await assert.rejects(rt.projectEndpoint('project-consultation-list', { projectId: 'p1', agentId: 'unknown' }, alice), /participation/) + const peer = await get(rt, bob) + await assert.rejects(rt.projectEndpoint('project-consultation-list', { projectId: 'p1', agentId: peer.mine.personalAgentId }, alice), /owner/) +}) + +test('personal-agent notifications run only after commit, reuse watch authority and do not borrow a changed default', async t => { + const { rt } = await setup(t) + const original = await get(rt, alice) + await rt.projectEndpoint('project-agent-mode', { projectId: 'p1', mode: 'watch' }, alice) + rt.projectChiefControl.requireJobActor = actor => { assert.equal(actor.chiefJobId, 'chief-run') } + const actor = { ...alice, chiefJobId: 'chief-run' } + const task = { id: 'notify-task', projectId: 'p1', status: 'open', requesterId: alice.memberId } + rt.state.tasks[task.id] = task + let wakes = 0 + rt.projectAgents.wakeWatch = async (projectId, memberId, text, carrierId) => { + wakes++ + assert.equal(projectId, 'p1') + assert.equal(memberId, alice.memberId) + assert.equal(carrierId, original.mine.id) + assert.match(text, /project_agent_consultations/) + return { agent: { id: carrierId } } + } + const question = { id: 'question-one', reason: 'Clarify the planned behavior' } + const recipient = rt.projectAgents.contactUser(task, question, actor) + assert.equal(rt.projectAgents.contactUser(task, question, actor).consultationId, recipient.consultationId) + task.chiefQuestion = { decisionId: question.id, recipient } + await rt.projectEffects.drain() + assert.equal(wakes, 0) + await rt.save() + await rt.projectEffects.drain() + assert.equal(wakes, 1) + await rt.projectEffects.drain() + assert.equal(wakes, 1) + const next = rt.projectAgents.contactUser(task, { id: 'question-two', reason: 'Another scoped question' }, actor) + task.chiefQuestion = { decisionId: 'question-two', recipient: next } + await rt.save() + const { agent } = await rt.projectEndpoint('personal-agent-create', { name: 'Another assistant' }, alice) + await rt.projectEndpoint('personal-agent-join', { projectId: 'p1', agentId: agent.id }, alice) + await rt.projectEndpoint('personal-agent-default', { projectId: 'p1', agentId: agent.id }, alice) + await rt.projectEffects.drain() + assert.equal(wakes, 1) + const ended = rt.projectAgents.contactUser(task, { id: 'question-ended', reason: 'This question will be cancelled' }, actor) + task.chiefQuestion = { decisionId: 'question-ended', recipient: ended } + await rt.save() + delete task.chiefQuestion + await rt.projectEffects.drain() + assert.equal(rt.state.projectEffects[ended.effectId].status, 'cancelled') + const noAgent = { ...task, requesterId: bob.memberId } + assert.equal(rt.projectAgents.contactUser(noAgent, { id: 'no-agent', reason: 'Ask the current human' }, actor).kind, 'human') +}) + +test('a busy native watch defers its committed notification without exhausting retries', async t => { + const { rt } = await setup(t) + await get(rt, alice) + await rt.projectEndpoint('project-agent-mode', { projectId: 'p1', mode: 'watch' }, alice) + rt.projectChiefControl.requireJobActor = () => {} + const task = { id: 'busy-notify', projectId: 'p1', requesterId: alice.memberId, status: 'open' } + rt.state.tasks[task.id] = task + const recipient = rt.projectAgents.contactUser(task, { id: 'busy-question', reason: 'Clarify a current choice' }, { ...alice, chiefJobId: 'chief-run' }) + task.chiefQuestion = { decisionId: 'busy-question', recipient } + rt.projectAgents.wakeWatch = async () => { throw Object.assign(new Error('Already answering'), { code: 'bot/busy' }) } + await rt.save() + await rt.projectEffects.drain() + const effect = rt.state.projectEffects[recipient.effectId] + assert.equal(effect.status, 'pending') + assert.equal(effect.failures, 0) + assert.equal(effect.waitingFor, 'agent') +}) + test('resident Chief role proposals require separate Owner approval and cannot be forged by private Bots or HTTP fields', async t => { const { rt } = await setup(t), initial = await get(rt, alice) await message(rt, bob, 'public', 'Suggest an additional analysis role') diff --git a/patches/ds-bot/files/test/project-chief-control.test.js b/patches/ds-bot/files/test/project-chief-control.test.js index 12161309..4bbedeae 100644 --- a/patches/ds-bot/files/test/project-chief-control.test.js +++ b/patches/ds-bot/files/test/project-chief-control.test.js @@ -133,6 +133,21 @@ test('inspection compacts only proven released history and preserves current, un assert.equal(inspectionResources(state, task).releasedResourceHistory, undefined) }) +test('a needs-human decision records the personal-agent recipient without transferring Task initiative', async () => { + const f = await controlFixture() + f.rt.projectAgents.contactUser = (task, question, actor) => { + assert.equal(task.id, 't') + assert.equal(question.reason, 'Need the user preference') + assert.equal(actor.chiefJobId, f.request.runId) + return { kind: 'personal-agent', memberId: 'owner', agentId: 'personal', consultationId: 'consult' } + } + await f.request.onResult(f.result({ action: 'needs-human', reason: 'Need the user preference' })) + assert.equal(f.task.chiefQuestion.recipient.agentId, 'personal') + assert.equal(f.task.collaboration.status, 'blocked') + assert.equal(f.task.collaboration.botId, 'p:chief') + assert.equal(f.accepted(), 0) +}) + test('validated Chief completion can publish bounded role proposals but cannot apply role changes', async () => { const f = await controlFixture(), calls = [] diff --git a/patches/ds-bot/files/test/project-pools.test.js b/patches/ds-bot/files/test/project-pools.test.js index 3cd70ec3..ba873ddd 100644 --- a/patches/ds-bot/files/test/project-pools.test.js +++ b/patches/ds-bot/files/test/project-pools.test.js @@ -223,6 +223,108 @@ test('Chief intervention does not reset global usage or bypass quota', async () assert.deepEqual(f.task, before) }) +test('handoff queries distinguish pending, accepted and processed initiative without closing the Task', async () => { + const f = setup(), actor = { memberId: 'owner' } + const offered = await f.admit() + const pending = f.pool.get('task', offered.id, actor) + assert.equal(pending.state, 'pending') + assert.equal(pending.initiative.kind, 'pool') + assert.equal(pending.target.role, 'developer') + assert.equal(pending.acceptance, null) + const claim = await f.claim() + const accepted = f.pool.get('task', offered.id, actor) + assert.equal(accepted.state, 'accepted') + assert.equal(accepted.acceptance.claimId, claim.id) + assert.equal(accepted.initiative.kind, 'execution') + const next = await f.pool.decide('task', claim, { action: 'transfer', to: 'p:tester', reason: 'Inspect the code before continuing', completed: 'Located the affected module', next: 'Check the regression strategy' }) + const processed = f.pool.get('task', offered.id, actor) + assert.equal(processed.state, 'processed') + assert.equal(processed.successorId, next.id) + assert.equal(processed.initiative.kind, 'relinquished') + const target = f.pool.get('task', next.id, actor) + assert.equal(target.source.execution.claimId, claim.id) + assert.equal(target.summary.completed, 'Located the affected module') + assert.equal(target.summary.next, 'Check the regression strategy') + target.summary.next = 'Caller mutation' + assert.equal(f.pool.get('task', next.id, actor).summary.next, 'Check the regression strategy') + assert.deepEqual(target.events.map(row => row.type), ['offered']) + assert.notEqual(f.task.status, 'accepted') + assert.equal(f.pool.list('task', actor, { limit: 1 }).items.length, 1) + assert.equal(f.pool.list('task', actor, { after: offered.id }).items[0].id, next.id) + assert.equal(f.pool.list('task', actor, { after: next.id }).items.length, 0) + assert.throws(() => f.pool.get('task', offered.id, { memberId: 'stranger' }), /authority/) + assert.throws(() => f.pool.get('task', 'missing', actor), /Unknown/) + assert.throws(() => f.pool.list('task', actor, { after: 'foreign' }), /cursor/) + f.rt.revoked = true + assert.throws(() => f.pool.get('task', offered.id, actor), /authority/) +}) + +test('handoff contracts resolve scoped context references without copying private or other-role history', async () => { + const f = setup(), actor = { memberId: 'owner' } + await f.admit() + const claim = await f.claim() + const scope = { projectId: 'p', taskId: 'task', attemptId: 'attempt' } + f.rt.state.projectAttempts = { attempt: { ...scope, roleSessions: { + developer: { ...scope, id: 'dev-session', role: 'developer', logicalSessionId: 'dev-logical', definitionRevision: 7, context: { text: 'DO NOT COPY PRIVATE TRANSCRIPT' } }, + tester: { ...scope, id: 'test-session', role: 'tester', logicalSessionId: 'test-logical', definitionRevision: 5 }, + } } } + const receipt = await f.pool.decide('task', claim, { action: 'return', to: 'p:tester', reason: 'Return with a focused question', context: { sessionId: 'forged' } }) + const view = f.pool.get('task', receipt.id, actor) + assert.equal(view.source.context.sessionId, 'dev-session') + assert.equal(view.target.context.sessionId, 'test-session') + assert.equal(JSON.stringify(view).includes('DO NOT COPY'), false) + assert.equal(JSON.stringify(view).includes('forged'), false) + f.rt.state.projectAttempts.attempt.roleSessions.tester.taskId = 'foreign' + assert.equal(f.pool.get('task', receipt.id, actor).target.context.sessionId, 'test-session', 'The original handoff reference remains immutable') + const tester = await f.pool.claim('task', 'p:tester') + const returned = await f.pool.decide('task', tester, { action: 'return', to: 'p:developer', reason: 'Need another code change' }) + assert.equal(f.pool.get('task', returned.id, actor).source.context.sessionId, null, 'A new contract cannot resolve a foreign-scoped Session') +}) + +test('explicit handoff acceptance replays a lost response but never reactivates completed execution', async () => { + const f = setup(), offered = await f.admit() + const first = await f.pool.accept('task', 'p:developer', offered.id, 'accept-original') + assert.equal((await f.pool.accept('task', 'p:developer', offered.id, 'accept-original')).id, first.id) + const next = await f.pool.decide('task', first, { action: 'transfer', to: 'p:tester', reason: 'Check the code' }) + const replay = await f.pool.accept('task', 'p:developer', offered.id, 'accept-original') + assert.equal(replay.status, 'completed') + assert.equal(f.task.collaboration.currentHandoffId, next.id) + assert.equal(f.task.collaboration.claim, undefined) + await assert.rejects(f.pool.accept('task', 'p:tester', next.id, 'accept-original'), /idempotency/) + await assert.rejects(f.pool.accept('task', 'p:tester', next.id, ''), /stable/) + const accepted = await f.pool.accept('task', 'p:tester', next.id, 'accept-next') + assert.equal(accepted.botId, 'p:tester') + assert.equal(f.pool.get('task', next.id, { memberId: 'owner' }).acceptance.requestId, 'accept-next') +}) + +test('Chief observation cannot let a later pool bypass an earlier writer with unknown exit', async () => { + const f = setup(); await f.admit() + f.adapter.execute = async () => 'unknown-writer' + f.adapter.dispatch = async () => { throw new Error('Lost response') } + f.adapter.release = async () => false + await f.pool.pump() + f.rt.projectChiefControl = { requireJobActor() {}, validateActor: async () => true } + f.rt.state.projectAttempts = { attempt: { status: 'running' } } + await f.pool.chiefRoute({ taskId: 'task', targetBotId: 'p:tester', expectedGeneration: f.task.collaboration.generation, reason: 'Inspect after a dispatch failure', decisionId: 'chief-reroute' }, { memberId: 'owner' }) + await assert.rejects(f.pool.claim('task', 'p:tester'), /Source execution exit is unconfirmed/) + assert.equal(f.task.collaboration.status, 'queued') + assert.equal(f.task.collaboration.claim, undefined) + f.adapter.release = async () => true + assert.equal((await f.pool.claim('task', 'p:tester')).botId, 'p:tester') +}) + +test('an interrupted acceptance exposes a fenced initiative and recovery cause', async () => { + const f = setup(), offered = await f.admit() + await f.claim() + f.task.collaboration.claim.workerId = 'unknown-writer' + f.pool.recover() + const view = f.pool.get('task', offered.id, { memberId: 'owner' }) + assert.equal(view.state, 'accepted') + assert.equal(view.initiative.kind, 'fenced') + assert.equal(view.failure.phase, 'recovery') + assert.match(view.failure.reason, /restarted/) +}) + test('a retired source can finish its frozen active claim but cannot receive new offers', async () => { const f = setup(); await f.admit(); const claim = await f.claim() @@ -260,3 +362,17 @@ test('host execution revocation fences the exact claim before stopping and never assert.equal(await f.pool.revokeExecution('task', token.id, 'Repeated observation'), false) await assert.rejects(f.pool.decide('task', token, { action: 'handoff', to: 'p:tester', reason: 'Stale callback' }), /stale/) }) + +test('a role retired while source exit is inspected cannot acquire a new handoff claim', async () => { + const f = setup(); await f.admit() + f.task.collaboration.history[0].sourceExecution = { workerId: 'old-worker', exitConfirmed: false } + f.adapter.release = async () => { + const roles = f.rt.projectAgents.deliveryDefinitions().roles + roles.splice(roles.findIndex(role => role.role === 'developer'), 1) + return true + } + assert.equal(await f.claim(), null) + assert.equal(f.task.collaboration.status, 'blocked') + assert.equal(f.task.collaboration.claim, undefined) + assert.match(f.task.collaboration.block.reason, /retired/) +})