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:
Jiayuan Zhang
2026-09-03 16:13:33 +08:00
committed by GitHub
co-authored by multica-agent Lambda
parent 571e61128d
commit d276991c60
48 changed files with 126 additions and 636 deletions
@@ -13,12 +13,11 @@ import { Callout } from "fumadocs-ui/components/callout";
1. タスクを開き、**担当者**をクリックします。
2. エージェントまたはスクワッドを選択します。
3. 確認ダイアログで、これから開始するエージェントを確認します。
4. 必要に応じて引き継ぎメモを記入し、**開始**を選択します。
3. 確認ダイアログで、これから開始するエージェントを確認し、**開始**を選択します。
![割り当ての確認ダイアログ:引き継ぎメモを記入するか、まだ開始しないことを選べます](/images/docs/assign-confirm-dialog.webp)
![割り当ての確認ダイアログ:割り当てを確認するか、まだ開始しないことを選べます](/images/docs/assign-confirm-dialog.webp)
引き継ぎメモは今回の実行にのみ適用されます。範囲、順序、今回とくに注目してほしい点の補足に使ってください。長期的に有効な要件は、タスク本文、プロジェクトの説明、またはエージェントの指示に書きます。
エージェントに伝えたいことは、タスク本文とその議論、あるいはプロジェクトの説明やエージェントの指示に書きます。
スクワッドに割り当てると、タスクの担当者はスクワッドになり、最初に実行を開始するのはスクワッドのリーダーです。リーダーはスクワッドの運用方針に従って、他のメンバーに作業を渡すかどうかを判断します。
@@ -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. 확인 창에서 시작될 에이전트를 확인한 뒤 **시작**을 선택합니다.
![할당 확인 창: 인계 설명을 입력하거나 지금은 시작하지 않도록 선택할 수 있습니다.](/images/docs/assign-confirm-dialog.webp)
![할당 확인 창: 할당을 확인하거나 지금은 시작하지 않도록 선택할 수 있습니다.](/images/docs/assign-confirm-dialog.webp)
인계 설명은 이번 실행에만 사용됩니다. 범위, 순서, 이번에 특히 주의할 점을 보충하기에 적합합니다. 장기적으로 적용할 요구 사항은 태스크, 프로젝트 설명, 에이전트 지시문에 작성하세요.
에이전트가 알아야 할 내용은 태스크 본문과 그 논의, 또는 프로젝트 설명과 에이전트 지시문에 작성하세요.
스쿼드에 할당하면 태스크 담당자는 스쿼드가 되고 스쿼드 리더가 먼저 실행을 시작합니다. 리더는 스쿼드의 작업 방식에 따라 다른 멤버에게 넘길지 판단합니다.
@@ -43,7 +42,7 @@ Multica가 에이전트의 [작업](/tasks)를 만듭니다. 온라인 런타임
`backlog` 상태에서도 에이전트를 할당하는 것만으로 실행이 시작되지 않습니다. 태스크를 `backlog`에서 다른 상태로 옮기거나 댓글에서 담당자에게 추가 요청을 해야 이후 실행이 생성됩니다.
이미 에이전트나 스쿼드 담당자가 있는 작업을 `backlog` 밖으로 옮기면 같은 확인 대화상자가 열립니다. 실행을 시작하는 것이 바로 이 상태 변경이므로 동일한 인계 메모를 남길 수 있고, **아직 시작 안 함**도 그대로 사용할 수 있습니다(상태만 옮기고 에이전트는 깨우지 않습니다). 이 대화상자는 `backlog`·`done`·`cancelled` 이외 카테고리로 옮길 때 나타납니다.
이미 에이전트나 스쿼드 담당자가 있는 작업을 `backlog` 밖으로 옮기면 같은 확인 대화상자가 열립니다. 실행을 시작하는 것이 바로 이 상태 변경이므로 **아직 시작 안 함**도 그대로 사용할 수 있습니다(상태만 옮기고 에이전트는 깨우지 않습니다). 이 대화상자는 `backlog`·`done`·`cancelled` 이외 카테고리로 옮길 때 나타납니다.
이 효과는 `backlog` 카테고리에만 적용되며, 그 카테고리의 [사용자 지정 상태](/issues#사용자-지정-상태)도 똑같이 동작합니다. 이미 `done` 또는 `cancelled` 상태인 태스크도 에이전트에게 할당하거나 다시 할당하면 즉시 실행이 시작됩니다.
+4 -5
View File
@@ -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**.
![Assignment confirmation dialog: add a handoff note, or choose not to start yet](/images/docs/assign-confirm-dialog.webp)
![Assignment confirmation dialog: confirm the assignment, or choose not to start yet](/images/docs/assign-confirm-dialog.webp)
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. 在确认窗口中检查即将启动的智能体,然后选择**开始**。
![分配确认窗:可填写交接说明,或选择暂不开始](/images/docs/assign-confirm-dialog.webp)
![分配确认窗:确认分配,或选择暂不开始](/images/docs/assign-confirm-dialog.webp)
交接说明只用于这一次执行,适合补充范围、顺序或本次的注意重点。长期有效的要求写进任务、项目说明或智能体指令中。
这次执行需要智能体知道的信息,都写在任务本身——它的说明、讨论,或者项目说明与智能体指令中。
分配给小队时,任务的负责人是小队,首先开始执行的是小队 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

+3 -4
View File
@@ -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,
});
});
-2
View File
@@ -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),
+2 -5
View File
@@ -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);
+4 -30
View File
@@ -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);
-7
View File
@@ -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
+1 -8
View File
@@ -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),
};
}
-1
View File
@@ -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",
-3
View File
@@ -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",
-1
View File
@@ -589,7 +589,6 @@
"trigger_autopilot": "オートパイロット実行",
"trigger_comment": "コメントトリガー",
"trigger_initial": "初回実行",
"trigger_handoff_prefix": "引き継ぎ:",
"included_comments_other": "{{count}} 件のコメントを含む",
"status_queued": "待機中",
"status_dispatched": "開始中",
-3
View File
@@ -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": "まだ開始しない",
-1
View File
@@ -589,7 +589,6 @@
"trigger_autopilot": "오토파일럿 실행",
"trigger_comment": "댓글 트리거",
"trigger_initial": "초기 실행",
"trigger_handoff_prefix": "인계: ",
"included_comments_other": "댓글 {{count}}개 포함",
"status_queued": "대기 중",
"status_dispatched": "시작 중",
-3
View File
@@ -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,
}),
}));
+21 -114
View File
@@ -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));
});
+12 -76
View File
@@ -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>
-1
View File
@@ -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)
}
}
-7
View File
@@ -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()
-1
View File
@@ -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"}},
-1
View File
@@ -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
-6
View File
@@ -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,
-4
View File
@@ -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.
+2 -7
View File
@@ -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,
+11 -34
View File
@@ -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) {
+4 -6
View File
@@ -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
+3 -4
View File
@@ -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 {
+26 -32
View File
@@ -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
-26
View File
@@ -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)
}
})
}
}
-32
View File
@@ -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 (
+9 -15
View File
@@ -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,
+2 -4
View File
@@ -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, ''), ''),