Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

### Added

- #621: Add explicit task collaborators and responsibilities, validated dependency graphs, acceptance-based readiness, scoped delivery context and immutable execution snapshots with live project/review inspection.
- Versioned project task briefings: server-owned goal/task requirements and acceptance criteria enter execution and persist for conversation/review inspection, with stale-plan protection and preserved retry/approval bindings (#619).
- #617:接力任务支持可选本步要求,以及与模型输入一致的交接快照;可查看前序结果来源、裁剪和过滤提示,共享现有上下文预算并去除重复来源。新增本地记录字段,降级旧版本前请备份工作区。

### Added
Expand Down Expand Up @@ -77,7 +79,6 @@
- #559:新增协作区人工验收契约,验收通过必须完整覆盖计划中的全部验收项;限制标识符、时间戳和产物路径,拒绝遗漏或越界的裁决。

- #469:可选 Laya 接力停止建议。仅在 `ROLEWEAVE_LAYA_ENABLED` 开启、已完成步骤得到正向建议且仍有未执行步骤时暂停;桌面端由 owner 显式选择“停止剩余步骤”或“继续接力”。30 秒超时、SSE 断连、无效或失败建议均继续原 mention order;Laya 只接收 `status`、`errorCode`、`hasOutput`,不接收输入、输出或 handoff 文本。已接受的消息与 spawn 清单保持不可变。Refs #422。
>>>>>>> origin/main

- #360:工作区 `work/` 领地布局。initialize/create 脚手架 `work/README.md`(已有工作区不加强制迁移)。hire 创建 `work/<positionId>/`,仅给 hire 岗位生成 SKILL.md Territory 段,并把新角色的 `memoryScope` 写成 `./work/<positionId>/`;项目负责人保持 `memoryScope: "/"`,SKILL 不声称不存在的 `work/<owner>/`。默认 `toolAllow` 只有 Read/Grep/Glob,不含 Write/Edit/Bash。hire 任何 staging 失败都回滚声明文件。employee.json 摘要密封机制不变。Refs #360。

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"electron": "43.7.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"vite": "^6.0.0",
"vite": "^6.4.4",
"vitest": "4.1.11"
},
"dependencies": {
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/packaging/runtime-layout.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ const SERVER_RUNTIME_FILES = [
"dist/src/experiments/service.js",
"dist/src/experiments/store.js",
"dist/src/gemini-binary.js",
"dist/src/goals/collaboration.js",
"dist/src/goals/store.js",
"dist/src/groups/relay-stop.js",
"dist/src/groups/store.js",
Expand Down Expand Up @@ -163,13 +164,15 @@ const SERVER_RUNTIME_FILES = [
"dist/src/stable-read.js",
"dist/src/tasks/store.js",
"dist/src/turns/budget-remaining-advice.js",
"dist/src/turns/context-snapshot.js",
"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",
"dist/src/turns/task-briefing.js",
"dist/src/turns/thread-context.js",
"dist/src/vault/attachments.js",
"dist/src/vault/context.js",
Expand All @@ -196,6 +199,7 @@ const SHARED_RUNTIME_FILES = [
"position-id.cjs",
"turn-engines.cjs",
"relay-steps.cjs",
"task-briefing.cjs",
"dist/acceptance.js",
"dist/agent-binding.js",
"dist/api.js",
Expand Down Expand Up @@ -230,6 +234,8 @@ const SHARED_RUNTIME_FILES = [
"dist/services.js",
"dist/sessions.js",
"dist/task-board.js",
"dist/task-briefing.js",
"dist/task-collaboration.js",
"dist/tool-output.js",
"dist/turn-progress.js",
"dist/turns.js",
Expand Down Expand Up @@ -331,6 +337,12 @@ const APP_RESOURCE_REQUIRED_ENTRIES = [
"node_modules/@roleweave/shared/relay-steps.cjs",
"node_modules/@roleweave/shared/dist/relay-steps.js",
"apps/server/dist/src/turns/relay-handoff.js",
"apps/server/dist/src/turns/context-snapshot.js",
"apps/server/dist/src/turns/task-briefing.js",
"apps/server/dist/src/goals/collaboration.js",
"node_modules/@roleweave/shared/dist/task-collaboration.js",
"node_modules/@roleweave/shared/task-briefing.cjs",
"node_modules/@roleweave/shared/dist/task-briefing.js",
"examples/oss-maintainer/workspace.json",
"examples/oss-maintainer/organization.v1alpha1.json",
];
Expand Down
35 changes: 33 additions & 2 deletions apps/desktop/renderer/src/goals/ProjectBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { useEngineLabel } from "../turns/engine-select";
import { TaskAcceptanceDrawer } from "./TaskAcceptanceDrawer";
import { ProjectTaskProgress } from "./ProjectTaskProgress";
import { useProjectTaskProgress } from "./useProjectTaskProgress";
import { taskDependenciesReady, taskParticipantError, taskDependencyGraphError } from "@roleweave/shared/task-collaboration";
import { TaskCollaborationEditor } from "./TaskCollaborationEditor";
import { TaskCollaborationDetails } from "./TaskCollaborationDetails";
import "./project-board.css";

export interface ProjectBoardProps {
Expand Down Expand Up @@ -188,6 +191,16 @@ export function ProjectBoard({
!!own(accepted, item.taskId) || !!own(detail.taskExecutions, item.taskId) ||
!!own(detail.taskDeliveries, item.taskId) || !!own(detail.taskDeliveryUnavailable, item.taskId) ||
!!detail.executionUnavailable || !!detail.goal.taskAcceptances?.some((record) => record.taskId === item.taskId);
const dependenciesReady = (item: GoalWorkItem) => {
if (!item.dependsOn?.length) return true;
const context = own(detail.taskCollaborations, item.taskId);
return !!context && taskDependenciesReady(context);
};
const relations = (item: GoalWorkItem) => {
const context = own(detail.taskCollaborations, item.taskId);
return context && (context.participants.length > 0 || context.dependencies.length > 0 || context.dependents.length > 0 || context.omittedDependentCount > 0)
? <TaskCollaborationDetails context={context} positionNames={positionNames} onOpenBoundSession={onOpenBoundSession} /> : null;
};
const openAcceptance = (item: GoalWorkItem) =>
setAcceptanceTask({ item: { ...item }, scopeKey });
const acceptanceItem = acceptanceTask?.scopeKey === scopeKey
Expand Down Expand Up @@ -286,8 +299,11 @@ export function ProjectBoard({
setEditor((current) =>
current ? { ...current, item: { ...current.item, ...fields } } : current,
);
const relationError = editor ? taskParticipantError(editor.item.participants ?? [], editor.item.assigneePositionId) ||
taskDependencyGraphError([...items.filter(item => item.taskId !== editor.item.taskId), editor.item]) : null;
const validEditor =
!!editor?.item.title.trim() &&
!relationError &&
validDate(editor.item.startDate) &&
validDate(editor.item.dueDate) &&
!(
Expand Down Expand Up @@ -316,6 +332,8 @@ export function ProjectBoard({
: {}),
...(draft.startDate ? { startDate: draft.startDate } : {}),
...(draft.dueDate ? { dueDate: draft.dueDate } : {}),
...(draft.participants !== undefined ? { participants: draft.participants.map(p => ({ ...p, responsibility: p.responsibility.trim() })) } : {}),
...(draft.dependsOn !== undefined ? { dependsOn: draft.dependsOn } : {}),
};
void save(
editor.isNew
Expand All @@ -331,6 +349,10 @@ export function ProjectBoard({
const deleteTask = async () => {
if (!editor || editor.isNew || busy) return;
const item = editor.item;
if (items.some(other => other.dependsOn?.includes(item.taskId))) {
setError(t("taskCollaboration.referencedDelete"));
return;
}
if (
!window.confirm(
t("project.deleteTaskConfirm", { title: item.title }),
Expand All @@ -357,6 +379,8 @@ export function ProjectBoard({
const isCurrent = () => alive.current && scopeRef.current === launchScope;
if (
!positionId ||
!workspaceKey ||
!dependenciesReady(item) ||
!engine ||
launchLocks.current.has(lockKey) ||
executionOf(item)?.status === "running" ||
Expand Down Expand Up @@ -394,14 +418,16 @@ export function ProjectBoard({
input: [item.title, item.description].filter(Boolean).join("\n\n"),
goalId: detail.goal.goalId,
branchId: item.taskId,
taskBriefing: { expectedWorkspacePath: workspaceKey, expectedUpdatedAt: detail.goal.updatedAt },
});
if (!isCurrent()) return;
if (
result.status !== 200 &&
result.status !== 201 &&
result.status !== 202
)
throw new Error(responseError(result.body, t("project.runError")));
throw new Error(result.body && typeof result.body === "object" && "code" in result.body && result.body.code === "goal_dependency_blocked"
? t("taskCollaboration.blocked") : result.status === 409 ? t("taskBriefing.launchConflict") : responseError(result.body, t("project.runError")));
setAccepted((current) => ({
...current,
[item.taskId]: {
Expand Down Expand Up @@ -552,14 +578,16 @@ export function ProjectBoard({
!!own(launching, item.taskId) ||
running ||
!engine ||
!!detail.executionUnavailable
!!detail.executionUnavailable || !workspaceKey || !dependenciesReady(item)
}
onClick={() => void launch(item)}
>
{t("project.run")}
</Button>
</div>
{acceptanceEntry(item)}
{relations(item)}
{!dependenciesReady(item) && <p className="owb-project-card__hint">{t("taskCollaboration.blocked")}</p>}
{item.assigneePositionId && !engine && (
<p className="owb-project-card__hint">
{t("project.configureEngine")}
Expand Down Expand Up @@ -862,6 +890,7 @@ export function ProjectBoard({
<small>{label}</small>
{execution(item)}
{acceptanceEntry(item)}
{relations(item)}
</div>
<div
className="owb-project-timeline__track"
Expand Down Expand Up @@ -1006,6 +1035,8 @@ export function ProjectBoard({
}
/>
</label>
<TaskCollaborationEditor item={editor.item} items={items} positionNames={positionNames} disabled={busy} onChange={patch} />
{relationError && <p role="alert">{t("taskCollaboration.invalid")}</p>}
<div className="owb-project-form__row">
<label>
<span>{t("project.taskStatus")}</span>
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/renderer/src/goals/TaskAcceptanceDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
GoalWorkItem,
} from "@roleweave/shared/goals";
import "./task-acceptance.css";
import { TaskBriefingDetails } from "../turns/TaskBriefingDetails";
import { TaskCollaborationDetails } from "./TaskCollaborationDetails";

interface TaskAcceptanceDrawerProps {
detail: GoalDetail;
Expand Down Expand Up @@ -82,6 +84,10 @@ export function TaskAcceptanceDrawer({
}, []);

const currentDelivery = own(detail.taskDeliveries, item.taskId);
const currentCollaboration = own(detail.taskCollaborations, item.taskId);
const usedCollaboration = draft.delivery?.taskBriefing?.payload.collaboration;
const collaborationChanged = currentCollaboration && usedCollaboration &&
JSON.stringify({ ...currentCollaboration, capturedAt: "" }) !== JSON.stringify({ ...usedCollaboration, capturedAt: "" });
const taskExists = !!detail.goal.workItems?.some((task) => task.taskId === item.taskId);
const stale = conflict || draft.version !== detail.goal.updatedAt ||
!taskExists ||
Expand Down Expand Up @@ -244,6 +250,11 @@ export function TaskAcceptanceDrawer({
<dt>{t("project.acceptance.turn")}</dt><dd>{draft.delivery.source.turnId}</dd>
</dl>
<pre aria-label={t("project.acceptance.output")}>{outputText(draft.delivery.output)}</pre>
{draft.delivery.taskBriefing ? <TaskBriefingDetails snapshot={draft.delivery.taskBriefing} currentPlanVersion={draft.version} />
: <p>{t("taskBriefing.unavailable")}</p>}
{collaborationChanged && <p role="status">{t("taskCollaboration.changed")}</p>}
{currentCollaboration && (!!usedCollaboration || currentCollaboration.dependencies.length > 0 || currentCollaboration.participants.length > 0 || currentCollaboration.dependents.length > 0) &&
<TaskCollaborationDetails context={currentCollaboration} title={t("taskCollaboration.current")} />}
</section> : <p role="status" className="owb-project-notice">{t(unavailableKey)}</p>}
{!saved && draft.delivery && <fieldset disabled={busy || stale} className="owb-task-acceptance__criteria">
<legend>{t("project.acceptance.criteria")}</legend>
Expand Down
49 changes: 49 additions & 0 deletions apps/desktop/renderer/src/goals/TaskCollaborationDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useState } from "react";
import { useOwbLocale, useT } from "@roleweave/ui";
import type { TaskCollaborationContext } from "@roleweave/shared/task-collaboration";
import "./task-collaboration.css";

export function TaskCollaborationView({ context, positionNames = {}, onOpenBoundSession }: {
context: TaskCollaborationContext; positionNames?: Record<string, string>;
onOpenBoundSession?: (positionId: string, sessionId?: string, turnId?: string) => void;
}) {
const t = useT(), locale = useOwbLocale();
const name = (id: string) => Object.hasOwn(positionNames, id) ? positionNames[id]! : id;
return <div className="owb-task-collaboration__body">
<p className="owb-task-collaboration__muted">{t("taskCollaboration.observedAt")} <time dateTime={context.capturedAt}>
{new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "medium" }).format(new Date(context.capturedAt))}</time></p>
<p>{t("taskCollaboration.fixedInput")}</p>
{context.redacted && <p>{t("taskCollaboration.redacted")}</p>}
<h4>{t("taskCollaboration.participants")}</h4>
{context.participants.length === 0 ? <p>{t("taskCollaboration.noParticipants")}</p> : <ul>{context.participants.map(p => <li key={p.positionId}>
<strong>{name(p.positionId)}</strong> · {t(`taskCollaboration.role.${p.role}`)}<p>{p.responsibility}</p>
</li>)}</ul>}
<h4>{t("taskCollaboration.dependencies")}</h4>
{context.dependencies.length === 0 ? <p>{t("taskCollaboration.independent")}</p> : <ul>{context.dependencies.map(d => <li key={d.taskId}>
<strong>{d.title}</strong>{d.assigneePositionId && <> · {name(d.assigneePositionId)}</>}
<p>{t(`project.status.${d.status}`)} · <span data-readiness={d.readiness}>{t(`taskCollaboration.readiness.${d.readiness}`)}</span>
{d.executionStatus && <> · {t(`project.execution.${d.executionStatus}`)}</>}</p>
{d.delivery && <div>
<p className="owb-task-collaboration__muted">{t("taskCollaboration.delivery")} <code>{d.delivery.turnId}</code></p>
<code className="owb-task-collaboration__digest">{d.delivery.outputDigest}</code>
<pre>{d.delivery.outputExcerpt || t("taskCollaboration.emptyOutput")}</pre>
{d.delivery.truncated && <p>{t("taskCollaboration.truncated")}</p>}
{onOpenBoundSession && <button type="button" onClick={() => onOpenBoundSession(d.delivery!.positionId, d.delivery!.sessionId, d.delivery!.turnId)}>{t("taskCollaboration.openSource")}</button>}
</div>}
</li>)}</ul>}
<h4>{t("taskCollaboration.dependents")}</h4>
{context.dependents.length === 0 ? <p>{t("taskCollaboration.noDependents")}</p> : <ul>{context.dependents.map(d => <li key={d.taskId}>
{d.title}{d.assigneePositionId && <> · {name(d.assigneePositionId)}</>}
</li>)}</ul>}
{context.omittedDependentCount > 0 && <p>{t("taskCollaboration.omitted", { count: context.omittedDependentCount })}</p>}
</div>;
}

export function TaskCollaborationDetails({ title, ...props }: Parameters<typeof TaskCollaborationView>[0] & { title?: string }) {
const t = useT();
const [open, setOpen] = useState(false);
return <details className="owb-task-collaboration" open={open} onToggle={event => setOpen(event.currentTarget.open)}>
<summary>{title ?? t("taskCollaboration.title")}</summary>
{open && <TaskCollaborationView {...props} />}
</details>;
}
48 changes: 48 additions & 0 deletions apps/desktop/renderer/src/goals/TaskCollaborationEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Button, Checkbox, Input } from "antd";
import { Select } from "@fullstack-ai-infra/ui";
import { useT } from "@roleweave/ui";
import type { GoalWorkItem } from "@roleweave/shared/goals";
import type { TaskParticipant } from "@roleweave/shared/task-collaboration";
import "./task-collaboration.css";

export function TaskCollaborationEditor({ item, items, positionNames, disabled, onChange }: {
item: GoalWorkItem; items: GoalWorkItem[]; positionNames: Record<string, string>; disabled: boolean;
onChange: (patch: Partial<GoalWorkItem>) => void;
}) {
const t = useT();
const participants = item.participants ?? [];
const dependencies = item.dependsOn ?? [];
const patchParticipant = (positionId: string, fields: Partial<TaskParticipant>) =>
onChange({ participants: participants.map(p => p.positionId === positionId ? { ...p, ...fields } : p) });
const available = Object.keys(positionNames).filter(id => id !== item.assigneePositionId && !participants.some(p => p.positionId === id));
return <section className="owb-task-collaboration-editor" aria-label={t("taskCollaboration.editor")}>
<h4>{t("taskCollaboration.participants")}</h4>
<p>{t("taskCollaboration.explicit")}</p>
{participants.map(p => {
const name = Object.hasOwn(positionNames, p.positionId) ? positionNames[p.positionId]! : p.positionId;
return <div key={p.positionId} className="owb-task-collaboration-editor__person">
<div><strong>{name}</strong><Button size="small" disabled={disabled} aria-label={t("taskCollaboration.remove", { name })}
onClick={() => onChange({ participants: participants.filter(person => person.positionId !== p.positionId) })}>{t("taskCollaboration.removeAction")}</Button></div>
<Select<TaskParticipant["role"]> aria-label={t("taskCollaboration.roleFor", { name })} value={p.role} disabled={disabled}
options={[{ value: "contributor", label: t("taskCollaboration.role.contributor") }, { value: "reviewer", label: t("taskCollaboration.role.reviewer") }]}
onChange={role => patchParticipant(p.positionId, { role })} />
<Input.TextArea rows={2} maxLength={1024} disabled={disabled} aria-label={t("taskCollaboration.responsibilityFor", { name })}
placeholder={t("taskCollaboration.responsibilityHint")} value={p.responsibility}
onChange={event => patchParticipant(p.positionId, { responsibility: event.target.value })} />
</div>;
})}
<Select<string> aria-label={t("taskCollaboration.add")} value="" disabled={disabled || participants.length >= 8 || available.length === 0}
options={[{ value: "", label: t("taskCollaboration.add") }, ...available.map(id => ({ value: id, label: positionNames[id]! }))]}
onChange={positionId => { if (positionId) onChange({ participants: [...participants, { positionId, role: "contributor", responsibility: "" }] }); }} />
<h4>{t("taskCollaboration.dependencies")}</h4>
<p>{t("taskCollaboration.dependencyHint")}</p>
<div className="owb-task-collaboration-editor__dependencies">
{items.filter(candidate => candidate.taskId !== item.taskId).map(candidate => <Checkbox key={candidate.taskId}
checked={dependencies.includes(candidate.taskId)} disabled={disabled || (!dependencies.includes(candidate.taskId) && dependencies.length >= 8)}
onChange={event => onChange({ dependsOn: event.target.checked ? [...dependencies, candidate.taskId] : dependencies.filter(id => id !== candidate.taskId) })}>
{candidate.title}
</Checkbox>)}
{items.every(candidate => candidate.taskId === item.taskId) && <p>{t("taskCollaboration.noCandidates")}</p>}
</div>
</section>;
}
Loading
Loading