mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 09:26:50 +08:00
MUL-6743 refactor(issues): remove the assignment handoff note (#7626)
* refactor(issues): remove the assignment handoff note (MUL-6743) Usage data puts the handoff note at 0.2% of assignments, so the box occupied the confirm dialog's centre for the other 99.8% and bought them nothing. Removing it takes the whole feature with it: the note input and its runtime version gate, the `handoff_note` write field on PATCH/batch issue updates, the task column write, and the daemon's opening-prompt + issue_context.md handoff sections. The confirm dialog itself stays — it still names who is about to run and still offers "Don't start yet" (suppress_run). The `*WithHandoff` enqueue methods only ever varied by that note, so they become `EnqueueTaskForIssueByActor` / `EnqueueTaskForSquadLeaderByActor` — the actor-carrying variants autopilot already called with an empty note. `preview-trigger` drops `handoff_supported`, which nothing read after the gate moved client-side. `agent_task_queue.handoff_note` is deliberately left in place: it holds historical rows and this is the expand half of an expand-contract pair. Nothing reads or writes it now, so a follow-up migration can drop it once no deployed binary selects it. Co-authored-by: multica-agent <[email protected]> * docs(issues): reshoot the assignment confirm dialog (MUL-6743) The asset still showed the handoff note box the surrounding prose no longer describes. Recaptured from the app at the same 600x450 frame; the confirm button now carries the send-chord keycaps it has shipped with since MUL-5694. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
Lambda
parent
571e61128d
commit
d276991c60
@@ -13,12 +13,11 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
1. タスクを開き、**担当者**をクリックします。
|
||||
2. エージェントまたはスクワッドを選択します。
|
||||
3. 確認ダイアログで、これから開始するエージェントを確認します。
|
||||
4. 必要に応じて引き継ぎメモを記入し、**開始**を選択します。
|
||||
3. 確認ダイアログで、これから開始するエージェントを確認し、**開始**を選択します。
|
||||
|
||||

|
||||

|
||||
|
||||
引き継ぎメモは今回の実行にのみ適用されます。範囲、順序、今回とくに注目してほしい点の補足に使ってください。長期的に有効な要件は、タスク本文、プロジェクトの説明、またはエージェントの指示に書きます。
|
||||
エージェントに伝えたいことは、タスク本文とその議論、あるいはプロジェクトの説明やエージェントの指示に書きます。
|
||||
|
||||
スクワッドに割り当てると、タスクの担当者はスクワッドになり、最初に実行を開始するのはスクワッドのリーダーです。リーダーはスクワッドの運用方針に従って、他のメンバーに作業を渡すかどうかを判断します。
|
||||
|
||||
@@ -43,7 +42,7 @@ Multica はエージェントのために[作業](/tasks)を作成します。
|
||||
|
||||
`backlog` のタスクも、割り当てだけでは実行を開始しません。タスクを `backlog` から移動するか、コメントで担当者に依頼を続けたときに、はじめて後続の実行が生まれます。
|
||||
|
||||
すでにエージェントまたはスカッドの担当者がいる作業を `backlog` から移動すると、同じ確認ダイアログが開きます。実行を開始するのはこのステータス変更そのものなので、同じ引き継ぎメモを書けますし、**まだ開始しない**も同じように使えます(ステータスだけ変えて、エージェントは起こしません)。この確認ダイアログは、`backlog`・`done`・`cancelled` 以外のカテゴリへ移動するときに表示されます。
|
||||
すでにエージェントまたはスカッドの担当者がいる作業を `backlog` から移動すると、同じ確認ダイアログが開きます。実行を開始するのはこのステータス変更そのものなので、**まだ開始しない**も同じように使えます(ステータスだけ変えて、エージェントは起こしません)。この確認ダイアログは、`backlog`・`done`・`cancelled` 以外のカテゴリへ移動するときに表示されます。
|
||||
|
||||
この挙動は `backlog` カテゴリだけで、そのカテゴリの[カスタムステータス](/issues#カスタムステータス)もまったく同じように振る舞います。すでに `done` や `cancelled` にあるタスクは、エージェントに割り当て・再割り当てすると、すぐに実行が開始されます。
|
||||
|
||||
|
||||
@@ -13,12 +13,11 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
1. 태스크를 열고 **담당자**를 클릭합니다.
|
||||
2. 에이전트 또는 스쿼드를 선택합니다.
|
||||
3. 확인 창에서 시작될 에이전트를 확인합니다.
|
||||
4. 필요하면 인계 설명을 입력한 뒤 **시작**을 선택합니다.
|
||||
3. 확인 창에서 시작될 에이전트를 확인한 뒤 **시작**을 선택합니다.
|
||||
|
||||

|
||||

|
||||
|
||||
인계 설명은 이번 실행에만 사용됩니다. 범위, 순서, 이번에 특히 주의할 점을 보충하기에 적합합니다. 장기적으로 적용할 요구 사항은 태스크, 프로젝트 설명, 에이전트 지시문에 작성하세요.
|
||||
에이전트가 알아야 할 내용은 태스크 본문과 그 논의, 또는 프로젝트 설명과 에이전트 지시문에 작성하세요.
|
||||
|
||||
스쿼드에 할당하면 태스크 담당자는 스쿼드가 되고 스쿼드 리더가 먼저 실행을 시작합니다. 리더는 스쿼드의 작업 방식에 따라 다른 멤버에게 넘길지 판단합니다.
|
||||
|
||||
@@ -43,7 +42,7 @@ Multica가 에이전트의 [작업](/tasks)를 만듭니다. 온라인 런타임
|
||||
|
||||
`backlog` 상태에서도 에이전트를 할당하는 것만으로 실행이 시작되지 않습니다. 태스크를 `backlog`에서 다른 상태로 옮기거나 댓글에서 담당자에게 추가 요청을 해야 이후 실행이 생성됩니다.
|
||||
|
||||
이미 에이전트나 스쿼드 담당자가 있는 작업을 `backlog` 밖으로 옮기면 같은 확인 대화상자가 열립니다. 실행을 시작하는 것이 바로 이 상태 변경이므로 동일한 인계 메모를 남길 수 있고, **아직 시작 안 함**도 그대로 사용할 수 있습니다(상태만 옮기고 에이전트는 깨우지 않습니다). 이 대화상자는 `backlog`·`done`·`cancelled` 이외 카테고리로 옮길 때 나타납니다.
|
||||
이미 에이전트나 스쿼드 담당자가 있는 작업을 `backlog` 밖으로 옮기면 같은 확인 대화상자가 열립니다. 실행을 시작하는 것이 바로 이 상태 변경이므로 **아직 시작 안 함**도 그대로 사용할 수 있습니다(상태만 옮기고 에이전트는 깨우지 않습니다). 이 대화상자는 `backlog`·`done`·`cancelled` 이외 카테고리로 옮길 때 나타납니다.
|
||||
|
||||
이 효과는 `backlog` 카테고리에만 적용되며, 그 카테고리의 [사용자 지정 상태](/issues#사용자-지정-상태)도 똑같이 동작합니다. 이미 `done` 또는 `cancelled` 상태인 태스크도 에이전트에게 할당하거나 다시 할당하면 즉시 실행이 시작됩니다.
|
||||
|
||||
|
||||
@@ -13,12 +13,11 @@ When you only need the agent to handle one specific request without changing own
|
||||
|
||||
1. Open an issue and click **Assignee**.
|
||||
2. Pick an agent or a squad.
|
||||
3. Review the agent about to start in the confirmation dialog.
|
||||
4. Add a handoff note if needed, then choose **Start**.
|
||||
3. Review the agent about to start in the confirmation dialog, then choose **Start**.
|
||||
|
||||

|
||||

|
||||
|
||||
The handoff note applies to this run only — use it for scope, ordering, or what to focus on this time. Requirements that should hold long term belong in the issue, the project description, or the agent instructions.
|
||||
Everything the agent should know about this work belongs in the issue itself — its description, its discussion, or the project description and agent instructions.
|
||||
|
||||
When you assign to a squad, the squad becomes the issue's assignee and the squad leader starts first. It decides whether to hand work to other members, following how the squad operates.
|
||||
|
||||
@@ -47,7 +46,7 @@ When the assignee is a [squad](/squads), the same rule applies: Multica enqueues
|
||||
|
||||
`backlog` doesn't start a run on assignment either. Execution only follows after you move the issue out of `backlog`, or ask the assignee for something in a follow-up comment.
|
||||
|
||||
When the issue already has an agent or squad owner, moving it out of `backlog` opens the same dialog: that status change is what starts the run, so it offers the same handoff note and the same **Don't start yet**, which moves the issue without waking the agent. The dialog appears for every target outside the `backlog`, `done`, and `cancelled` categories.
|
||||
When the issue already has an agent or squad owner, moving it out of `backlog` opens the same dialog: that status change is what starts the run, so it offers the same **Don't start yet**, which moves the issue without waking the agent. The dialog appears for every target outside the `backlog`, `done`, and `cancelled` categories.
|
||||
|
||||
Only the `backlog` category works this way, and a [custom status](/issues#custom-statuses) in that category behaves identically. Issues already in `done` or `cancelled` still start a run immediately when assigned or reassigned to an agent.
|
||||
|
||||
|
||||
@@ -13,12 +13,11 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
1. 打开一条任务,点击**负责人**。
|
||||
2. 选择一个智能体或小队。
|
||||
3. 在确认窗口中检查即将启动的智能体。
|
||||
4. 如有必要,填写一段交接说明,然后选择**开始**。
|
||||
3. 在确认窗口中检查即将启动的智能体,然后选择**开始**。
|
||||
|
||||

|
||||

|
||||
|
||||
交接说明只用于这一次执行,适合补充范围、顺序或本次的注意重点。长期有效的要求写进任务、项目说明或智能体指令中。
|
||||
这次执行需要智能体知道的信息,都写在任务本身——它的说明、讨论,或者项目说明与智能体指令中。
|
||||
|
||||
分配给小队时,任务的负责人是小队,首先开始执行的是小队 leader。它会按照小队的工作方式判断是否需要交给其他成员。
|
||||
|
||||
@@ -47,7 +46,7 @@ Multica 会为智能体创建一条[Task](/tasks)。在线运行时领取 task
|
||||
|
||||
`backlog` 也不会因为分配智能体而启动执行。把任务移出 `backlog`,或者在评论中继续向负责人提出要求后,才会产生后续执行。
|
||||
|
||||
如果任务已经有智能体或小队负责人,把它移出 `backlog` 会弹出同一个确认窗:真正启动执行的正是这次状态变更,所以同样可以填写交接说明,也同样有**暂不开始**——只改状态,不叫醒智能体。目标状态只要不属于 `backlog`、`done`、`cancelled` 分类,都会弹这个确认窗。
|
||||
如果任务已经有智能体或小队负责人,把它移出 `backlog` 会弹出同一个确认窗:真正启动执行的正是这次状态变更,所以同样有**暂不开始**——只改状态,不叫醒智能体。目标状态只要不属于 `backlog`、`done`、`cancelled` 分类,都会弹这个确认窗。
|
||||
|
||||
只有 `backlog` 分类有这个效果,该分类下的[自定义状态](/issues#自定义状态)表现完全一致。已经处于 `done` 或 `cancelled` 的任务,分配或重新分配给智能体时同样会立即开始执行。
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 7.5 KiB |
@@ -478,14 +478,14 @@ describe("IssueTriggerPreviewSchema", () => {
|
||||
it("parses a well-formed response", () => {
|
||||
const parsed = IssueTriggerPreviewSchema.parse({
|
||||
triggers: [
|
||||
{ issue_id: "i1", agent_id: "a1", source: "assign", handoff_supported: true },
|
||||
{ issue_id: "i2", agent_id: "a2", source: "status", handoff_supported: false },
|
||||
{ issue_id: "i1", agent_id: "a1", source: "assign" },
|
||||
{ issue_id: "i2", agent_id: "a2", source: "status" },
|
||||
],
|
||||
total_count: 2,
|
||||
});
|
||||
expect(parsed.total_count).toBe(2);
|
||||
expect(parsed.triggers).toHaveLength(2);
|
||||
expect(parsed.triggers[0]).toMatchObject({ issue_id: "i1", agent_id: "a1", source: "assign", handoff_supported: true });
|
||||
expect(parsed.triggers[0]).toMatchObject({ issue_id: "i1", agent_id: "a1", source: "assign" });
|
||||
});
|
||||
|
||||
it("defaults missing top-level fields (empty / older backend)", () => {
|
||||
@@ -500,7 +500,6 @@ describe("IssueTriggerPreviewSchema", () => {
|
||||
issue_id: "i1",
|
||||
agent_id: "",
|
||||
source: "",
|
||||
handoff_supported: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1073,7 +1073,6 @@ const IssueTriggerPreviewItemSchema = z.object({
|
||||
issue_id: z.string(),
|
||||
agent_id: z.string().default(""),
|
||||
source: z.string().default(""),
|
||||
handoff_supported: z.boolean().default(false),
|
||||
}).loose();
|
||||
|
||||
export const IssueTriggerPreviewSchema = z.object({
|
||||
@@ -1767,7 +1766,6 @@ export const AgentTaskSchema = z.object({
|
||||
coalesced_comment_ids: OptionalStringArraySchema,
|
||||
delivered_comment_ids: OptionalStringArraySchema,
|
||||
trigger_summary: z.string().optional(),
|
||||
handoff_note: z.string().optional(),
|
||||
kind: z.string().optional(),
|
||||
work_dir: z.string().optional().catch(undefined),
|
||||
relative_work_dir: z.string().optional().catch(undefined),
|
||||
|
||||
@@ -132,15 +132,14 @@ export function useUpdateIssue() {
|
||||
return api.moveIssue(id, { ...target, ...moveIntent });
|
||||
},
|
||||
onMutate: ({ id, move_intent: _moveIntent, ...data }) => {
|
||||
// suppress_run / handoff_note are write-time control fields, not Issue
|
||||
// columns. description_base is merge metadata, while description itself
|
||||
// suppress_run is a write-time control field, not an Issue column.
|
||||
// description_base is merge metadata, while description itself
|
||||
// is resolved against that base on the server and therefore is not safe
|
||||
// to predict optimistically. Keep the authoritative raw description in
|
||||
// cache so hidden channel-media markers remain available as the base for
|
||||
// a rapid follow-up edit. mutationFn still sends the full payload.
|
||||
const {
|
||||
suppress_run: _suppressRun,
|
||||
handoff_note: _handoffNote,
|
||||
description: _description,
|
||||
description_base: _descriptionBase,
|
||||
title_base: _titleBase,
|
||||
@@ -251,7 +250,6 @@ export function useUpdateIssue() {
|
||||
// is the plain surgical patch it always was.
|
||||
const {
|
||||
suppress_run: _suppressRun,
|
||||
handoff_note: _handoffNote,
|
||||
description_base: _descriptionBase,
|
||||
move_intent: _moveIntent,
|
||||
id: _id,
|
||||
@@ -445,7 +443,6 @@ export function useBatchUpdateIssues() {
|
||||
// until a refetch returns the committed result.
|
||||
const {
|
||||
suppress_run: _suppressRun,
|
||||
handoff_note: _handoffNote,
|
||||
description: _description,
|
||||
description_base: _descriptionBase,
|
||||
...patch
|
||||
|
||||
@@ -3,9 +3,7 @@ import {
|
||||
chatProjectContextSupported,
|
||||
checkQuickCreateCliVersion,
|
||||
checkQuickCreateFieldsCliVersion,
|
||||
handoffSupported,
|
||||
MIN_CHAT_PROJECT_CONTEXT_CLI_VERSION,
|
||||
MIN_HANDOFF_CLI_VERSION,
|
||||
runtimeAdvertisesLocalWorktree,
|
||||
} from "./cli-version";
|
||||
|
||||
@@ -41,33 +39,6 @@ describe("checkQuickCreateFieldsCliVersion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Mirrors server/pkg/agent/handoff_version_test.go so the frontend soft-gate
|
||||
// signal and the server's authoritative one agree by construction.
|
||||
describe("handoffSupported", () => {
|
||||
it("supports a tagged release at or above the minimum", () => {
|
||||
expect(handoffSupported(MIN_HANDOFF_CLI_VERSION)).toBe(true);
|
||||
expect(handoffSupported("0.4.0")).toBe(true);
|
||||
expect(handoffSupported("v0.3.28")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not support a tagged release below the minimum", () => {
|
||||
expect(handoffSupported("0.3.26")).toBe(false);
|
||||
expect(handoffSupported("0.2.21")).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed on empty or unparsable input", () => {
|
||||
expect(handoffSupported("")).toBe(false);
|
||||
expect(handoffSupported(undefined)).toBe(false);
|
||||
expect(handoffSupported(null)).toBe(false);
|
||||
expect(handoffSupported("garbage")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats git-describe dev builds as supported regardless of base tag", () => {
|
||||
expect(handoffSupported("v0.3.0-5-gabc1234")).toBe(true);
|
||||
expect(handoffSupported("v0.1.0-235-gdaf0e935-dirty")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chatProjectContextSupported", () => {
|
||||
it("supports a tagged release at or above the minimum", () => {
|
||||
expect(chatProjectContextSupported(MIN_CHAT_PROJECT_CONTEXT_CLI_VERSION)).toBe(true);
|
||||
|
||||
@@ -88,31 +88,6 @@ export function readRuntimeCliVersion(metadata: Record<string, unknown> | undefi
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend mirror of the server's `MinHandoffCLIVersion` soft gate
|
||||
* (`server/pkg/agent/version.go`). The assignment handoff note is only rendered
|
||||
* into the run's opening prompt by daemons at or above this multica CLI version
|
||||
* (MUL-3375); older daemons silently drop it. Unlike the quick-create gate this
|
||||
* never blocks the assignment — the UI just grays out the note box and warns.
|
||||
*
|
||||
* Keep in lockstep with the server constant; the two are enforced independently
|
||||
* (the server is authoritative) but must agree so the warning matches reality.
|
||||
*/
|
||||
export const MIN_HANDOFF_CLI_VERSION = "0.3.28";
|
||||
|
||||
/**
|
||||
* Whether a daemon-reported CLI version is new enough to render a handoff note.
|
||||
* Mirrors server `agent.HandoffSupported`: missing / unparsable / below-minimum
|
||||
* all degrade to `false`, and dev-built daemons (git-describe shape) always
|
||||
* pass — the version string is the shared signal, so frontend and server agree
|
||||
* by construction. Pure and synchronous, so the note box can settle from the
|
||||
* already-warm runtime cache instead of waiting on the trigger-preview
|
||||
* round-trip, exactly like the quick-create version gate.
|
||||
*/
|
||||
export function handoffSupported(detected: string | undefined | null): boolean {
|
||||
return meetsMinCliVersion(detected, MIN_HANDOFF_CLI_VERSION);
|
||||
}
|
||||
|
||||
/**
|
||||
* First release whose daemon renders the chat session's project context
|
||||
* (description + resources) into the run brief (PR #5765, ships in v0.4.10).
|
||||
@@ -121,16 +96,15 @@ export function handoffSupported(detected: string | undefined | null): boolean {
|
||||
* the Project Context section, so the durable description never reaches the
|
||||
* agent. SOFT gate: selecting a project always works; the UI only warns.
|
||||
*
|
||||
* Frontend-only constant: unlike handoff there is no server preview endpoint
|
||||
* computing this, so there is no server twin to keep in lockstep with.
|
||||
* Frontend-only constant: there is no server preview endpoint computing this,
|
||||
* so there is no server twin to keep in lockstep with.
|
||||
*/
|
||||
export const MIN_CHAT_PROJECT_CONTEXT_CLI_VERSION = "0.4.10";
|
||||
|
||||
/**
|
||||
* Whether a daemon-reported CLI version is new enough to inject a chat
|
||||
* session's project description into the run brief. Same degrade rules as
|
||||
* `handoffSupported`: missing / unparsable / below-minimum are `false`,
|
||||
* dev-built daemons (git-describe shape) always pass.
|
||||
* session's project description into the run brief. Missing / unparsable /
|
||||
* below-minimum are `false`; dev-built daemons (git-describe shape) always pass.
|
||||
*/
|
||||
export function chatProjectContextSupported(detected: string | undefined | null): boolean {
|
||||
return meetsMinCliVersion(detected, MIN_CHAT_PROJECT_CONTEXT_CLI_VERSION);
|
||||
|
||||
@@ -347,13 +347,6 @@ export interface AgentTask {
|
||||
* or deleted.
|
||||
*/
|
||||
trigger_summary?: string;
|
||||
/**
|
||||
* Handoff instruction the assigner attached when starting this run (MUL-3375).
|
||||
* Present only on assignment-triggered runs that carried a note; the execution
|
||||
* log shows it inline as the trigger reason. Absent (legacy / no note) falls
|
||||
* back to the generic "initial run" label.
|
||||
*/
|
||||
handoff_note?: string;
|
||||
/**
|
||||
* Server-computed source discriminator used by the activity row to label
|
||||
* tasks that have no linked issue (so e.g. quick-create tasks render
|
||||
|
||||
@@ -77,10 +77,6 @@ export interface UpdateIssueRequest {
|
||||
* MUL-3375). The assignee/status change still applies. Control field —
|
||||
* strip from optimistic cache patches; never written onto the Issue. */
|
||||
suppress_run?: boolean;
|
||||
/** Free-text handoff instruction injected into the started run's opening
|
||||
* context (MUL-3375). Only consumed when a run actually starts. Control
|
||||
* field — strip from optimistic cache patches. */
|
||||
handoff_note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,14 +108,11 @@ export interface IssueTriggerPreviewParams {
|
||||
}
|
||||
|
||||
/** One issue that WILL start a run under the prospective write. `agent_id` is
|
||||
* the runnable agent (squad leader for squads). `handoff_supported` is the
|
||||
* soft-gate signal: false when the target runtime is too old to render a
|
||||
* handoff note (gray the note box; the assignment still works). */
|
||||
* the runnable agent (squad leader for squads). */
|
||||
export interface IssueTriggerPreviewItem {
|
||||
issue_id: string;
|
||||
agent_id: string;
|
||||
source: string;
|
||||
handoff_supported: boolean;
|
||||
}
|
||||
|
||||
export interface IssueTriggerPreview {
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
* project description into the run brief. `null` means "cannot tell" (no
|
||||
* agent, no bound runtime, or the runtime row not in cache yet) and must NOT
|
||||
* warn: this is a soft gate, and a spurious warning is worse than a
|
||||
* description an old daemon drops — same policy as the handoff note gate in
|
||||
* run-confirm.
|
||||
* description an old daemon drops.
|
||||
*
|
||||
* Shared by both send chains (chat tab controller and floating ChatWindow) so
|
||||
* the resolution rule cannot drift between the two surfaces.
|
||||
|
||||
@@ -801,7 +801,7 @@ export function AgentTranscriptDialog({
|
||||
? t(($) => $.transcript.trigger_chat)
|
||||
: task.kind === "quick_create"
|
||||
? t(($) => $.transcript.trigger_quick_create)
|
||||
: task.kind === "direct" || task.handoff_note
|
||||
: task.kind === "direct"
|
||||
? t(($) => $.transcript.trigger_direct)
|
||||
: t(($) => $.transcript.trigger_initial);
|
||||
|
||||
|
||||
@@ -42,12 +42,6 @@ export function useTriggerText(task: AgentTask): string {
|
||||
}
|
||||
if (task.autopilot_run_id) return t(($) => $.execution_log.trigger_autopilot);
|
||||
if (task.trigger_comment_id) return t(($) => $.execution_log.trigger_comment);
|
||||
// Assignment-triggered run that carried a handoff note: show the note inline
|
||||
// (truncated by the caller) the way comment triggers show their text, so the
|
||||
// row reads as the handoff instead of the generic "initial run".
|
||||
if (task.handoff_note) {
|
||||
return retryPrefix + t(($) => $.execution_log.trigger_handoff_prefix) + stripMentionMarkdown(task.handoff_note);
|
||||
}
|
||||
return t(($) => $.execution_log.trigger_initial);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,6 @@ export interface UseIssueTriggerPreviewResult {
|
||||
triggers: IssueTriggerPreviewItem[];
|
||||
totalCount: number;
|
||||
isLoading: boolean;
|
||||
/** True when every trigger's target runtime can render a handoff note, so
|
||||
* the note box is safe to enable. False if any started run would drop it. */
|
||||
handoffSupported: boolean;
|
||||
}
|
||||
|
||||
const EMPTY: IssueTriggerPreviewItem[] = [];
|
||||
@@ -90,6 +87,5 @@ export function useIssueTriggerPreview(
|
||||
// Only the first load (no prior data) is "loading"; a background/placeholder
|
||||
// refetch is not, so reveal animations gated on this never collapse mid-fetch.
|
||||
isLoading: enabled && previewQuery.isLoading,
|
||||
handoffSupported: triggers.length > 0 && triggers.every((t) => t.handoff_supported === true),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -612,7 +612,6 @@
|
||||
"trigger_autopilot": "Autopilot run",
|
||||
"trigger_comment": "Comment trigger",
|
||||
"trigger_initial": "Initial run",
|
||||
"trigger_handoff_prefix": "Handoff: ",
|
||||
"included_comments_one": "Includes {{count}} comment",
|
||||
"included_comments_other": "Includes {{count}} comments",
|
||||
"status_queued": "Queued",
|
||||
|
||||
@@ -56,9 +56,6 @@
|
||||
"assign_batch": "Assign {{count}} issues to {{name}}. They'll start right away if the run conditions are met.",
|
||||
"title_promote": "Start work now?",
|
||||
"promote_single": "Move this issue to {{status}}. {{name}} will start right away if the run conditions are met.",
|
||||
"note_label": "Handoff note (optional)",
|
||||
"note_placeholder": "Tell them what matters — scope, focus, or what to avoid…",
|
||||
"note_unsupported": "Their runtime is too old to receive this handoff note; it may not be read.",
|
||||
"confirm_assign": "Confirm assignment",
|
||||
"confirm_promote": "Move and start",
|
||||
"dont_start": "Don't start yet",
|
||||
|
||||
@@ -589,7 +589,6 @@
|
||||
"trigger_autopilot": "オートパイロット実行",
|
||||
"trigger_comment": "コメントトリガー",
|
||||
"trigger_initial": "初回実行",
|
||||
"trigger_handoff_prefix": "引き継ぎ:",
|
||||
"included_comments_other": "{{count}} 件のコメントを含む",
|
||||
"status_queued": "待機中",
|
||||
"status_dispatched": "開始中",
|
||||
|
||||
@@ -56,9 +56,6 @@
|
||||
"assign_batch": "{{count}} 件のタスクを {{name}} に割り当てます。実行条件を満たす場合はすぐに作業を開始します。",
|
||||
"title_promote": "今すぐ作業を開始しますか?",
|
||||
"promote_single": "このタスクを {{status}} に移します。実行条件を満たす場合は {{name}} がすぐに作業を開始します。",
|
||||
"note_label": "引き継ぎメモ(任意)",
|
||||
"note_placeholder": "範囲・重点・触れてほしくない点などを一言で…",
|
||||
"note_unsupported": "相手の実行環境が古いため、この引き継ぎメモは読み取れない可能性があります。",
|
||||
"confirm_assign": "割り当てを確認",
|
||||
"confirm_promote": "移動して開始",
|
||||
"dont_start": "まだ開始しない",
|
||||
|
||||
@@ -589,7 +589,6 @@
|
||||
"trigger_autopilot": "오토파일럿 실행",
|
||||
"trigger_comment": "댓글 트리거",
|
||||
"trigger_initial": "초기 실행",
|
||||
"trigger_handoff_prefix": "인계: ",
|
||||
"included_comments_other": "댓글 {{count}}개 포함",
|
||||
"status_queued": "대기 중",
|
||||
"status_dispatched": "시작 중",
|
||||
|
||||
@@ -56,9 +56,6 @@
|
||||
"assign_batch": "{{count}}개 태스크를 {{name}}에게 할당합니다. 실행 조건을 충족하면 바로 시작합니다.",
|
||||
"title_promote": "지금 작업을 시작할까요?",
|
||||
"promote_single": "이 태스크를 {{status}}(으)로 옮깁니다. 실행 조건을 충족하면 {{name}}이(가) 바로 시작합니다.",
|
||||
"note_label": "인계 메모 (선택)",
|
||||
"note_placeholder": "범위·핵심·건드리지 말 것 등을 한마디로…",
|
||||
"note_unsupported": "상대 런타임이 오래되어 이 인계 메모를 읽지 못할 수 있습니다.",
|
||||
"confirm_assign": "할당 확인",
|
||||
"confirm_promote": "옮기고 시작",
|
||||
"dont_start": "아직 시작 안 함",
|
||||
|
||||
@@ -589,7 +589,6 @@
|
||||
"trigger_autopilot": "自动化运行",
|
||||
"trigger_comment": "评论触发",
|
||||
"trigger_initial": "首次运行",
|
||||
"trigger_handoff_prefix": "交接:",
|
||||
"included_comments_other": "包含 {{count}} 条评论",
|
||||
"status_queued": "排队中",
|
||||
"status_dispatched": "启动中",
|
||||
|
||||
@@ -56,9 +56,6 @@
|
||||
"assign_batch": "将 {{count}} 个任务指派给 {{name}};符合运行条件时会立即开始处理。",
|
||||
"title_promote": "现在开始处理?",
|
||||
"promote_single": "把这个任务移到 {{status}};符合运行条件时 {{name}} 会立即开始处理。",
|
||||
"note_label": "交接说明(可选)",
|
||||
"note_placeholder": "交代一句——范围、重点、或别碰什么……",
|
||||
"note_unsupported": "对方的运行环境版本较旧,这条交接说明可能读不到。",
|
||||
"confirm_assign": "确认指派",
|
||||
"confirm_promote": "移动并开始",
|
||||
"dont_start": "暂不开始",
|
||||
|
||||
@@ -227,7 +227,6 @@ vi.mock("../issues/hooks/use-issue-trigger-preview", () => ({
|
||||
triggers: [],
|
||||
totalCount: 0,
|
||||
isLoading: false,
|
||||
handoffSupported: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -8,26 +8,6 @@ import {
|
||||
} from "@multica/core/shortcuts";
|
||||
import { RunConfirmModal } from "./run-confirm";
|
||||
|
||||
// --- Warm agent / squad / runtime caches (prefetched in the real app) --------
|
||||
// The modal resolves the target runtime's cli_version locally — an agent's own
|
||||
// runtime, or a squad leader's — so nothing in the dialog waits on the network.
|
||||
// Tests drive the verdict by swapping the runtime's reported cli_version here.
|
||||
const cache = {
|
||||
agents: [{ id: "agent-1", runtime_id: "runtime-1" }] as Array<{ id: string; runtime_id: string }>,
|
||||
runtimes: [{ id: "runtime-1", metadata: { cli_version: "0.4.0" } }] as Array<{
|
||||
id: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}>,
|
||||
squads: [{ id: "squad-1", leader_id: "agent-1" }] as Array<{ id: string; leader_id: string }>,
|
||||
};
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: string[] }) => {
|
||||
if (queryKey[0] === "runtimes") return { data: cache.runtimes };
|
||||
if (queryKey[0] === "workspaces" && queryKey[2] === "agents") return { data: cache.agents };
|
||||
if (queryKey[0] === "workspaces" && queryKey[2] === "squads") return { data: cache.squads };
|
||||
return { data: [] };
|
||||
},
|
||||
}));
|
||||
vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws-test" }));
|
||||
vi.mock("@multica/core/issue-statuses/hooks", () => ({
|
||||
useIssueStatuses: () =>
|
||||
@@ -48,26 +28,6 @@ vi.mock("@multica/core/issue-statuses/hooks", () => ({
|
||||
},
|
||||
]),
|
||||
}));
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
agentListOptions: (wsId: string) => ({ queryKey: ["workspaces", wsId, "agents"] }),
|
||||
squadListOptions: (wsId: string) => ({ queryKey: ["workspaces", wsId, "squads"] }),
|
||||
}));
|
||||
// Stub the runtimes barrel: the query-options builder would otherwise drag the
|
||||
// network layer in, and the deep cli-version module isn't an exported subpath.
|
||||
// `handoffSupported`'s real semver/dev-build logic is exhaustively covered in
|
||||
// packages/core/runtimes/cli-version.test.ts; here we only need a faithful
|
||||
// stand-in for the >= 0.3.28 threshold so the cache → version → verdict wiring
|
||||
// is exercised end to end.
|
||||
vi.mock("@multica/core/runtimes", () => ({
|
||||
runtimeListOptions: (wsId: string) => ({ queryKey: ["runtimes", wsId, "list"] }),
|
||||
readRuntimeCliVersion: (m?: { cli_version?: unknown }) =>
|
||||
typeof m?.cli_version === "string" ? m.cli_version : "",
|
||||
handoffSupported: (v?: string | null) => {
|
||||
const m = /(\d+)\.(\d+)\.(\d+)/.exec((v ?? "").trim());
|
||||
if (!m) return false;
|
||||
return Number(m[1]) * 1e6 + Number(m[2]) * 1e3 + Number(m[3]) >= 3028; // 0.3.28
|
||||
},
|
||||
}));
|
||||
|
||||
const mockUpdate = vi.fn().mockResolvedValue({ id: "issue-1" });
|
||||
const mockBatch = vi.fn().mockResolvedValue({ updated: 2 });
|
||||
@@ -94,9 +54,6 @@ vi.mock("../i18n", () => ({
|
||||
title_assign: "Confirm assignment?",
|
||||
assign_single: "assign to {{name}}",
|
||||
assign_batch: "assign {{count}} to {{name}}",
|
||||
note_label: "Handoff note",
|
||||
note_placeholder: "scope...",
|
||||
note_unsupported: "runtime too old",
|
||||
confirm_assign: "Confirm assignment",
|
||||
dont_start: "Don't start yet",
|
||||
toast_failed: "failed",
|
||||
@@ -130,9 +87,6 @@ vi.mock("@multica/ui/components/ui/button", () => ({
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@multica/ui/components/ui/textarea", () => ({
|
||||
Textarea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
|
||||
}));
|
||||
vi.mock("@multica/ui/components/ui/spinner", () => ({
|
||||
Spinner: () => <span data-testid="spinner" />,
|
||||
}));
|
||||
@@ -146,9 +100,6 @@ beforeEach(() => {
|
||||
mockBatch.mockClear().mockResolvedValue({ updated: 2 });
|
||||
mockToast.error.mockClear();
|
||||
mockToast.success.mockClear();
|
||||
cache.agents = [{ id: "agent-1", runtime_id: "runtime-1" }];
|
||||
cache.runtimes = [{ id: "runtime-1", metadata: { cli_version: "0.4.0" } }];
|
||||
cache.squads = [{ id: "squad-1", leader_id: "agent-1" }];
|
||||
// The real shortcut store drives both the submit chord and the keycap hint,
|
||||
// and jsdom's platform follows the host OS — pin it so the chord is ⌘+Enter
|
||||
// everywhere, not Ctrl+Enter on a Linux CI runner.
|
||||
@@ -162,7 +113,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
const confirmButton = () => screen.getByRole("button", { name: "Confirm assignment" });
|
||||
const noteBox = () => screen.getByPlaceholderText("scope...");
|
||||
const dialog = () => screen.getByTestId("dialog-content");
|
||||
|
||||
const single = {
|
||||
issueIds: ["issue-1"],
|
||||
@@ -187,22 +138,19 @@ describe("RunConfirmModal", () => {
|
||||
// The MUL-5010 core: opening the dialog fires nothing and blocks nothing.
|
||||
const { container } = render(<RunConfirmModal onClose={vi.fn()} data={single} />);
|
||||
expect(screen.queryByTestId("spinner")).not.toBeInTheDocument();
|
||||
expect(noteBox()).not.toBeDisabled();
|
||||
expect(confirmButton()).not.toBeDisabled();
|
||||
// Headline reads across elements — the assignee name is bolded in place.
|
||||
expect(container.textContent).toContain("assign to Walt");
|
||||
});
|
||||
|
||||
it("single assign sends the assignee change with the handoff note", async () => {
|
||||
it("single assign sends the assignee change and nothing else", async () => {
|
||||
render(<RunConfirmModal onClose={vi.fn()} data={single} />);
|
||||
fireEvent.change(noteBox(), { target: { value: "only login" } });
|
||||
fireEvent.click(confirmButton());
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(mockUpdate).toHaveBeenCalledWith({
|
||||
id: "issue-1",
|
||||
assignee_type: "agent",
|
||||
assignee_id: "agent-1",
|
||||
handoff_note: "only login",
|
||||
});
|
||||
expect(mockBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -218,35 +166,28 @@ describe("RunConfirmModal", () => {
|
||||
expect(mockToast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("'暂不开始' sends suppress_run and no handoff note", async () => {
|
||||
it("'暂不开始' sends suppress_run alongside the assignee change", async () => {
|
||||
render(<RunConfirmModal onClose={vi.fn()} data={single} />);
|
||||
fireEvent.change(noteBox(), { target: { value: "ignored" } });
|
||||
fireEvent.click(screen.getByText("Don't start yet"));
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
const payload = mockUpdate.mock.calls[0]![0];
|
||||
expect(payload.suppress_run).toBe(true);
|
||||
expect(payload.handoff_note).toBeUndefined();
|
||||
expect(mockUpdate).toHaveBeenCalledWith({
|
||||
id: "issue-1",
|
||||
assignee_type: "agent",
|
||||
assignee_id: "agent-1",
|
||||
suppress_run: true,
|
||||
});
|
||||
expect(mockToast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables the note box when the agent's runtime is too old", () => {
|
||||
cache.runtimes = [{ id: "runtime-1", metadata: { cli_version: "0.2.21" } }];
|
||||
render(<RunConfirmModal onClose={vi.fn()} data={single} />);
|
||||
expect(noteBox()).toBeDisabled();
|
||||
expect(screen.getByText("runtime too old")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("promote sends the status change with the handoff note and no assignee fields", async () => {
|
||||
it("promote sends the status change with no assignee fields", async () => {
|
||||
// The owner is already on the issue: re-sending it would turn a status
|
||||
// write into an assignee write on the server's side of the predicate.
|
||||
render(<RunConfirmModal onClose={vi.fn()} data={promote} />);
|
||||
fireEvent.change(noteBox(), { target: { value: "redo the migration" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Move and start" }));
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(mockUpdate).toHaveBeenCalledWith({
|
||||
id: "issue-1",
|
||||
status: "rework",
|
||||
handoff_note: "redo the migration",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -274,29 +215,6 @@ describe("RunConfirmModal", () => {
|
||||
expect(container.textContent).toContain("move to Todo, Walt starts");
|
||||
});
|
||||
|
||||
it("resolves a squad's verdict through its leader's runtime, locally", () => {
|
||||
// A squad run is executed by its leader, so the leader's runtime decides.
|
||||
// The squad list gives us leader_id, so this needs no server verdict.
|
||||
cache.runtimes = [{ id: "runtime-1", metadata: { cli_version: "0.2.21" } }];
|
||||
render(
|
||||
<RunConfirmModal
|
||||
onClose={vi.fn()}
|
||||
data={{ ...single, assigneeType: "squad", assigneeId: "squad-1" }}
|
||||
/>,
|
||||
);
|
||||
expect(noteBox()).toBeDisabled();
|
||||
expect(screen.getByText("runtime too old")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leaves the note box enabled when the target runtime can't be resolved", () => {
|
||||
// Unknown assignee → no verdict. The note is a soft gate, so an
|
||||
// unresolvable target must not produce a spurious warning.
|
||||
cache.agents = [];
|
||||
render(<RunConfirmModal onClose={vi.fn()} data={single} />);
|
||||
expect(noteBox()).not.toBeDisabled();
|
||||
expect(screen.queryByText("runtime too old")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("batch assign (N ids) applies via batchUpdate", async () => {
|
||||
const { container } = render(
|
||||
<RunConfirmModal onClose={vi.fn()} data={{ ...single, issueIds: ["i1", "i2"] }} />,
|
||||
@@ -313,20 +231,18 @@ describe("RunConfirmModal", () => {
|
||||
});
|
||||
|
||||
// --- Send chord (MUL-5694) ------------------------------------------------
|
||||
// The note box is where the caret starts, so the dialog has to submit from
|
||||
// the keyboard there, the same way the issue composer creates.
|
||||
// The chord is bound on the dialog, not on a single control, so it confirms
|
||||
// wherever focus happens to be.
|
||||
|
||||
it("confirms on the send chord typed in the note box", async () => {
|
||||
it("confirms on the send chord typed anywhere in the dialog", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<RunConfirmModal onClose={onClose} data={single} />);
|
||||
fireEvent.change(noteBox(), { target: { value: "only login" } });
|
||||
fireEvent.keyDown(noteBox(), { key: "Enter", metaKey: true });
|
||||
fireEvent.keyDown(dialog(), { key: "Enter", metaKey: true });
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(mockUpdate).toHaveBeenCalledWith({
|
||||
id: "issue-1",
|
||||
assignee_type: "agent",
|
||||
assignee_id: "agent-1",
|
||||
handoff_note: "only login",
|
||||
});
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
@@ -334,15 +250,12 @@ describe("RunConfirmModal", () => {
|
||||
it("confirms from a focused footer button, which the chord cannot activate", async () => {
|
||||
// Chromium fires no click for ⌘/Ctrl+Enter on a focused button, so without
|
||||
// the dialog handling it there the chord is simply dead. The dialog focuses
|
||||
// its first tabbable child, so this is where an old runtime leaves the user.
|
||||
cache.runtimes = [{ id: "runtime-1", metadata: { cli_version: "0.2.21" } }];
|
||||
// its first tabbable child, which is now a footer button.
|
||||
render(<RunConfirmModal onClose={vi.fn()} data={single} />);
|
||||
fireEvent.keyDown(screen.getByText("Don't start yet"), { key: "Enter", metaKey: true });
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
// The primary action, not the button the caret happened to sit on.
|
||||
const payload = mockUpdate.mock.calls[0]![0];
|
||||
expect(payload.suppress_run).toBeUndefined();
|
||||
expect(payload.handoff_note).toBeUndefined();
|
||||
expect(mockUpdate.mock.calls[0]![0].suppress_run).toBeUndefined();
|
||||
});
|
||||
|
||||
it("yields to a focused button when send is remapped to plain Enter", () => {
|
||||
@@ -355,27 +268,21 @@ describe("RunConfirmModal", () => {
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves plain Enter alone so the note stays multi-line", () => {
|
||||
render(<RunConfirmModal onClose={vi.fn()} data={single} />);
|
||||
fireEvent.keyDown(noteBox(), { key: "Enter" });
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits once for a held chord, and never for an IME's committing Enter", async () => {
|
||||
render(<RunConfirmModal onClose={vi.fn()} data={single} />);
|
||||
fireEvent.keyDown(noteBox(), { key: "Enter", metaKey: true, isComposing: true });
|
||||
fireEvent.keyDown(noteBox(), { key: "Enter", metaKey: true, repeat: true });
|
||||
fireEvent.keyDown(dialog(), { key: "Enter", metaKey: true, isComposing: true });
|
||||
fireEvent.keyDown(dialog(), { key: "Enter", metaKey: true, repeat: true });
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(noteBox(), { key: "Enter", metaKey: true });
|
||||
fireEvent.keyDown(dialog(), { key: "Enter", metaKey: true });
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("follows a remapped send chord instead of hardcoding ⌘+Enter", async () => {
|
||||
useShortcutStore.setState({ overrides: { send: createShortcutChord("Enter") } });
|
||||
render(<RunConfirmModal onClose={vi.fn()} data={single} />);
|
||||
fireEvent.keyDown(noteBox(), { key: "Enter", metaKey: true });
|
||||
fireEvent.keyDown(dialog(), { key: "Enter", metaKey: true });
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(noteBox(), { key: "Enter" });
|
||||
fireEvent.keyDown(dialog(), { key: "Enter" });
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -12,23 +11,18 @@ import {
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Textarea } from "@multica/ui/components/ui/textarea";
|
||||
import { Spinner } from "@multica/ui/components/ui/spinner";
|
||||
import type { IssueAssigneeType, IssueStatus, UpdateIssueRequest } from "@multica/core/types";
|
||||
import { useUpdateIssue, useBatchUpdateIssues } from "@multica/core/issues/mutations";
|
||||
import { errorCode } from "@multica/core/api";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions, squadListOptions } from "@multica/core/workspace/queries";
|
||||
import { runtimeListOptions, readRuntimeCliVersion, handoffSupported } from "@multica/core/runtimes";
|
||||
import { useShortcut, shortcutMatchesEvent, isPlainShortcut } from "@multica/core/shortcuts";
|
||||
import { isImeComposing } from "@multica/core/utils";
|
||||
import { ShortcutKeycaps } from "../common/shortcut-keycaps";
|
||||
import { useStatusLabel } from "../issues/utils/status-label";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
const MAX_HANDOFF_NOTE = 2000;
|
||||
|
||||
// i18next inlines {{name}} / {{status}} into the sentence, but their position
|
||||
// varies by language ("{{name}} 会…" vs "Once assigned, {{name}} will…" vs
|
||||
// "{{name}}'s leader…"). Fence each one with a sentinel so we can bold just
|
||||
@@ -80,11 +74,11 @@ interface RunConfirmData {
|
||||
*
|
||||
* The rule is "dialog = you are handing this to an agent", NOT "you are
|
||||
* confirming N runs" (MUL-5010). It therefore does no pre-flight prediction:
|
||||
* opening it fires no request, so the note box and buttons are usable on the
|
||||
* first frame. Previously it called POST /api/issues/preview-trigger on open
|
||||
* and blocked the whole dialog behind a "检查中…" spinner; because that query is
|
||||
* keyed per issue id with staleTime 0, every new issue was a guaranteed cache
|
||||
* miss and the wait was unavoidable.
|
||||
* opening it fires no request, so the buttons are usable on the first frame.
|
||||
* Previously it called POST /api/issues/preview-trigger on open and blocked the
|
||||
* whole dialog behind a "检查中…" spinner; because that query is keyed per issue
|
||||
* id with staleTime 0, every new issue was a guaranteed cache miss and the wait
|
||||
* was unavoidable.
|
||||
*
|
||||
* Completion is silent: the assignee/status change and any run it starts
|
||||
* surface through the issue's normal updates, so the confirm adds no result
|
||||
@@ -107,52 +101,18 @@ export function RunConfirmModal({
|
||||
const d = (data ?? {}) as RunConfirmData;
|
||||
const issueIds = d.issueIds ?? [];
|
||||
|
||||
const [note, setNote] = useState("");
|
||||
// Which footer action is in flight, so only the clicked button shows a
|
||||
// spinner (the request runs an agent on the server for note assigns, so it is
|
||||
// not instant — the disabled-only state read as frozen).
|
||||
// spinner (the write is not instant — the disabled-only state read as frozen).
|
||||
const [pendingAction, setPendingAction] = useState<"go" | "suppress" | null>(null);
|
||||
const submitting = pendingAction !== null;
|
||||
|
||||
const updateIssue = useUpdateIssue();
|
||||
const batchUpdate = useBatchUpdateIssues();
|
||||
|
||||
// Handoff-support verdict, resolved entirely from warm client caches
|
||||
// (useWorkspacePresencePrefetch keeps agents / squads / runtimes hot), so the
|
||||
// note box settles on the first frame with no round-trip — the same shape as
|
||||
// the quick-create version gate. An agent assignee targets its own runtime; a
|
||||
// squad targets its leader's, which the squad list gives us directly, so both
|
||||
// are knowable locally. `null` means "cannot tell" (assignee not in cache
|
||||
// yet, or no runtime bound) and leaves the box enabled: the note is a soft
|
||||
// gate, and a spurious warning is worse than a note an old daemon drops.
|
||||
const wsId = useWorkspaceId();
|
||||
// Built-ins resolve through i18n, custom statuses through the catalog, so the
|
||||
// promotion headline reads the same way the picker the user just used does.
|
||||
const statusLabel = useStatusLabel(wsId);
|
||||
const { data: agents = [] } = useQuery({ ...agentListOptions(wsId), enabled: !!wsId });
|
||||
const { data: runtimes = [] } = useQuery({ ...runtimeListOptions(wsId), enabled: !!wsId });
|
||||
const { data: squads = [] } = useQuery({ ...squadListOptions(wsId), enabled: !!wsId });
|
||||
const localHandoff = useMemo<boolean | null>(() => {
|
||||
if (!d.assigneeId) return null;
|
||||
let agentId: string | undefined;
|
||||
if (d.assigneeType === "agent") {
|
||||
agentId = d.assigneeId;
|
||||
} else if (d.assigneeType === "squad") {
|
||||
// A squad run is executed by its leader, so the leader's runtime is the
|
||||
// one that has to render the note.
|
||||
agentId = squads.find((s) => s.id === d.assigneeId)?.leader_id;
|
||||
}
|
||||
if (!agentId) return null;
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
if (!agent?.runtime_id) return null;
|
||||
const runtime = runtimes.find((r) => r.id === agent.runtime_id);
|
||||
if (!runtime) return null;
|
||||
return handoffSupported(readRuntimeCliVersion(runtime.metadata));
|
||||
}, [d.assigneeType, d.assigneeId, agents, runtimes, squads]);
|
||||
|
||||
// Soft gate: an old runtime can't render the note. Disable the box but let
|
||||
// the assignment proceed (MUL-3375 §6.3).
|
||||
const noteDisabled = localHandoff === false;
|
||||
|
||||
// A promotion carries the status and nothing else: the owner is already on
|
||||
// the issue, and re-sending the same assignee would turn a status write into
|
||||
@@ -178,10 +138,7 @@ export function RunConfirmModal({
|
||||
const submit = async (suppressRun: boolean) => {
|
||||
if (issueIds.length === 0 || submitting) return;
|
||||
setPendingAction(suppressRun ? "suppress" : "go");
|
||||
const payload = applyTo({
|
||||
...(suppressRun ? { suppress_run: true } : {}),
|
||||
...(!suppressRun && !noteDisabled && note.trim() ? { handoff_note: note.trim() } : {}),
|
||||
});
|
||||
const payload = applyTo(suppressRun ? { suppress_run: true } : {});
|
||||
try {
|
||||
// Completion is silent, exactly as before: the assignee and any run show
|
||||
// up through the issue's normal assignee / run-status updates, so there is
|
||||
@@ -212,11 +169,10 @@ export function RunConfirmModal({
|
||||
* The configured `send` chord confirms the assignment, the same chord that
|
||||
* creates from the issue composer (MUL-5694).
|
||||
*
|
||||
* Bound on the dialog, not on the note box, because the chord means "run the
|
||||
* primary action" no matter which control has focus — and the note box is
|
||||
* not always where focus is. An old runtime disables it, which hands initial
|
||||
* focus to the footer instead, and that is precisely where the keycap on the
|
||||
* confirm button would otherwise be advertising a dead key.
|
||||
* Bound on the dialog, not on a single control, because the chord means "run
|
||||
* the primary action" no matter which control has focus — and with only the
|
||||
* footer left to focus, that is precisely where the keycap on the confirm
|
||||
* button would otherwise be advertising a dead key.
|
||||
*/
|
||||
const onDialogKeyDown = (e: React.KeyboardEvent) => {
|
||||
// A held chord submits once, and the Enter that commits an IME
|
||||
@@ -267,26 +223,6 @@ export function RunConfirmModal({
|
||||
<DialogDescription>{headline}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Always mounted and always usable on the first frame — nothing about
|
||||
this box depends on a server answer. */}
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-body font-medium" htmlFor="handoff-note">
|
||||
{t(($) => $.run_confirm.note_label)}
|
||||
</label>
|
||||
<Textarea
|
||||
id="handoff-note"
|
||||
value={note}
|
||||
maxLength={MAX_HANDOFF_NOTE}
|
||||
disabled={submitting || noteDisabled}
|
||||
placeholder={t(($) => $.run_confirm.note_placeholder)}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
{noteDisabled ? (
|
||||
<p className="text-caption text-muted-foreground">{t(($) => $.run_confirm.note_unsupported)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* The only spinner left is on the button the user just pressed, and it
|
||||
reflects the write in flight — never a pre-flight check. */}
|
||||
<DialogFooter>
|
||||
|
||||
@@ -7208,7 +7208,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
|
||||
AutopilotSource: task.AutopilotSource,
|
||||
AutopilotTriggerPayload: strings.TrimSpace(string(task.AutopilotTriggerPayload)),
|
||||
QuickCreatePrompt: task.QuickCreatePrompt,
|
||||
HandoffNote: task.HandoffNote,
|
||||
IsSquadLeader: taskIsSquadLeader(task),
|
||||
RequestingUserName: task.RequestingUserName,
|
||||
RequestingUserProfileDescription: task.RequestingUserProfileDescription,
|
||||
|
||||
@@ -1028,14 +1028,6 @@ func renderIssueContext(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("**Trigger:** New Assignment\n\n")
|
||||
}
|
||||
|
||||
// Assignment handoff note (MUL-3375): the assigner's scoping instruction for
|
||||
// this run. Distinct from a comment — there is no thread to reply to.
|
||||
if ctx.HandoffNote != "" {
|
||||
b.WriteString("## Handoff Note\n\n")
|
||||
b.WriteString("The person who assigned this issue left this instruction for the run. Treat it as scope guidance and follow it before doing anything broader:\n\n")
|
||||
fmt.Fprintf(&b, "> %s\n\n", ctx.HandoffNote)
|
||||
}
|
||||
|
||||
b.WriteString("## Quick Start\n\n")
|
||||
fmt.Fprintf(&b, "Run `multica issue get %s --output json` to fetch the full issue details.\n\n", ctx.IssueID)
|
||||
|
||||
|
||||
@@ -195,7 +195,6 @@ type TaskContextForEnv struct {
|
||||
AutopilotSource string
|
||||
AutopilotTriggerPayload string
|
||||
QuickCreatePrompt string // non-empty for quick-create tasks
|
||||
HandoffNote string // assignment handoff instruction; rendered into issue_context.md (MUL-3375)
|
||||
IsSquadLeader bool // true when THIS TASK runs the agent in the squad-leader role (may exit silently on no_action); derived from the claim's is_leader_task / squad_id, never sniffed from instructions text (MUL-5811)
|
||||
// WorkspaceContext is the workspace-level system prompt (workspace.context
|
||||
// in the DB). Rendered into the brief as `## Workspace Context` when
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package execenv
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRenderIssueContext_HandoffNote verifies the handoff note lands in
|
||||
// issue_context.md under its own section, distinct from the comment-reply
|
||||
// trigger framing.
|
||||
func TestRenderIssueContext_HandoffNote(t *testing.T) {
|
||||
note := "Scope to the auth module only."
|
||||
md := renderIssueContext("claude", TaskContextForEnv{IssueID: "issue-1", HandoffNote: note})
|
||||
|
||||
if !strings.Contains(md, "## Handoff Note") {
|
||||
t.Fatalf("expected Handoff Note section:\n%s", md)
|
||||
}
|
||||
if !strings.Contains(md, note) {
|
||||
t.Fatalf("handoff note text missing:\n%s", md)
|
||||
}
|
||||
if !strings.Contains(md, "**Trigger:** New Assignment") {
|
||||
t.Fatalf("handoff note must render under the assignment trigger:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderIssueContext_NoHandoffNote keeps the assignment context clean when
|
||||
// no note is present.
|
||||
func TestRenderIssueContext_NoHandoffNote(t *testing.T) {
|
||||
md := renderIssueContext("claude", TaskContextForEnv{IssueID: "issue-1"})
|
||||
if strings.Contains(md, "## Handoff Note") {
|
||||
t.Fatalf("unexpected Handoff Note section when no note set:\n%s", md)
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildPrompt_HandoffNote_AssignmentBranch verifies a handoff note on an
|
||||
// issue-assignment task renders through the assignment branch — it appears in
|
||||
// the prompt, framed as a handoff (not a comment to reply to), and does not
|
||||
// trip the quick-create branch.
|
||||
func TestBuildPrompt_HandoffNote_AssignmentBranch(t *testing.T) {
|
||||
note := "Only touch the login flow; do not change payments."
|
||||
out := BuildPrompt(Task{IssueID: "issue-123", HandoffNote: note}, "claude")
|
||||
|
||||
if !strings.Contains(out, note) {
|
||||
t.Fatalf("handoff note missing from prompt:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "handoff note") {
|
||||
t.Fatalf("expected handoff framing in prompt:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "quick-create assistant") {
|
||||
t.Fatalf("handoff task must not use the quick-create prompt branch:\n%s", out)
|
||||
}
|
||||
// Still an assignment task: should point the agent at `multica issue get`.
|
||||
if !strings.Contains(out, "multica issue get issue-123") {
|
||||
t.Fatalf("expected assignment prompt body:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPrompt_NoHandoffNote_Unchanged verifies the assignment prompt is
|
||||
// unchanged when no handoff note is present (no stray handoff framing).
|
||||
func TestBuildPrompt_NoHandoffNote_Unchanged(t *testing.T) {
|
||||
out := BuildPrompt(Task{IssueID: "issue-123"}, "claude")
|
||||
if strings.Contains(out, "handoff note") {
|
||||
t.Fatalf("unexpected handoff framing when no note set:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -214,13 +214,6 @@ func buildPromptBody(task Task, provider string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
|
||||
fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID)
|
||||
// Assignment handoff (MUL-3375): a free-text instruction the person who
|
||||
// assigned/promoted this issue left for you. Frame it as a handoff, not a
|
||||
// comment to reply to — there is no comment thread to answer here.
|
||||
if task.HandoffNote != "" {
|
||||
b.WriteString("You were handed this issue with a handoff note. Treat it as the assigner's scoping instruction for this run; follow it before doing anything broader, and do not reply to it as if it were a comment:\n\n")
|
||||
fmt.Fprintf(&b, "> %s\n\n", task.HandoffNote)
|
||||
}
|
||||
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then complete it.\n", task.IssueID)
|
||||
fmt.Fprintf(&b, "For comment history, follow the rule in your runtime workflow file (assignment-triggered tasks treat the read as mandatory). Scan the threads first with `multica issue comment list %s --roots-only --summary --compact --output json`, then expand only what matters with `--thread <thread-id> --tail 30`. For `--since` incremental polling, pagination, and folding, see `multica issue comment list --help`.\n", task.IssueID)
|
||||
return b.String()
|
||||
|
||||
@@ -1664,7 +1664,6 @@ func TestTurnModeMarkersRetired(t *testing.T) {
|
||||
{"comment-triggered with content", Task{IssueID: "issue-1", TriggerCommentID: "c-1", TriggerCommentContent: "please look"}},
|
||||
{"comment-triggered with EMPTY content", Task{IssueID: "issue-1", TriggerCommentID: "c-1"}},
|
||||
{"assignment-triggered", Task{IssueID: "issue-1"}},
|
||||
{"assignment-triggered with handoff note", Task{IssueID: "issue-1", HandoffNote: "start with the API"}},
|
||||
{"chat", Task{ChatSessionID: "chat-1"}},
|
||||
{"quick-create", Task{QuickCreatePrompt: "make an issue"}},
|
||||
{"autopilot", Task{AutopilotRunID: "run-1"}},
|
||||
|
||||
@@ -137,7 +137,6 @@ type Task struct {
|
||||
QuickCreateDueDate string `json:"quick_create_due_date,omitempty"` // explicit calendar due date selected in quick-create
|
||||
QuickCreateAttachmentIDs []string `json:"quick_create_attachment_ids,omitempty"` // attachments uploaded in the quick-create prompt and bound by issue create
|
||||
QuickCreateSourceContext json.RawMessage `json:"quick_create_source_context,omitempty"` // immutable historical context, separate from the new instruction
|
||||
HandoffNote string `json:"handoff_note,omitempty"` // assignment handoff instruction; rendered into the opening prompt + issue_context.md
|
||||
|
||||
SquadID string `json:"squad_id,omitempty"` // when the picker was a squad, the squad's UUID; Agent is still the resolved leader
|
||||
SquadName string `json:"squad_name,omitempty"` // display name for the picker squad, used in prompt text
|
||||
|
||||
@@ -461,7 +461,6 @@ type AgentTaskResponse struct {
|
||||
QuickCreateDueDate string `json:"quick_create_due_date,omitempty"` // explicit calendar due date selected in quick-create
|
||||
QuickCreateAttachmentIDs []string `json:"quick_create_attachment_ids,omitempty"` // attachment ids uploaded in the quick-create prompt and bound on issue create
|
||||
QuickCreateSourceContext json.RawMessage `json:"quick_create_source_context,omitempty"` // immutable historical context for source-context quick-create
|
||||
HandoffNote string `json:"handoff_note,omitempty"` // assignment handoff instruction; rendered into the run's opening prompt + issue_context.md (omitempty so old daemons ignore it)
|
||||
SquadID string `json:"squad_id,omitempty"` // for quick-create tasks where the picker was a squad; Agent is still the resolved leader
|
||||
SquadName string `json:"squad_name,omitempty"` // display name for the picker squad
|
||||
ParentIssueID string `json:"parent_issue_id,omitempty"` // for quick-create tasks opened from "Add sub issue" — UUID of the parent issue the new issue should be filed under
|
||||
@@ -753,10 +752,6 @@ func taskToResponse(t db.AgentTaskQueue, workspaceID string) AgentTaskResponse {
|
||||
if t.DurableWorkDir.Valid {
|
||||
durableWorkDir = t.DurableWorkDir.String
|
||||
}
|
||||
handoffNote := ""
|
||||
if t.HandoffNote.Valid {
|
||||
handoffNote = t.HandoffNote.String
|
||||
}
|
||||
branchName := ""
|
||||
if t.BranchName.Valid {
|
||||
branchName = t.BranchName.String
|
||||
@@ -785,7 +780,6 @@ func taskToResponse(t db.AgentTaskQueue, workspaceID string) AgentTaskResponse {
|
||||
CoalescedCommentIDs: uuidsToStrings(t.CoalescedCommentIds),
|
||||
DeliveredCommentIDs: uuidStringsOrEmpty(t.DeliveredCommentIds),
|
||||
TriggerSummary: textToPtr(t.TriggerSummary),
|
||||
HandoffNote: handoffNote,
|
||||
WorkDir: workDir,
|
||||
RelativeWorkDir: relativeWorkDir(workDir, workspaceID, uuidToString(t.ID)),
|
||||
DurableWorkDir: durableWorkDir,
|
||||
|
||||
@@ -2929,10 +2929,6 @@ func (h *Handler) buildClaimedTaskResponse(r *http.Request, task *db.AgentTaskQu
|
||||
projectCtx.applyTo(&resp)
|
||||
}
|
||||
|
||||
// Handoff note (MUL-3375) is populated by taskToResponse (the shared mapper
|
||||
// resp came from above), so the daemon's prompt + issue_context.md render the
|
||||
// assignment-handoff branch. Empty for all other task kinds.
|
||||
|
||||
// Quick-create task: no issue / chat / autopilot link — workspace and
|
||||
// prompt come from the task's context JSONB. Resolve workspace from
|
||||
// there so the isolation check below has something to compare.
|
||||
|
||||
@@ -3138,11 +3138,6 @@ type UpdateIssueRequest struct {
|
||||
// the issue can be run later via manual run/rerun. Optional; omitted or
|
||||
// false keeps today's behavior. Mirrors comment suppress_agent_ids.
|
||||
SuppressRun bool `json:"suppress_run,omitempty"`
|
||||
// HandoffNote is an optional free-text instruction injected into the run's
|
||||
// opening context when this write starts an agent/squad run ("交接说明" —
|
||||
// MUL-3375). Only consumed when a run actually starts: SuppressRun=true or
|
||||
// a parked/non-triggering write drops it. Never fabricates a comment.
|
||||
HandoffNote string `json:"handoff_note,omitempty"`
|
||||
}
|
||||
|
||||
func mergeIssueChannelMediaDescription(current, incoming string, base *string, attachments []db.Attachment) string {
|
||||
@@ -3667,7 +3662,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
},
|
||||
h.issueTriggerWriteProbe(r, actorType, actorID, issue),
|
||||
); ok && !req.SuppressRun {
|
||||
h.dispatchIssueRun(r.Context(), issue, trigger, actorType, actorID, req.HandoffNote)
|
||||
h.dispatchIssueRun(r.Context(), issue, trigger, actorType, actorID)
|
||||
}
|
||||
|
||||
// Platform-driven parent notification: when this issue transitions into
|
||||
@@ -4349,7 +4344,7 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
|
||||
},
|
||||
h.issueTriggerWriteProbe(r, actorType, actorID, issue),
|
||||
); ok && !req.Updates.SuppressRun {
|
||||
h.dispatchIssueRun(r.Context(), issue, trigger, actorType, actorID, req.Updates.HandoffNote)
|
||||
h.dispatchIssueRun(r.Context(), issue, trigger, actorType, actorID)
|
||||
}
|
||||
|
||||
// No status change — not even → cancelled — cancels active tasks here,
|
||||
|
||||
@@ -7,10 +7,8 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
|
||||
"github.com/multica-ai/multica/server/internal/service"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
agentver "github.com/multica-ai/multica/server/pkg/agent"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
@@ -79,18 +77,17 @@ func (h *Handler) shouldSuppressActiveSelfAssignment(ctx context.Context, actorT
|
||||
}
|
||||
|
||||
// dispatchIssueRun executes the enqueue side effect for a decision produced by
|
||||
// WillEnqueueRun, carrying an optional handoff note into the run's opening
|
||||
// context. The squad path still flows through enqueueSquadLeaderTask so the
|
||||
// leader access gate and pending dedup stay in one place.
|
||||
func (h *Handler) dispatchIssueRun(ctx context.Context, issue db.Issue, trigger service.IssueRunTrigger, actorType, actorID, handoffNote string) {
|
||||
// WillEnqueueRun. The squad path still flows through enqueueSquadLeaderTask so
|
||||
// the leader access gate and pending dedup stay in one place.
|
||||
func (h *Handler) dispatchIssueRun(ctx context.Context, issue db.Issue, trigger service.IssueRunTrigger, actorType, actorID string) {
|
||||
switch trigger.AssigneeType {
|
||||
case "agent":
|
||||
// The member who performed this assign/promote is the accountable human
|
||||
// for the run (MUL-4302 §4). An agent actor is not a human, so only a
|
||||
// member actor is threaded; otherwise attribution falls back to the chain.
|
||||
_, _ = h.TaskService.EnqueueTaskForIssueWithHandoff(ctx, issue, handoffNote, memberActorUserID(actorType, actorID))
|
||||
_, _ = h.TaskService.EnqueueTaskForIssueByActor(ctx, issue, memberActorUserID(actorType, actorID))
|
||||
case "squad":
|
||||
h.enqueueSquadLeaderTask(ctx, issue, pgtype.UUID{}, actorType, actorID, handoffNote)
|
||||
h.enqueueSquadLeaderTask(ctx, issue, pgtype.UUID{}, actorType, actorID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,14 +122,10 @@ type IssueTriggerPreviewRequest struct {
|
||||
|
||||
// IssueTriggerPreviewItem is one issue that WILL start a run under the
|
||||
// prospective write. AgentID is the runnable agent (squad leader for squads).
|
||||
// HandoffSupported is the soft-gate signal: false when the target runtime's
|
||||
// daemon is too old to render a handoff note, so the UI can gray out the note
|
||||
// box rather than silently drop the text. The assignment itself still works.
|
||||
type IssueTriggerPreviewItem struct {
|
||||
IssueID string `json:"issue_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Source string `json:"source"`
|
||||
HandoffSupported bool `json:"handoff_supported"`
|
||||
IssueID string `json:"issue_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// IssueTriggerPreviewResponse lists every issue that will enqueue plus a total
|
||||
@@ -193,10 +186,9 @@ func (h *Handler) PreviewIssueTrigger(w http.ResponseWriter, r *http.Request) {
|
||||
probe := h.issueTriggerPreviewProbe(r, actorType, actorID, workspaceID, issue)
|
||||
if trigger, ok := h.IssueService.WillEnqueueRun(r.Context(), in, probe); ok {
|
||||
resp.Triggers = append(resp.Triggers, IssueTriggerPreviewItem{
|
||||
IssueID: uuidToString(trigger.IssueID),
|
||||
AgentID: uuidToString(trigger.AgentID),
|
||||
Source: string(trigger.Source),
|
||||
HandoffSupported: h.runtimeSupportsHandoff(r.Context(), trigger.AgentID),
|
||||
IssueID: uuidToString(trigger.IssueID),
|
||||
AgentID: uuidToString(trigger.AgentID),
|
||||
Source: string(trigger.Source),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -255,18 +247,3 @@ func (h *Handler) PreviewIssueTrigger(w http.ResponseWriter, r *http.Request) {
|
||||
resp.TotalCount = len(resp.Triggers)
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// runtimeSupportsHandoff reports whether the agent's bound runtime reports a
|
||||
// CLI version new enough to render handoff notes. Drives the preview's
|
||||
// handoff_supported soft-gate signal. Any resolution failure → false (degrade).
|
||||
func (h *Handler) runtimeSupportsHandoff(ctx context.Context, agentID pgtype.UUID) bool {
|
||||
agent, err := h.Queries.GetAgent(ctx, agentID)
|
||||
if err != nil || !agent.RuntimeID.Valid {
|
||||
return false
|
||||
}
|
||||
rt, err := h.getAgentRuntime(ctx, obsmetrics.RuntimeLookupSourceIssue, agent.RuntimeID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return agentver.HandoffSupported(readRuntimeCLIVersion(rt.Metadata))
|
||||
}
|
||||
|
||||
@@ -220,49 +220,6 @@ func TestUpdateIssueSuppressRunSkipsEnqueue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateIssueHandoffNotePersistsOnTask verifies an assign carrying a
|
||||
// handoff_note writes that note onto the enqueued task (the daemon then renders
|
||||
// it), while a suppressed assign with a note enqueues nothing at all.
|
||||
func TestUpdateIssueHandoffNotePersistsOnTask(t *testing.T) {
|
||||
agentID := seededReadyAgentID(t)
|
||||
note := "Only touch the login flow."
|
||||
|
||||
issue := createIssueForTest(t, map[string]any{"title": "handoff persist", "status": "todo"})
|
||||
w := httptest.NewRecorder()
|
||||
req := withURLParam(newRequest("PUT", "/api/issues/"+issue.ID, map[string]any{
|
||||
"assignee_type": "agent", "assignee_id": agentID, "handoff_note": note,
|
||||
}), "id", issue.ID)
|
||||
testHandler.UpdateIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateIssue with handoff: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var stored string
|
||||
if err := testPool.QueryRow(context.Background(), `
|
||||
SELECT COALESCE(handoff_note, '') FROM agent_task_queue
|
||||
WHERE issue_id = $1 AND agent_id = $2 ORDER BY created_at DESC LIMIT 1
|
||||
`, issue.ID, agentID).Scan(&stored); err != nil {
|
||||
t.Fatalf("read task handoff_note: %v", err)
|
||||
}
|
||||
if stored != note {
|
||||
t.Fatalf("expected task handoff_note %q, got %q", note, stored)
|
||||
}
|
||||
|
||||
// Suppressed assign with a note: no task at all (no run to inject into).
|
||||
suppressed := createIssueForTest(t, map[string]any{"title": "handoff suppressed", "status": "todo"})
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := withURLParam(newRequest("PUT", "/api/issues/"+suppressed.ID, map[string]any{
|
||||
"assignee_type": "agent", "assignee_id": agentID, "handoff_note": note, "suppress_run": true,
|
||||
}), "id", suppressed.ID)
|
||||
testHandler.UpdateIssue(w2, req2)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateIssue suppressed handoff: %d %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
if got := taskCountFor(t, suppressed.ID, agentID); got != 0 {
|
||||
t.Fatalf("suppressed handoff should enqueue no task, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreviewIssueTrigger_MalformedBody verifies the endpoint rejects a
|
||||
// malformed body with 400 rather than a 500 or a silent empty result.
|
||||
func TestPreviewIssueTrigger_MalformedBody(t *testing.T) {
|
||||
|
||||
@@ -1170,9 +1170,8 @@ func commentMentionsAnyone(content string) bool {
|
||||
// to a squad. Assign and backlog-promotion paths use this directly; comment
|
||||
// paths go through computeCommentAgentTriggers so preview and create share the
|
||||
// same trigger set.
|
||||
// enqueueSquadLeaderTask returns true when it actually enqueued a leader task
|
||||
// (so the caller can record a handoff trace only on a real run start).
|
||||
func (h *Handler) enqueueSquadLeaderTask(ctx context.Context, issue db.Issue, triggerCommentID pgtype.UUID, authorType, authorID, handoffNote string) bool {
|
||||
// It returns true only when a leader task was actually enqueued.
|
||||
func (h *Handler) enqueueSquadLeaderTask(ctx context.Context, issue db.Issue, triggerCommentID pgtype.UUID, authorType, authorID string) bool {
|
||||
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
||||
ID: issue.AssigneeID,
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
@@ -1211,13 +1210,12 @@ func (h *Handler) enqueueSquadLeaderTask(ctx context.Context, issue db.Issue, tr
|
||||
return false
|
||||
}
|
||||
|
||||
// triggerCommentID is always empty on the assign/promote path; the handoff
|
||||
// note rides its own task column, never trigger_comment_id.
|
||||
// triggerCommentID is always empty on the assign/promote path.
|
||||
_ = triggerCommentID
|
||||
// The member who performed the assign/promote is the accountable human for the
|
||||
// leader run (MUL-4302 §4) — the same principal the gate above judged. An agent
|
||||
// author is not a human, so only a member actor is threaded.
|
||||
if _, err := h.TaskService.EnqueueTaskForSquadLeaderWithHandoff(ctx, issue, squad.LeaderID, squad.ID, handoffNote, memberActorUserID(authorType, authorID)); err != nil {
|
||||
if _, err := h.TaskService.EnqueueTaskForSquadLeaderByActor(ctx, issue, squad.LeaderID, squad.ID, memberActorUserID(authorType, authorID)); err != nil {
|
||||
slog.Warn("enqueue squad leader task failed",
|
||||
"issue_id", uuidToString(issue.ID),
|
||||
"squad_id", uuidToString(squad.ID),
|
||||
|
||||
@@ -117,12 +117,12 @@ func TestEnqueueTaskForIssueStampsDirectHumanAttribution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnqueueTaskForIssueWithHandoffAttributesToActor is the acceptance test for
|
||||
// TestEnqueueTaskForIssueByActorAttributesToActor is the acceptance test for
|
||||
// the assign/promote actor fix (MUL-4302 §4): when a member assigns an issue that
|
||||
// a DIFFERENT member created, the run's accountable human — and, honoring the
|
||||
// invariant, its originator — is the assigning member (the actor), not the issue
|
||||
// creator. The evidence still points at the issue.
|
||||
func TestEnqueueTaskForIssueWithHandoffAttributesToActor(t *testing.T) {
|
||||
func TestEnqueueTaskForIssueByActorAttributesToActor(t *testing.T) {
|
||||
pool := newResolveOriginatorPool(t)
|
||||
ctx := context.Background()
|
||||
q := db.New(pool)
|
||||
@@ -142,7 +142,7 @@ func TestEnqueueTaskForIssueWithHandoffAttributesToActor(t *testing.T) {
|
||||
}
|
||||
|
||||
svc := &TaskService{Queries: q, TxStarter: pool, Bus: events.New()}
|
||||
task, err := svc.EnqueueTaskForIssueWithHandoff(ctx, db.Issue{
|
||||
task, err := svc.EnqueueTaskForIssueByActor(ctx, db.Issue{
|
||||
ID: util.MustParseUUID(issueID),
|
||||
AssigneeID: util.MustParseUUID(agentID),
|
||||
Priority: "medium",
|
||||
@@ -150,9 +150,9 @@ func TestEnqueueTaskForIssueWithHandoffAttributesToActor(t *testing.T) {
|
||||
CreatorID: util.MustParseUUID(creatorID),
|
||||
WorkspaceID: util.MustParseUUID(workspaceID),
|
||||
AssigneeType: pgtype.Text{String: "agent", Valid: true},
|
||||
}, "", util.MustParseUUID(actorID))
|
||||
}, util.MustParseUUID(actorID))
|
||||
if err != nil {
|
||||
t.Fatalf("EnqueueTaskForIssueWithHandoff: %v", err)
|
||||
t.Fatalf("EnqueueTaskForIssueByActor: %v", err)
|
||||
}
|
||||
|
||||
var source pgtype.Text
|
||||
@@ -1032,7 +1032,7 @@ func TestEnqueueTaskForIssueAutopilotManualStampsDirectHuman(t *testing.T) {
|
||||
|
||||
svc := &TaskService{Queries: q, TxStarter: pool, Bus: events.New()}
|
||||
// dispatchCreateIssue routes a manual trigger through the actor-carrying enqueue.
|
||||
task, err := svc.EnqueueTaskForIssueWithHandoff(ctx, db.Issue{
|
||||
task, err := svc.EnqueueTaskForIssueByActor(ctx, db.Issue{
|
||||
ID: util.MustParseUUID(issueID),
|
||||
AssigneeID: util.MustParseUUID(agentID),
|
||||
Priority: "medium",
|
||||
@@ -1042,9 +1042,9 @@ func TestEnqueueTaskForIssueAutopilotManualStampsDirectHuman(t *testing.T) {
|
||||
AssigneeType: pgtype.Text{String: "agent", Valid: true},
|
||||
OriginType: pgtype.Text{String: "autopilot", Valid: true},
|
||||
OriginID: util.MustParseUUID(autopilotID),
|
||||
}, "", util.MustParseUUID(actorID))
|
||||
}, util.MustParseUUID(actorID))
|
||||
if err != nil {
|
||||
t.Fatalf("EnqueueTaskForIssueWithHandoff: %v", err)
|
||||
t.Fatalf("EnqueueTaskForIssueByActor: %v", err)
|
||||
}
|
||||
|
||||
var source pgtype.Text
|
||||
|
||||
@@ -801,8 +801,7 @@ func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopi
|
||||
// triggering member (originator == accountable == actor, MUL-4302 §4). Schedule /
|
||||
// webhook dispatch has no actor and takes the plain entry points, where the
|
||||
// autopilot-origin issue resolves to the trigger's creator (MUL-6951). The
|
||||
// *WithHandoff variants are the existing actor-carrying enqueue methods; the
|
||||
// handoff note is empty here.
|
||||
// *ByActor variants are the actor-carrying enqueue methods.
|
||||
if ap.AssigneeType == "squad" {
|
||||
// Fail-closed invocation gate: verify the admission principal (manual
|
||||
// clicker, else creator — see autopilotAdmitInvoke) may still invoke the
|
||||
@@ -812,14 +811,14 @@ func (s *AutopilotService) dispatchCreateIssue(ctx context.Context, ap db.Autopi
|
||||
return fmt.Errorf("not allowed to invoke private squad leader")
|
||||
}
|
||||
if actorUserID.Valid {
|
||||
if _, err := s.TaskSvc.EnqueueTaskForSquadLeaderWithHandoff(ctx, issue, leader.ID, ap.AssigneeID, "", actorUserID); err != nil {
|
||||
if _, err := s.TaskSvc.EnqueueTaskForSquadLeaderByActor(ctx, issue, leader.ID, ap.AssigneeID, actorUserID); err != nil {
|
||||
return fmt.Errorf("enqueue squad leader task: %w", err)
|
||||
}
|
||||
} else if _, err := s.TaskSvc.EnqueueTaskForSquadLeader(ctx, issue, leader.ID, ap.AssigneeID, pgtype.UUID{}); err != nil {
|
||||
return fmt.Errorf("enqueue squad leader task: %w", err)
|
||||
}
|
||||
} else if actorUserID.Valid {
|
||||
if _, err := s.TaskSvc.EnqueueTaskForIssueWithHandoff(ctx, issue, "", actorUserID); err != nil {
|
||||
if _, err := s.TaskSvc.EnqueueTaskForIssueByActor(ctx, issue, actorUserID); err != nil {
|
||||
return fmt.Errorf("enqueue task for issue: %w", err)
|
||||
}
|
||||
} else if _, err := s.TaskSvc.EnqueueTaskForIssue(ctx, issue); err != nil {
|
||||
|
||||
@@ -1095,7 +1095,7 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue, t
|
||||
if len(triggerCommentID) > 0 {
|
||||
commentID = triggerCommentID[0]
|
||||
}
|
||||
return s.enqueueIssueTask(ctx, issue, commentID, false, "", pgtype.UUID{}, pgtype.UUID{}, pgtype.Timestamptz{})
|
||||
return s.enqueueIssueTask(ctx, issue, commentID, false, pgtype.UUID{}, pgtype.UUID{}, pgtype.Timestamptz{})
|
||||
}
|
||||
|
||||
// EnqueueDeferredChannelIssueTask persists the assigned task for a media-backed
|
||||
@@ -1103,7 +1103,7 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue, t
|
||||
// crash-safe fallback; the channel router promotes the task as soon as the
|
||||
// detached attachment transaction settles.
|
||||
func (s *TaskService) EnqueueDeferredChannelIssueTask(ctx context.Context, issue db.Issue, fireAt time.Time) (db.AgentTaskQueue, error) {
|
||||
return s.enqueueIssueTask(ctx, issue, pgtype.UUID{}, false, "", pgtype.UUID{}, pgtype.UUID{}, pgtype.Timestamptz{Time: fireAt, Valid: true})
|
||||
return s.enqueueIssueTask(ctx, issue, pgtype.UUID{}, false, pgtype.UUID{}, pgtype.UUID{}, pgtype.Timestamptz{Time: fireAt, Valid: true})
|
||||
}
|
||||
|
||||
// createDeferredChannelIssueTaskWithQueries inserts the inert media-gated task
|
||||
@@ -1114,7 +1114,7 @@ func (s *TaskService) EnqueueDeferredChannelIssueTask(ctx context.Context, issue
|
||||
// commit without holding database locks across a network call.
|
||||
func (s *TaskService) createDeferredChannelIssueTaskWithQueries(ctx context.Context, q *db.Queries, issue db.Issue, fireAt time.Time) (db.AgentTaskQueue, error) {
|
||||
txService := &TaskService{Queries: q}
|
||||
return txService.enqueueIssueTask(ctx, issue, pgtype.UUID{}, false, "", pgtype.UUID{}, pgtype.UUID{}, pgtype.Timestamptz{Time: fireAt, Valid: true})
|
||||
return txService.enqueueIssueTask(ctx, issue, pgtype.UUID{}, false, pgtype.UUID{}, pgtype.UUID{}, pgtype.Timestamptz{Time: fireAt, Valid: true})
|
||||
}
|
||||
|
||||
// hydrateDeferredChannelIssueTaskOverlay fills the optional Composio overlay
|
||||
@@ -1149,14 +1149,12 @@ func (s *TaskService) hydrateDeferredChannelIssueTaskOverlay(ctx context.Context
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnqueueTaskForIssueWithHandoff is the assign/promote variant that carries a
|
||||
// handoff note into the run's opening context (MUL-3375). The note rides a
|
||||
// dedicated task column; the daemon renders it via the assignment-handoff
|
||||
// branch. Empty note behaves exactly like EnqueueTaskForIssue. actorUserID is the
|
||||
// member who performed the assign/promote and becomes the accountable human for
|
||||
// the run (MUL-4302 §4); invalid when the caller has no member actor.
|
||||
func (s *TaskService) EnqueueTaskForIssueWithHandoff(ctx context.Context, issue db.Issue, handoffNote string, actorUserID pgtype.UUID) (db.AgentTaskQueue, error) {
|
||||
return s.enqueueIssueTask(ctx, issue, pgtype.UUID{}, false, handoffNote, actorUserID, pgtype.UUID{}, pgtype.Timestamptz{})
|
||||
// EnqueueTaskForIssueByActor is the assign/promote variant of
|
||||
// EnqueueTaskForIssue. actorUserID is the member who performed the
|
||||
// assign/promote and becomes the accountable human for the run (MUL-4302 §4);
|
||||
// invalid when the caller has no member actor.
|
||||
func (s *TaskService) EnqueueTaskForIssueByActor(ctx context.Context, issue db.Issue, actorUserID pgtype.UUID) (db.AgentTaskQueue, error) {
|
||||
return s.enqueueIssueTask(ctx, issue, pgtype.UUID{}, false, actorUserID, pgtype.UUID{}, pgtype.Timestamptz{})
|
||||
}
|
||||
|
||||
// enqueueIssueTask is the shared implementation behind EnqueueTaskForIssue
|
||||
@@ -1204,11 +1202,11 @@ func (s *TaskService) ResolveIssueReviewSHAParam(ctx context.Context, issueID pg
|
||||
return headShaText(s.ResolveIssueReviewSHA(ctx, issueID))
|
||||
}
|
||||
|
||||
func (s *TaskService) enqueueIssueTask(ctx context.Context, issue db.Issue, triggerCommentID pgtype.UUID, forceFreshSession bool, handoffNote string, actorUserID pgtype.UUID, rerunOfTaskID pgtype.UUID, fireAt pgtype.Timestamptz) (db.AgentTaskQueue, error) {
|
||||
return s.enqueueIssueTaskWithCommentPlan(ctx, issue, triggerCommentID, nil, forceFreshSession, handoffNote, actorUserID, rerunOfTaskID, fireAt)
|
||||
func (s *TaskService) enqueueIssueTask(ctx context.Context, issue db.Issue, triggerCommentID pgtype.UUID, forceFreshSession bool, actorUserID pgtype.UUID, rerunOfTaskID pgtype.UUID, fireAt pgtype.Timestamptz) (db.AgentTaskQueue, error) {
|
||||
return s.enqueueIssueTaskWithCommentPlan(ctx, issue, triggerCommentID, nil, forceFreshSession, actorUserID, rerunOfTaskID, fireAt)
|
||||
}
|
||||
|
||||
func (s *TaskService) enqueueIssueTaskWithCommentPlan(ctx context.Context, issue db.Issue, triggerCommentID pgtype.UUID, coalescedCommentIDs []pgtype.UUID, forceFreshSession bool, handoffNote string, actorUserID pgtype.UUID, rerunOfTaskID pgtype.UUID, fireAt pgtype.Timestamptz) (db.AgentTaskQueue, error) {
|
||||
func (s *TaskService) enqueueIssueTaskWithCommentPlan(ctx context.Context, issue db.Issue, triggerCommentID pgtype.UUID, coalescedCommentIDs []pgtype.UUID, forceFreshSession bool, actorUserID pgtype.UUID, rerunOfTaskID pgtype.UUID, fireAt pgtype.Timestamptz) (db.AgentTaskQueue, error) {
|
||||
if !issue.AssigneeID.Valid {
|
||||
slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", "issue has no assignee")
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("issue has no assignee")
|
||||
@@ -1254,7 +1252,6 @@ func (s *TaskService) enqueueIssueTaskWithCommentPlan(ctx context.Context, issue
|
||||
CoalescedCommentIds: coalescedCommentIDs,
|
||||
TriggerSummary: s.buildCommentTriggerSummary(ctx, issue.WorkspaceID, triggerCommentID),
|
||||
ForceFreshSession: pgtype.Bool{Bool: forceFreshSession, Valid: forceFreshSession},
|
||||
HandoffNote: pgtype.Text{String: handoffNote, Valid: handoffNote != ""},
|
||||
OriginatorUserID: originatorUserID,
|
||||
AccountableUserID: attr.AccountableUserID,
|
||||
RuleVersionID: attr.RuleVersionID,
|
||||
@@ -1282,7 +1279,6 @@ func (s *TaskService) enqueueIssueTaskWithCommentPlan(ctx context.Context, issue
|
||||
TriggerSummary: createParams.TriggerSummary,
|
||||
ForceFreshSession: createParams.ForceFreshSession,
|
||||
IsLeaderTask: createParams.IsLeaderTask,
|
||||
HandoffNote: createParams.HandoffNote,
|
||||
SquadID: createParams.SquadID,
|
||||
HeadSha: createParams.HeadSha,
|
||||
OriginatorUserID: createParams.OriginatorUserID,
|
||||
@@ -1329,13 +1325,13 @@ func (s *TaskService) enqueueIssueTaskWithCommentPlan(ctx context.Context, issue
|
||||
// Unlike EnqueueTaskForIssue, this takes an explicit agent ID rather than
|
||||
// deriving it from the issue assignee.
|
||||
func (s *TaskService) EnqueueTaskForMention(ctx context.Context, issue db.Issue, agentID pgtype.UUID, triggerCommentID pgtype.UUID) (db.AgentTaskQueue, error) {
|
||||
return s.enqueueMentionTask(ctx, issue, agentID, triggerCommentID, false, pgtype.UUID{}, false, "", pgtype.UUID{}, pgtype.UUID{})
|
||||
return s.enqueueMentionTask(ctx, issue, agentID, triggerCommentID, false, pgtype.UUID{}, false, pgtype.UUID{}, pgtype.UUID{})
|
||||
}
|
||||
|
||||
// EnqueueTaskForThreadParent creates a queued task for the agent who authored
|
||||
// the direct parent comment a member replied to.
|
||||
func (s *TaskService) EnqueueTaskForThreadParent(ctx context.Context, issue db.Issue, agentID pgtype.UUID, triggerCommentID pgtype.UUID) (db.AgentTaskQueue, error) {
|
||||
return s.enqueueMentionTask(ctx, issue, agentID, triggerCommentID, false, pgtype.UUID{}, false, "", pgtype.UUID{}, pgtype.UUID{})
|
||||
return s.enqueueMentionTask(ctx, issue, agentID, triggerCommentID, false, pgtype.UUID{}, false, pgtype.UUID{}, pgtype.UUID{})
|
||||
}
|
||||
|
||||
// EnqueueTaskForSquadLeader is the leader-role variant of EnqueueTaskForMention.
|
||||
@@ -1350,23 +1346,22 @@ func (s *TaskService) EnqueueTaskForThreadParent(ctx context.Context, issue db.I
|
||||
// leader task was triggered (comment @squad, issue assign, autopilot,
|
||||
// sub-issue done callback). See migration 127.
|
||||
func (s *TaskService) EnqueueTaskForSquadLeader(ctx context.Context, issue db.Issue, leaderID pgtype.UUID, squadID pgtype.UUID, triggerCommentID pgtype.UUID) (db.AgentTaskQueue, error) {
|
||||
return s.enqueueMentionTask(ctx, issue, leaderID, triggerCommentID, true, squadID, false, "", pgtype.UUID{}, pgtype.UUID{})
|
||||
return s.enqueueMentionTask(ctx, issue, leaderID, triggerCommentID, true, squadID, false, pgtype.UUID{}, pgtype.UUID{})
|
||||
}
|
||||
|
||||
// EnqueueTaskForSquadLeaderWithHandoff is the assign/promote variant carrying a
|
||||
// handoff note into the leader run's opening context (MUL-3375). Empty note
|
||||
// behaves exactly like EnqueueTaskForSquadLeader. actorUserID is the member who
|
||||
// performed the assign/promote and becomes the accountable human (MUL-4302 §4);
|
||||
// invalid when the caller has no member actor.
|
||||
func (s *TaskService) EnqueueTaskForSquadLeaderWithHandoff(ctx context.Context, issue db.Issue, leaderID pgtype.UUID, squadID pgtype.UUID, handoffNote string, actorUserID pgtype.UUID) (db.AgentTaskQueue, error) {
|
||||
return s.enqueueMentionTask(ctx, issue, leaderID, pgtype.UUID{}, true, squadID, false, handoffNote, actorUserID, pgtype.UUID{})
|
||||
// EnqueueTaskForSquadLeaderByActor is the assign/promote variant of
|
||||
// EnqueueTaskForSquadLeader. actorUserID is the member who performed the
|
||||
// assign/promote and becomes the accountable human (MUL-4302 §4); invalid when
|
||||
// the caller has no member actor.
|
||||
func (s *TaskService) EnqueueTaskForSquadLeaderByActor(ctx context.Context, issue db.Issue, leaderID pgtype.UUID, squadID pgtype.UUID, actorUserID pgtype.UUID) (db.AgentTaskQueue, error) {
|
||||
return s.enqueueMentionTask(ctx, issue, leaderID, pgtype.UUID{}, true, squadID, false, actorUserID, pgtype.UUID{})
|
||||
}
|
||||
|
||||
func (s *TaskService) enqueueMentionTask(ctx context.Context, issue db.Issue, agentID pgtype.UUID, triggerCommentID pgtype.UUID, isLeader bool, squadID pgtype.UUID, forceFreshSession bool, handoffNote string, actorUserID pgtype.UUID, rerunOfTaskID pgtype.UUID) (db.AgentTaskQueue, error) {
|
||||
return s.enqueueMentionTaskWithCommentPlan(ctx, issue, agentID, triggerCommentID, nil, isLeader, squadID, forceFreshSession, handoffNote, actorUserID, rerunOfTaskID)
|
||||
func (s *TaskService) enqueueMentionTask(ctx context.Context, issue db.Issue, agentID pgtype.UUID, triggerCommentID pgtype.UUID, isLeader bool, squadID pgtype.UUID, forceFreshSession bool, actorUserID pgtype.UUID, rerunOfTaskID pgtype.UUID) (db.AgentTaskQueue, error) {
|
||||
return s.enqueueMentionTaskWithCommentPlan(ctx, issue, agentID, triggerCommentID, nil, isLeader, squadID, forceFreshSession, actorUserID, rerunOfTaskID)
|
||||
}
|
||||
|
||||
func (s *TaskService) enqueueMentionTaskWithCommentPlan(ctx context.Context, issue db.Issue, agentID pgtype.UUID, triggerCommentID pgtype.UUID, coalescedCommentIDs []pgtype.UUID, isLeader bool, squadID pgtype.UUID, forceFreshSession bool, handoffNote string, actorUserID pgtype.UUID, rerunOfTaskID pgtype.UUID) (db.AgentTaskQueue, error) {
|
||||
func (s *TaskService) enqueueMentionTaskWithCommentPlan(ctx context.Context, issue db.Issue, agentID pgtype.UUID, triggerCommentID pgtype.UUID, coalescedCommentIDs []pgtype.UUID, isLeader bool, squadID pgtype.UUID, forceFreshSession bool, actorUserID pgtype.UUID, rerunOfTaskID pgtype.UUID) (db.AgentTaskQueue, error) {
|
||||
agent, err := s.Queries.GetAgent(ctx, agentID)
|
||||
if err != nil {
|
||||
slog.Error("mention task enqueue failed: agent not found", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID), "error", err)
|
||||
@@ -1407,7 +1402,6 @@ func (s *TaskService) enqueueMentionTaskWithCommentPlan(ctx context.Context, iss
|
||||
TriggerSummary: s.buildCommentTriggerSummary(ctx, issue.WorkspaceID, triggerCommentID),
|
||||
IsLeaderTask: pgtype.Bool{Bool: isLeader, Valid: isLeader},
|
||||
ForceFreshSession: pgtype.Bool{Bool: forceFreshSession, Valid: forceFreshSession},
|
||||
HandoffNote: pgtype.Text{String: handoffNote, Valid: handoffNote != ""},
|
||||
SquadID: squadID,
|
||||
OriginatorUserID: originatorUserID,
|
||||
AccountableUserID: attr.AccountableUserID,
|
||||
@@ -5719,9 +5713,9 @@ func (s *TaskService) promoteNewestSurvivingComment(ctx context.Context, ids []p
|
||||
func (s *TaskService) enqueueRerunTask(ctx context.Context, issue db.Issue, agentID pgtype.UUID, triggerCommentID pgtype.UUID, coalescedCommentIDs []pgtype.UUID, isLeader bool, squadID pgtype.UUID, actorUserID pgtype.UUID, rerunOfTaskID pgtype.UUID) (db.AgentTaskQueue, error) {
|
||||
if issue.AssigneeType.String == "agent" && issue.AssigneeID.Valid &&
|
||||
util.UUIDToString(issue.AssigneeID) == util.UUIDToString(agentID) {
|
||||
return s.enqueueIssueTaskWithCommentPlan(ctx, issue, triggerCommentID, coalescedCommentIDs, true, "", actorUserID, rerunOfTaskID, pgtype.Timestamptz{})
|
||||
return s.enqueueIssueTaskWithCommentPlan(ctx, issue, triggerCommentID, coalescedCommentIDs, true, actorUserID, rerunOfTaskID, pgtype.Timestamptz{})
|
||||
}
|
||||
return s.enqueueMentionTaskWithCommentPlan(ctx, issue, agentID, triggerCommentID, coalescedCommentIDs, isLeader, squadID, true, "", actorUserID, rerunOfTaskID)
|
||||
return s.enqueueMentionTaskWithCommentPlan(ctx, issue, agentID, triggerCommentID, coalescedCommentIDs, isLeader, squadID, true, actorUserID, rerunOfTaskID)
|
||||
}
|
||||
|
||||
// The bulk terminal writes below are the sweeper, archive and daemon-recovery
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHandoffSupported(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
want bool
|
||||
}{
|
||||
{"at minimum", MinHandoffCLIVersion, true},
|
||||
{"above minimum", "0.4.0", true},
|
||||
{"below minimum", "0.3.26", false},
|
||||
{"far below", "0.2.21", false},
|
||||
{"empty (unreported)", "", false},
|
||||
{"unparsable", "garbage", false},
|
||||
{"dev git-describe build", "v0.3.0-5-gabc1234", true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := HandoffSupported(tc.version); got != tc.want {
|
||||
t.Fatalf("HandoffSupported(%q) = %v, want %v", tc.version, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -53,38 +53,6 @@ const MinQuickCreateFieldsCLIVersion = "0.4.3"
|
||||
// through the floor and ran two tasks in the user's own directory.
|
||||
const MinLocalWorktreeCLIVersion = "0.4.24"
|
||||
|
||||
// MinHandoffCLIVersion is the lowest multica CLI version whose daemon renders
|
||||
// the assignment handoff note into the run's opening prompt + issue_context.md
|
||||
// (MUL-3375). Unlike quick-create this is a SOFT gate: assigning an issue with
|
||||
// a note never fails on an old daemon — the assignment still takes effect, the
|
||||
// note is simply dropped. The frontend reads HandoffSupported to gray out the
|
||||
// note box and warn the user, so they aren't surprised by a silently ignored
|
||||
// note. Bump this to the release that actually ships the daemon rendering.
|
||||
const MinHandoffCLIVersion = "0.3.28"
|
||||
|
||||
// HandoffSupported reports whether a daemon reporting cliVersion is new enough
|
||||
// to render handoff notes. Reuses the CheckMinCLIVersion parsing (including the
|
||||
// git-describe dev-build exemption) but never errors — a missing/old/unparsable
|
||||
// version simply means "not supported", which the soft gate degrades gracefully.
|
||||
func HandoffSupported(cliVersion string) bool {
|
||||
d := strings.TrimSpace(cliVersion)
|
||||
if d == "" {
|
||||
return false
|
||||
}
|
||||
if devDescribeRe.MatchString(d) {
|
||||
return true
|
||||
}
|
||||
parsed, err := parseSemver(d)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
min, err := parseSemver(MinHandoffCLIVersion)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !parsed.lessThan(min)
|
||||
}
|
||||
|
||||
// Errors returned by CheckMinCLIVersion. Callers branch on these to surface
|
||||
// "needs upgrade" vs "version not reported" with the right user message.
|
||||
var (
|
||||
|
||||
@@ -2281,7 +2281,7 @@ func (q *Queries) CreateAgentBuilder(ctx context.Context, arg CreateAgentBuilder
|
||||
const createAgentTask = `-- name: CreateAgentTask :one
|
||||
INSERT INTO agent_task_queue (
|
||||
agent_id, runtime_id, issue_id, status, priority, trigger_comment_id,
|
||||
coalesced_comment_ids, trigger_summary, force_fresh_session, is_leader_task, handoff_note,
|
||||
coalesced_comment_ids, trigger_summary, force_fresh_session, is_leader_task,
|
||||
squad_id, context, originator_user_id, accountable_user_id, runtime_mcp_overlay, runtime_connected_apps,
|
||||
originator_source, delegated_from_task_id, rule_version_id, rerun_of_task_id, trigger_evidence_kind, trigger_evidence_ref_id,
|
||||
id
|
||||
@@ -2293,12 +2293,12 @@ SELECT
|
||||
COALESCE($8::boolean, FALSE),
|
||||
COALESCE($9::boolean, FALSE),
|
||||
$10,
|
||||
$11,
|
||||
CASE
|
||||
WHEN COALESCE($12::text, '') <> ''
|
||||
THEN jsonb_build_object('head_sha', $12::text)
|
||||
WHEN COALESCE($11::text, '') <> ''
|
||||
THEN jsonb_build_object('head_sha', $11::text)
|
||||
ELSE NULL
|
||||
END,
|
||||
$12,
|
||||
$13,
|
||||
$14,
|
||||
$15,
|
||||
@@ -2308,8 +2308,7 @@ SELECT
|
||||
$19,
|
||||
$20,
|
||||
$21,
|
||||
$22,
|
||||
COALESCE($23::uuid, gen_random_uuid())
|
||||
COALESCE($22::uuid, gen_random_uuid())
|
||||
WHERE lock_task_owner_rows($1, $3, $2)
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, session_rollout_missing, retired_session_id, quick_actions_disabled, regenerate_quick_actions_for, branch_name, durable_work_dir, channel_context_revision
|
||||
`
|
||||
@@ -2324,7 +2323,6 @@ type CreateAgentTaskParams struct {
|
||||
TriggerSummary pgtype.Text `json:"trigger_summary"`
|
||||
ForceFreshSession pgtype.Bool `json:"force_fresh_session"`
|
||||
IsLeaderTask pgtype.Bool `json:"is_leader_task"`
|
||||
HandoffNote pgtype.Text `json:"handoff_note"`
|
||||
SquadID pgtype.UUID `json:"squad_id"`
|
||||
HeadSha pgtype.Text `json:"head_sha"`
|
||||
OriginatorUserID pgtype.UUID `json:"originator_user_id"`
|
||||
@@ -2369,7 +2367,6 @@ func (q *Queries) CreateAgentTask(ctx context.Context, arg CreateAgentTaskParams
|
||||
arg.TriggerSummary,
|
||||
arg.ForceFreshSession,
|
||||
arg.IsLeaderTask,
|
||||
arg.HandoffNote,
|
||||
arg.SquadID,
|
||||
arg.HeadSha,
|
||||
arg.OriginatorUserID,
|
||||
@@ -2591,7 +2588,7 @@ func (q *Queries) CreateDeferredAgentTask(ctx context.Context, arg CreateDeferre
|
||||
const createDeferredChannelIssueTask = `-- name: CreateDeferredChannelIssueTask :one
|
||||
INSERT INTO agent_task_queue (
|
||||
agent_id, runtime_id, issue_id, status, priority, trigger_comment_id,
|
||||
coalesced_comment_ids, trigger_summary, force_fresh_session, is_leader_task, handoff_note,
|
||||
coalesced_comment_ids, trigger_summary, force_fresh_session, is_leader_task,
|
||||
squad_id, context, originator_user_id, accountable_user_id, runtime_mcp_overlay, runtime_connected_apps,
|
||||
originator_source, delegated_from_task_id, rule_version_id, rerun_of_task_id,
|
||||
trigger_evidence_kind, trigger_evidence_ref_id, fire_at,
|
||||
@@ -2604,11 +2601,11 @@ SELECT
|
||||
COALESCE($8::boolean, FALSE),
|
||||
COALESCE($9::boolean, FALSE),
|
||||
$10,
|
||||
$11,
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'head_sha', NULLIF(COALESCE($12::text, ''), ''),
|
||||
'head_sha', NULLIF(COALESCE($11::text, ''), ''),
|
||||
'channel_issue_media_pending', TRUE
|
||||
)),
|
||||
$12,
|
||||
$13,
|
||||
$14,
|
||||
$15,
|
||||
@@ -2619,8 +2616,7 @@ SELECT
|
||||
$20,
|
||||
$21,
|
||||
$22,
|
||||
$23,
|
||||
COALESCE($24::uuid, gen_random_uuid())
|
||||
COALESCE($23::uuid, gen_random_uuid())
|
||||
WHERE lock_task_owner_rows($1, $3, $2)
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, trigger_summary, force_fresh_session, is_leader_task, wait_reason, initiator_user_id, handoff_note, prepare_lease_expires_at, squad_id, runtime_mcp_overlay, escalation_for_task_id, fire_at, originator_user_id, runtime_connected_apps, coalesced_comment_ids, delivered_comment_ids, chat_input_task_id, chat_finalize_deferred_at, originator_source, delegated_from_task_id, retry_of_task_id, rerun_of_task_id, rule_version_id, trigger_evidence_kind, trigger_evidence_ref_id, accountable_user_id, session_rollout_missing, retired_session_id, quick_actions_disabled, regenerate_quick_actions_for, branch_name, durable_work_dir, channel_context_revision
|
||||
`
|
||||
@@ -2635,7 +2631,6 @@ type CreateDeferredChannelIssueTaskParams struct {
|
||||
TriggerSummary pgtype.Text `json:"trigger_summary"`
|
||||
ForceFreshSession pgtype.Bool `json:"force_fresh_session"`
|
||||
IsLeaderTask pgtype.Bool `json:"is_leader_task"`
|
||||
HandoffNote pgtype.Text `json:"handoff_note"`
|
||||
SquadID pgtype.UUID `json:"squad_id"`
|
||||
HeadSha pgtype.Text `json:"head_sha"`
|
||||
OriginatorUserID pgtype.UUID `json:"originator_user_id"`
|
||||
@@ -2670,7 +2665,6 @@ func (q *Queries) CreateDeferredChannelIssueTask(ctx context.Context, arg Create
|
||||
arg.TriggerSummary,
|
||||
arg.ForceFreshSession,
|
||||
arg.IsLeaderTask,
|
||||
arg.HandoffNote,
|
||||
arg.SquadID,
|
||||
arg.HeadSha,
|
||||
arg.OriginatorUserID,
|
||||
|
||||
@@ -299,7 +299,7 @@ ORDER BY created_at DESC;
|
||||
-- The same pattern is used by every INSERT listed in pkg/dbid's write table.
|
||||
INSERT INTO agent_task_queue (
|
||||
agent_id, runtime_id, issue_id, status, priority, trigger_comment_id,
|
||||
coalesced_comment_ids, trigger_summary, force_fresh_session, is_leader_task, handoff_note,
|
||||
coalesced_comment_ids, trigger_summary, force_fresh_session, is_leader_task,
|
||||
squad_id, context, originator_user_id, accountable_user_id, runtime_mcp_overlay, runtime_connected_apps,
|
||||
originator_source, delegated_from_task_id, rule_version_id, rerun_of_task_id, trigger_evidence_kind, trigger_evidence_ref_id,
|
||||
id
|
||||
@@ -310,7 +310,6 @@ SELECT
|
||||
sqlc.narg(trigger_summary),
|
||||
COALESCE(sqlc.narg('force_fresh_session')::boolean, FALSE),
|
||||
COALESCE(sqlc.narg('is_leader_task')::boolean, FALSE),
|
||||
sqlc.narg(handoff_note),
|
||||
sqlc.narg(squad_id),
|
||||
CASE
|
||||
WHEN COALESCE(sqlc.narg('head_sha')::text, '') <> ''
|
||||
@@ -341,7 +340,7 @@ RETURNING *;
|
||||
-- binding settles or the fire_at fallback is promoted by the normal sweeper.
|
||||
INSERT INTO agent_task_queue (
|
||||
agent_id, runtime_id, issue_id, status, priority, trigger_comment_id,
|
||||
coalesced_comment_ids, trigger_summary, force_fresh_session, is_leader_task, handoff_note,
|
||||
coalesced_comment_ids, trigger_summary, force_fresh_session, is_leader_task,
|
||||
squad_id, context, originator_user_id, accountable_user_id, runtime_mcp_overlay, runtime_connected_apps,
|
||||
originator_source, delegated_from_task_id, rule_version_id, rerun_of_task_id,
|
||||
trigger_evidence_kind, trigger_evidence_ref_id, fire_at,
|
||||
@@ -353,7 +352,6 @@ SELECT
|
||||
sqlc.narg(trigger_summary),
|
||||
COALESCE(sqlc.narg('force_fresh_session')::boolean, FALSE),
|
||||
COALESCE(sqlc.narg('is_leader_task')::boolean, FALSE),
|
||||
sqlc.narg(handoff_note),
|
||||
sqlc.narg(squad_id),
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'head_sha', NULLIF(COALESCE(sqlc.narg('head_sha')::text, ''), ''),
|
||||
|
||||
Reference in New Issue
Block a user