From 15a79400bc6c8646826aa0c6fbce47148d50311e Mon Sep 17 00:00:00 2001 From: Bindy-lbb <70745012+Bindy-lbb@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:24:16 +0800 Subject: [PATCH] feat(groups): persist traceable relay handoff notes (#617) --- CHANGELOG.md | 4 + apps/desktop/packaging/runtime-layout.cjs | 6 + apps/desktop/renderer/src/app.css | 14 ++ .../renderer/src/groups/GroupsPanel.tsx | 37 ++++- .../src/groups/RelayHandoffDetails.tsx | 33 +++++ .../renderer/src/groups/relay-instructions.ts | 15 +++ apps/desktop/renderer/src/owb.d.ts | 1 + apps/desktop/renderer/src/turns/adapter.ts | 1 + apps/desktop/renderer/src/turns/types.ts | 1 + .../renderer/test/groups-panel.test.tsx | 22 ++- .../renderer/test/relay-handoff.test.tsx | 49 +++++++ apps/desktop/src/group-ipc.cjs | 9 +- apps/desktop/test/session-ipc.test.cjs | 14 ++ apps/server/src/groups/store.ts | 10 +- apps/server/src/routes/groups.ts | 35 +++-- apps/server/src/routes/turns.ts | 13 +- apps/server/src/turns/relay-handoff.ts | 63 +++++++++ apps/server/src/turns/store.ts | 17 ++- apps/server/src/turns/thread-context.ts | 110 ++++++++++++++- apps/server/test/group-collaboration.test.ts | 69 +++++++++- apps/server/test/relay-handoff.test.ts | 126 ++++++++++++++++++ apps/server/test/thread-context.test.ts | 1 + docs/design/relay-handoff-notes-v1.md | 59 ++++++++ docs/thread-context-and-collaboration.md | 14 ++ packages/shared/package.json | 8 +- packages/shared/relay-steps.cjs | 20 +++ packages/shared/src/groups.ts | 4 + packages/shared/src/index.ts | 1 + packages/shared/src/relay-steps.ts | 8 ++ packages/shared/src/turns.ts | 31 +++++ packages/ui/src/locales/en.ts | 20 +++ packages/ui/src/locales/zh.ts | 20 +++ 32 files changed, 807 insertions(+), 28 deletions(-) create mode 100644 apps/desktop/renderer/src/groups/RelayHandoffDetails.tsx create mode 100644 apps/desktop/renderer/src/groups/relay-instructions.ts create mode 100644 apps/desktop/renderer/test/relay-handoff.test.tsx create mode 100644 apps/server/src/turns/relay-handoff.ts create mode 100644 apps/server/test/relay-handoff.test.ts create mode 100644 docs/design/relay-handoff-notes-v1.md create mode 100644 packages/shared/relay-steps.cjs create mode 100644 packages/shared/src/relay-steps.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb346ad..c250fe21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ ### Added +- #617:接力任务支持可选本步要求,以及与模型输入一致的交接快照;可查看前序结果来源、裁剪和过滤提示,共享现有上下文预算并去除重复来源。新增本地记录字段,降级旧版本前请备份工作区。 + +### Added + - #615: Show per-task execution progress in project cards, schedule rows, and the task editor. Reuse live stage events and exact persisted snapshots, recover on reconnect, and keep completed execution separate from delivery acceptance. ### Fixed diff --git a/apps/desktop/packaging/runtime-layout.cjs b/apps/desktop/packaging/runtime-layout.cjs index 81a8ba00..86a611d0 100644 --- a/apps/desktop/packaging/runtime-layout.cjs +++ b/apps/desktop/packaging/runtime-layout.cjs @@ -165,6 +165,7 @@ const SERVER_RUNTIME_FILES = [ "dist/src/turns/delta-forwarder.js", "dist/src/turns/envelope.js", "dist/src/turns/progress.js", + "dist/src/turns/relay-handoff.js", "dist/src/turns/response-format.js", "dist/src/turns/running.js", "dist/src/turns/store.js", @@ -193,6 +194,7 @@ const SHARED_RUNTIME_FILES = [ "pending-approval.cjs", "position-id.cjs", "turn-engines.cjs", + "relay-steps.cjs", "dist/acceptance.js", "dist/agent-binding.js", "dist/api.js", @@ -222,6 +224,7 @@ const SHARED_RUNTIME_FILES = [ "dist/position-id.js", "dist/position-profile.js", "dist/relationship-graph.js", + "dist/relay-steps.js", "dist/semantic-runtime.js", "dist/services.js", "dist/sessions.js", @@ -324,6 +327,9 @@ const APP_RESOURCE_REQUIRED_ENTRIES = [ "node_modules/@roleweave/shared/position-id.cjs", "node_modules/@roleweave/shared/pending-approval.cjs", "node_modules/@roleweave/shared/turn-engines.cjs", + "node_modules/@roleweave/shared/relay-steps.cjs", + "node_modules/@roleweave/shared/dist/relay-steps.js", + "apps/server/dist/src/turns/relay-handoff.js", "examples/oss-maintainer/workspace.json", "examples/oss-maintainer/organization.v1alpha1.json", ]; diff --git a/apps/desktop/renderer/src/app.css b/apps/desktop/renderer/src/app.css index 96145328..b200f03a 100644 --- a/apps/desktop/renderer/src/app.css +++ b/apps/desktop/renderer/src/app.css @@ -9110,3 +9110,17 @@ body, @media (prefers-reduced-motion: reduce) { .owb-turn-panel *, .owb-org-module__splitter::before { animation: none !important; transition: none !important; } } .owb-turn-panel .owb-tc-head__eng { max-width: 100%; white-space: normal; overflow-wrap: anywhere; } + +/* Bounded relay notes and optional per-step requirements. */ +.owb-relay-handoff, .owb-relay-instructions { min-width: 0; margin-block: 8px; } +.owb-relay-handoff summary, .owb-relay-instructions summary { cursor: pointer; overflow-wrap: anywhere; } +.owb-relay-handoff__body { max-height: 420px; overflow: auto; padding: 8px 0; } +.owb-relay-handoff dl { margin: 8px 0; } +.owb-relay-handoff dt { font-weight: 600; } +.owb-relay-handoff dd { margin: 4px 0 12px; white-space: pre-wrap; overflow-wrap: anywhere; } +.owb-relay-handoff section { border-top: 1px solid var(--ui-border); padding-block: 10px; } +.owb-relay-handoff pre { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; } +.owb-relay-handoff code { overflow-wrap: anywhere; } +.owb-relay-instructions__fields { display: grid; gap: 10px; max-height: 240px; overflow: auto; padding: 8px 0; } +.owb-relay-instructions__fields label { display: grid; gap: 4px; min-width: 0; } +.owb-relay-instructions small { color: var(--ui-foreground-muted); } diff --git a/apps/desktop/renderer/src/groups/GroupsPanel.tsx b/apps/desktop/renderer/src/groups/GroupsPanel.tsx index 5d7381a6..408cc149 100644 --- a/apps/desktop/renderer/src/groups/GroupsPanel.tsx +++ b/apps/desktop/renderer/src/groups/GroupsPanel.tsx @@ -3,6 +3,8 @@ import { createPortal } from "react-dom"; import { Button as AntButton, Input, Select as AntSelect } from "antd"; import { ArrowUp, Plus, Search, Trash2, UserRound, UserRoundPlus, UsersRound } from "lucide-react"; import { useOwbLocale, useT } from "@roleweave/ui"; +import { RelayHandoffDetails } from "./RelayHandoffDetails"; +import { instructionBytes, selectedRelaySteps, relayStepsTooLarge, RELAY_INSTRUCTION_BYTES } from "./relay-instructions"; import { PositionAvatar } from "../PositionAvatar"; import { DiagnosticNotice, type AvailabilityCheck } from "../DiagnosticNotice"; import { ProgressTrail } from "../turns/TurnThread"; @@ -208,8 +210,8 @@ export function GroupsPanel({ const [createOpen, setCreateOpen] = useState(false); const [draftMembers, setDraftMembers] = useState>(new Set()); const draftKey = selectedRef ?? ""; - const [drafts, setDrafts] = useState>({}); - const emptyDraft = { input: "", mentions: [] as string[], mode: "parallel" as const }; + const [drafts, setDrafts] = useState }>>({}); + const emptyDraft = { input: "", mentions: [] as string[], mode: "parallel" as const, instructions: {} as Record }; const draft = drafts[draftKey] ?? emptyDraft; const input = draft.input; const mentions = useMemo(() => new Set(draft.mentions), [draft.mentions]); @@ -230,6 +232,12 @@ export function GroupsPanel({ ); const mentionsReady = unavailableMention === undefined; const dispatchMode = draft.mode; + const relaySteps = selectedRelaySteps(mentionedPositionIds, draft.instructions); + const invalidRelaySteps = dispatchMode === "relay" && relayStepsTooLarge(relaySteps); + const setStepInstruction = (positionId: string, instruction: string) => setDrafts(current => { + const previous = current[draftKey] ?? emptyDraft; + return { ...current, [draftKey]: { ...previous, instructions: { ...previous.instructions, [positionId]: instruction } } }; + }); const setInput = (input: string) => setDrafts((current) => ({ ...current, [draftKey]: { ...(current[draftKey] ?? emptyDraft), input } })); const setMentions = (mentions: ReadonlySet) => setDrafts((current) => ({ ...current, [draftKey]: { ...(current[draftKey] ?? emptyDraft), mentions: [...mentions] } })); const setDispatchMode = (mode: "parallel" | "relay") => setDrafts((current) => ({ ...current, [draftKey]: { ...(current[draftKey] ?? emptyDraft), mode } })); @@ -543,7 +551,7 @@ export function GroupsPanel({ const ref = selectedRefRef.current; const trimmed = input.trim(); if ( - ref === null || trimmed.length === 0 || mentions.size === 0 || !mentionsReady || + ref === null || trimmed.length === 0 || mentions.size === 0 || !mentionsReady || invalidRelaySteps || sendingGroupsRef.current.has(ref) ) return; const requestEngine = mentionEngines[mentionedPositionIds[0]!] ?? engine; @@ -560,6 +568,7 @@ export function GroupsPanel({ engines: mentionEngines, mentions: mentionedPositionIds, mode: dispatchMode, + ...(dispatchMode === "relay" && relaySteps.length ? { relaySteps } : {}), }); if (!isCurrent()) return; if (res.status !== 202) { @@ -572,7 +581,7 @@ export function GroupsPanel({ spawns: Array<{ turnId: string; positionId: string; engine?: TurnEngine }>; }; onSpawnRuns(ref, body.messageId, body.spawns, trimmed, requestEngine); - setDrafts((current) => current[ref]?.input === input ? { ...current, [ref]: { ...current[ref]!, input: "", mentions: [] } } : current); + setDrafts((current) => current[ref] === draft ? { ...current, [ref]: { ...current[ref]!, input: "", mentions: [], instructions: {} } } : current); void loadTimeline(ref); } catch { if (isCurrent() && selectedRefRef.current === ref) setPanelError(t("grp.turnFailOffline")); @@ -585,6 +594,9 @@ export function GroupsPanel({ }, [ captureScope, dispatchMode, + draft, + invalidRelaySteps, + relaySteps, engine, input, loadTimeline, @@ -946,6 +958,7 @@ export function GroupsPanel({ )} {isLive || !["group_relay_blocked", "group_relay_stopped"].includes(turn.errorCode ?? "") ? : null} + {"relayHandoff" in turn && turn.relayHandoff ? : null} {turn.output ? ( {dispatchMode === "relay" ?

{t("grp.relayOrder", { order: [...mentions].map(displayPositionName).join(" → ") })}

: null} + {dispatchMode === "relay" && mentions.size > 0 ?
+ {t("grp.relayInstructions")} +
+ {mentionedPositionIds.map((positionId, index) => )} +
+
: null} + {invalidRelaySteps ?

{t("grp.relayInstructionLimit")}

: null}