diff --git a/apps/docs/content/docs/issue-wakeups.fr.mdx b/apps/docs/content/docs/issue-wakeups.fr.mdx index d0d535aa9..1c8fbd4fd 100644 --- a/apps/docs/content/docs/issue-wakeups.fr.mdx +++ b/apps/docs/content/docs/issue-wakeups.fr.mdx @@ -140,7 +140,7 @@ multica issue wakeup create OPS-56 --until-status in_review --instruction "Véri - **Temps** : `--kind at` avec `--after 10m` ou `--at ` ; `--kind every --every 1h` pour un intervalle fixe d'au moins 1 minute ; `--kind cron --cron ""` avec `--timezone` (UTC par défaut). - **Événements** : indiquez les noms d'événements après `--event`, séparés par des virgules. `multica issue wakeup events` liste tous les événements et filtres. `--mode continuous` rend la règle répétée. -- **Conditions** : `--until-status`, `--until-assignee`, `--until-label`, `--until-property`, `--until-children-done [--stage N]`, `--until-pr checks|merged|conflicts` et `--until-issue --until-issue-state done|ended|in_review`. Un seul `--until-*` par réveil, et jamais avec `--event`. +- **Conditions** : `--until-status`, `--until-assignee`, `--until-label`, `--until-property`, `--until-children-done [--stage N]`, `--until-pr checks|merged|conflicts` et `--until-issue --until-issue-state done|ended|in_review|blocked`. Un seul `--until-*` par réveil, et jamais avec `--event`. - **Fin** : le CLI n'ajoute pas d'échéance à votre place. Utilisez `--expires-in 72h` ou `--expires-at ` ; `--on-timeout wake` fait qu'une règle d'événement réveille l'agent une dernière fois à l'échéance. `--max-fires N` (1–1000) limite le nombre d'exécutions d'une règle répétée. - **Où va la réponse** : `--parent ` garde la réponse de l'exécution réveillée dans le fil d'origine. diff --git a/apps/docs/content/docs/issue-wakeups.ja.mdx b/apps/docs/content/docs/issue-wakeups.ja.mdx index 99e52e5c2..31c39e9d9 100644 --- a/apps/docs/content/docs/issue-wakeups.ja.mdx +++ b/apps/docs/content/docs/issue-wakeups.ja.mdx @@ -140,7 +140,7 @@ multica issue wakeup create OPS-56 --until-status in_review --instruction "成 - **時間**:`--kind at` に `--after 10m` または `--at `。`--kind every --every 1h` で一定間隔(最短 1 分)。`--kind cron --cron "<5 フィールドの式>"` には `--timezone` を付けます(既定は UTC)。 - **イベント**:`--event` のあとにイベント名をカンマ区切りで書きます。`multica issue wakeup events` ですべてのイベントとフィルターを一覧できます。`--mode continuous` で繰り返し起動になります。 -- **条件**:`--until-status`、`--until-assignee`、`--until-label`、`--until-property`、`--until-children-done [--stage N]`、`--until-pr checks|merged|conflicts`、`--until-issue <タスク> --until-issue-state done|ended|in_review`。1 つのウェイクアップに使える `--until-*` は 1 つだけで、`--event` とは併用できません。 +- **条件**:`--until-status`、`--until-assignee`、`--until-label`、`--until-property`、`--until-children-done [--stage N]`、`--until-pr checks|merged|conflicts`、`--until-issue <タスク> --until-issue-state done|ended|in_review|blocked`。1 つのウェイクアップに使える `--until-*` は 1 つだけで、`--event` とは併用できません。 - **終わり方**:CLI は待つ期限を自動では付けません。`--expires-in 72h` か `--expires-at ` で終わりを決めます。`--on-timeout wake` を付けると、イベントのルールは期限が来たときにもう一度起こします。`--max-fires N`(1〜1000)で繰り返すルールの実行回数に上限を付けます。 - **返信先**:`--parent <コメント ID>` を付けると、起動後の返信が元のスレッドに入ります。 diff --git a/apps/docs/content/docs/issue-wakeups.ko.mdx b/apps/docs/content/docs/issue-wakeups.ko.mdx index b269cca41..e46ff37c1 100644 --- a/apps/docs/content/docs/issue-wakeups.ko.mdx +++ b/apps/docs/content/docs/issue-wakeups.ko.mdx @@ -140,7 +140,7 @@ multica issue wakeup create OPS-56 --until-status in_review --instruction "결 - **시간**: `--kind at`에 `--after 10m` 또는 `--at `. `--kind every --every 1h`는 고정 간격(최소 1분). `--kind cron --cron "<5개 필드 표현식>"`에는 `--timezone`을 붙입니다(기본 UTC). - **이벤트**: `--event` 뒤에 이벤트 이름을 쉼표로 구분해 씁니다. `multica issue wakeup events`가 모든 이벤트와 필터를 보여 줍니다. `--mode continuous`는 반복 깨우기로 만듭니다. -- **조건**: `--until-status`, `--until-assignee`, `--until-label`, `--until-property`, `--until-children-done [--stage N]`, `--until-pr checks|merged|conflicts`, `--until-issue <태스크> --until-issue-state done|ended|in_review`. 깨우기 하나에 `--until-*`는 하나만 쓸 수 있고 `--event`와 함께 쓸 수 없습니다. +- **조건**: `--until-status`, `--until-assignee`, `--until-label`, `--until-property`, `--until-children-done [--stage N]`, `--until-pr checks|merged|conflicts`, `--until-issue <태스크> --until-issue-state done|ended|in_review|blocked`. 깨우기 하나에 `--until-*`는 하나만 쓸 수 있고 `--event`와 함께 쓸 수 없습니다. - **끝내는 방법**: CLI는 대기 기한을 자동으로 붙이지 않습니다. `--expires-in 72h`나 `--expires-at `으로 끝을 정하세요. `--on-timeout wake`를 붙이면 이벤트 규칙이 기한이 되었을 때 한 번 더 깨웁니다. `--max-fires N`(1–1000)은 반복 규칙의 실행 횟수에 상한을 둡니다. - **답이 남는 곳**: `--parent <댓글 ID>`를 붙이면 깨운 뒤의 답글이 원래 스레드에 남습니다. diff --git a/apps/docs/content/docs/issue-wakeups.mdx b/apps/docs/content/docs/issue-wakeups.mdx index 7eb1fdc3d..ec79ceedd 100644 --- a/apps/docs/content/docs/issue-wakeups.mdx +++ b/apps/docs/content/docs/issue-wakeups.mdx @@ -140,7 +140,7 @@ multica issue wakeup create OPS-56 --until-status in_review --instruction "Check - **Time**: `--kind at` with `--after 10m` or `--at `; `--kind every --every 1h` for a fixed interval of at least 1 minute; `--kind cron --cron "<5-field expression>"` with `--timezone` (UTC by default). - **Events**: list event names after `--event`, separated by commas. `multica issue wakeup events` lists every event and filter. `--mode continuous` makes the rule repeat. -- **Conditions**: `--until-status`, `--until-assignee`, `--until-label`, `--until-property`, `--until-children-done [--stage N]`, `--until-pr checks|merged|conflicts`, and `--until-issue --until-issue-state done|ended|in_review`. Use one `--until-*` per wakeup, and not together with `--event`. +- **Conditions**: `--until-status`, `--until-assignee`, `--until-label`, `--until-property`, `--until-children-done [--stage N]`, `--until-pr checks|merged|conflicts`, and `--until-issue --until-issue-state done|ended|in_review|blocked`. Use one `--until-*` per wakeup, and not together with `--event`. - **Endings**: the CLI does not add a deadline for you. Use `--expires-in 72h` or `--expires-at `; `--on-timeout wake` makes an event rule wake the agent once more at the deadline. `--max-fires N` (1–1000) caps how many runs a repeating rule starts. - **Where results go**: `--parent ` keeps the woken run's reply in the original thread. diff --git a/apps/docs/content/docs/issue-wakeups.zh.mdx b/apps/docs/content/docs/issue-wakeups.zh.mdx index 6b60b8958..b0664a8bd 100644 --- a/apps/docs/content/docs/issue-wakeups.zh.mdx +++ b/apps/docs/content/docs/issue-wakeups.zh.mdx @@ -140,7 +140,7 @@ multica issue wakeup create OPS-56 --until-status in_review --instruction "检 - **时间**:`--kind at` 配 `--after 10m` 或 `--at `;`--kind every --every 1h` 按固定间隔,最短 1 分钟;`--kind cron --cron "<5 段表达式>"` 配 `--timezone`,时区默认 UTC。 - **事件**:`--event` 后面写事件名,多个用逗号分隔,`multica issue wakeup events` 列出全部事件和过滤参数。`--mode continuous` 改成重复唤醒。 -- **条件**:`--until-status`、`--until-assignee`、`--until-label`、`--until-property`、`--until-children-done [--stage N]`、`--until-pr checks|merged|conflicts`、`--until-issue <任务> --until-issue-state done|ended|in_review`。一条唤醒只能用一个 `--until-*`,不能和 `--event` 同时用。 +- **条件**:`--until-status`、`--until-assignee`、`--until-label`、`--until-property`、`--until-children-done [--stage N]`、`--until-pr checks|merged|conflicts`、`--until-issue <任务> --until-issue-state done|ended|in_review|blocked`。一条唤醒只能用一个 `--until-*`,不能和 `--event` 同时用。 - **结束方式**:CLI 不会自动给等待加期限,用 `--expires-in 72h` 或 `--expires-at ` 设结束时间;`--on-timeout wake` 让事件规则在超时时再唤醒一次。`--max-fires N`(1–1000)限制重复规则最多运行几次。 - **结果放在哪**:`--parent <评论 ID>` 让唤醒后的回复留在原来的评论串里。 diff --git a/docs/engineering/issue-wakeups.md b/docs/engineering/issue-wakeups.md index c02d01de0..ece401332 100644 --- a/docs/engineering/issue-wakeups.md +++ b/docs/engineering/issue-wakeups.md @@ -580,7 +580,7 @@ the scheduler evaluates: | `issue_field` | `field=property`, `property_id`, `value` | the property's stored value equals `value` | | `children_done` | optional `stage` | every sub-issue (or every one up to that stage) is closed | | `pull_request` | `event=checks_finished`, `merged` or `conflicts` | a linked PR's checks finished on its current head, it merged, or an open GitHub PR is `CONFLICTING` on its current head | -| `other_issue` | `issue_id`, `state=done`, `ended` or `in_review` | another issue in the workspace reaches that state | +| `other_issue` | `issue_id`, `state=done`, `ended`, `in_review` or `blocked` | another issue in the workspace reaches that state | A condition rule stays `kind=event`. Validation derives the same-issue events that can change the fact (for example `issue.status_changed`) as hints: the diff --git a/packages/core/api/issue-wakeup.test.ts b/packages/core/api/issue-wakeup.test.ts index cd88bf523..aa32e6c91 100644 --- a/packages/core/api/issue-wakeup.test.ts +++ b/packages/core/api/issue-wakeup.test.ts @@ -231,12 +231,14 @@ it("reads conditions, caps and pauses, and drops shapes it does not know", async { ...base, condition: { type: "issue_field", field: "status", value: "in_review" }, max_fires: 20, fire_count: 20, paused_reason: "max_fires" }, { ...base, id: "w2", condition: { type: "future_kind" }, paused_reason: "someday" }, { ...base, id: "w3", condition: { type: "other_issue", issue_id: "x", state: "done", identifier: "MUL-2" } }, + { ...base, id: "w4", condition: { type: "other_issue", issue_id: "y", state: "blocked", identifier: "MUL-3" } }, ]; vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(rows)))); - const [known, unknown, other] = await client.listIssueWakeups("i"); + const [known, unknown, other, blocked] = await client.listIssueWakeups("i"); expect(known).toMatchObject({ condition: { field: "status", value: "in_review" }, max_fires: 20, fire_count: 20, paused_reason: "max_fires" }); expect(unknown).toMatchObject({ condition: null, paused_reason: null }); expect(other!.condition).toEqual({ type: "other_issue", issue_id: "x", state: "done", identifier: "MUL-2" }); + expect(blocked!.condition).toEqual({ type: "other_issue", issue_id: "y", state: "blocked", identifier: "MUL-3" }); }); it("reads system rule rows in the workspace list", async () => { diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index c783a9b92..7a618e564 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -3721,7 +3721,7 @@ export const WakeupConditionSchema = z.union([ z.object({ type: z.literal("issue_field"), field: z.literal("property"), property_id: z.string(), value: z.unknown() }), z.object({ type: z.literal("children_done"), stage: z.number().int().nullish() }), z.object({ type: z.literal("pull_request"), event: z.enum(["checks_finished", "merged", "conflicts"]) }), - z.object({ type: z.literal("other_issue"), issue_id: z.string(), state: z.enum(["done", "ended", "in_review"]), identifier: z.string().optional() }), + z.object({ type: z.literal("other_issue"), issue_id: z.string(), state: z.enum(["done", "ended", "in_review", "blocked"]), identifier: z.string().optional() }), ]); export const WakeupRunSchema = z.object({ diff --git a/packages/core/types/issue-wakeup.ts b/packages/core/types/issue-wakeup.ts index ecf4ebb4b..92ef3bbf5 100644 --- a/packages/core/types/issue-wakeup.ts +++ b/packages/core/types/issue-wakeup.ts @@ -54,7 +54,7 @@ export type WakeupCondition = | { type: "issue_field"; field: "property"; property_id: string; value: unknown } | { type: "children_done"; stage?: number | null } | { type: "pull_request"; event: "checks_finished" | "merged" | "conflicts" } - | { type: "other_issue"; issue_id: string; state: "done" | "ended" | "in_review"; identifier?: string }; + | { type: "other_issue"; issue_id: string; state: "done" | "ended" | "in_review" | "blocked"; identifier?: string }; /** One run a rule started, for its trigger history. */ export interface WakeupRun { diff --git a/packages/views/issues/components/wakeup-create.tsx b/packages/views/issues/components/wakeup-create.tsx index ee894adbe..194acc7c0 100644 --- a/packages/views/issues/components/wakeup-create.tsx +++ b/packages/views/issues/components/wakeup-create.tsx @@ -475,6 +475,7 @@ function ConditionParams({ { value: "done", label: t(($) => $.wakeups.create.other_done) }, { value: "ended", label: t(($) => $.wakeups.create.other_ended) }, { value: "in_review", label: t(($) => $.wakeups.create.other_in_review) }, + { value: "blocked", label: t(($) => $.wakeups.create.other_blocked) }, ]} onChange={(otherState) => update({ otherState: otherState as WakeupDraft["otherState"] })} /> diff --git a/packages/views/issues/components/wakeup-draft.test.ts b/packages/views/issues/components/wakeup-draft.test.ts index cc8227860..cc03326ca 100644 --- a/packages/views/issues/components/wakeup-draft.test.ts +++ b/packages/views/issues/components/wakeup-draft.test.ts @@ -91,6 +91,9 @@ describe("platform conditions", () => { expect(input({ condition: "other_issue", otherIssue: { id: "i2", identifier: "MUL-2" }, otherState: "ended" })).toMatchObject({ input: { condition: { type: "other_issue", issue_id: "i2", state: "ended" } }, }); + expect(input({ condition: "other_issue", otherIssue: { id: "i2", identifier: "MUL-2" }, otherState: "blocked" })).toMatchObject({ + input: { condition: { type: "other_issue", issue_id: "i2", state: "blocked" } }, + }); // Conditions never send raw events. const children = input({ condition: "children" }); expect("input" in children && children.input.event_types).toBeUndefined(); diff --git a/packages/views/issues/components/wakeup-draft.ts b/packages/views/issues/components/wakeup-draft.ts index 11423b18d..e34003d9b 100644 --- a/packages/views/issues/components/wakeup-draft.ts +++ b/packages/views/issues/components/wakeup-draft.ts @@ -80,7 +80,7 @@ export interface WakeupDraft { stage: number | null; prEvent: "checks_finished" | "merged" | "conflicts"; otherIssue: { id: string; identifier: string } | null; - otherState: "done" | "ended" | "in_review"; + otherState: "done" | "ended" | "in_review" | "blocked"; maxFires: number; agentId: string; instruction: string; diff --git a/packages/views/issues/components/wakeup-presentation.ts b/packages/views/issues/components/wakeup-presentation.ts index 626b4c650..4bf4bf772 100644 --- a/packages/views/issues/components/wakeup-presentation.ts +++ b/packages/views/issues/components/wakeup-presentation.ts @@ -242,7 +242,7 @@ export function useWakeupText() { }; case "other_issue": return { - key: c.state === "ended" ? ("other_ended" as const) : c.state === "in_review" ? ("other_in_review" as const) : ("other_done" as const), + key: c.state === "ended" ? ("other_ended" as const) : c.state === "in_review" ? ("other_in_review" as const) : c.state === "blocked" ? ("other_blocked" as const) : ("other_done" as const), values: { issue: c.identifier || t(($) => $.wakeups.cond.another_issue) }, }; } diff --git a/packages/views/locales/en/issues.json b/packages/views/locales/en/issues.json index 2bc472844..8e27abdd4 100644 --- a/packages/views/locales/en/issues.json +++ b/packages/views/locales/en/issues.json @@ -1303,6 +1303,7 @@ "other_done": "is done", "other_ended": "is done or cancelled", "other_in_review": "moves to In Review", + "other_blocked": "moves to Blocked", "max_fires": "Up to", "max_fires_times_one": "{{count}} time", "max_fires_times_other": "{{count}} times", @@ -1358,6 +1359,7 @@ "other_done": "When {{issue}} is done", "other_ended": "When {{issue}} is done or cancelled", "other_in_review": "When {{issue}} moves to In Review", + "other_blocked": "When {{issue}} moves to Blocked", "another_issue": "another issue", "unknown_label": "a deleted label", "unknown_property": "a deleted property", @@ -1381,6 +1383,7 @@ "other_done": "Waiting for {{issue}}", "other_ended": "Waiting for {{issue}} to end", "other_in_review": "Waiting for {{issue}} to reach review", + "other_blocked": "Waiting for {{issue}} to be blocked", "time": "{{time}} · wakes {{agent}}", "header": "{{agent}}: {{what}}", "paused": "Wakeup paused", diff --git a/packages/views/locales/fr/issues.json b/packages/views/locales/fr/issues.json index 3632e70e0..f95670ea4 100644 --- a/packages/views/locales/fr/issues.json +++ b/packages/views/locales/fr/issues.json @@ -1303,6 +1303,7 @@ "other_done": "est terminée", "other_ended": "est terminée ou annulée", "other_in_review": "passe en revue", + "other_blocked": "passe à Bloqué", "max_fires": "Au plus", "max_fires_times_one": "{{count}} fois", "max_fires_times_other": "{{count}} fois", @@ -1358,6 +1359,7 @@ "other_done": "Quand {{issue}} est terminée", "other_ended": "Quand {{issue}} est terminée ou annulée", "other_in_review": "Quand {{issue}} passe en revue", + "other_blocked": "Quand {{issue}} passe à Bloqué", "another_issue": "une autre tâche", "unknown_label": "un libellé supprimé", "unknown_property": "une propriété supprimée", @@ -1381,6 +1383,7 @@ "other_done": "Attend {{issue}}", "other_ended": "Attend la fin de {{issue}}", "other_in_review": "Attend la revue de {{issue}}", + "other_blocked": "Attend que {{issue}} passe à Bloqué", "time": "{{time}} · réveille {{agent}}", "header": "{{agent}} : {{what}}", "paused": "Réveil en pause", diff --git a/packages/views/locales/ja/issues.json b/packages/views/locales/ja/issues.json index 2e99655dc..1ea4242ab 100644 --- a/packages/views/locales/ja/issues.json +++ b/packages/views/locales/ja/issues.json @@ -1260,6 +1260,7 @@ "other_done": "完了", "other_ended": "完了またはキャンセル", "other_in_review": "レビュー中になる", + "other_blocked": "ブロック中になる", "max_fires": "最大", "max_fires_times_other": "{{count}} 回", "missing_value": "値を選択してください。", @@ -1313,6 +1314,7 @@ "other_done": "{{issue}} が完了したとき", "other_ended": "{{issue}} が完了またはキャンセルされたとき", "other_in_review": "{{issue}} がレビュー中になったとき", + "other_blocked": "{{issue}} がブロック中になったとき", "another_issue": "別のタスク", "unknown_label": "削除されたラベル", "unknown_property": "削除されたプロパティ", @@ -1336,6 +1338,7 @@ "other_done": "{{issue}} の完了待ち", "other_ended": "{{issue}} の終了待ち", "other_in_review": "{{issue}} のレビュー待ち", + "other_blocked": "{{issue}} のブロック待ち", "time": "{{time}} に {{agent}} を起こす", "header": "{{agent}}:{{what}}", "paused": "起動を一時停止中", diff --git a/packages/views/locales/ko/issues.json b/packages/views/locales/ko/issues.json index 0acfaa86d..2a7430692 100644 --- a/packages/views/locales/ko/issues.json +++ b/packages/views/locales/ko/issues.json @@ -1260,6 +1260,7 @@ "other_done": "완료", "other_ended": "완료 또는 취소", "other_in_review": "검토 중으로 바뀜", + "other_blocked": "막힘으로 바뀜", "max_fires": "최대", "max_fires_times_other": "{{count}}회", "missing_value": "값을 선택하세요.", @@ -1313,6 +1314,7 @@ "other_done": "{{issue}}이(가) 완료되면", "other_ended": "{{issue}}이(가) 완료되거나 취소되면", "other_in_review": "{{issue}}이(가) 검토 중으로 바뀌면", + "other_blocked": "{{issue}}이(가) 막힘으로 바뀌면", "another_issue": "다른 태스크", "unknown_label": "삭제된 레이블", "unknown_property": "삭제된 속성", @@ -1336,6 +1338,7 @@ "other_done": "{{issue}} 완료 대기", "other_ended": "{{issue}} 종료 대기", "other_in_review": "{{issue}} 검토 대기", + "other_blocked": "{{issue}} 막힘 대기", "time": "{{time}}에 {{agent}} 깨우기", "header": "{{agent}}: {{what}}", "paused": "깨우기 일시중지됨", diff --git a/packages/views/locales/zh-Hans/issues.json b/packages/views/locales/zh-Hans/issues.json index 2bb7f92f8..5903812c1 100644 --- a/packages/views/locales/zh-Hans/issues.json +++ b/packages/views/locales/zh-Hans/issues.json @@ -1260,6 +1260,7 @@ "other_done": "完成", "other_ended": "完成或取消", "other_in_review": "进入审核中", + "other_blocked": "进入 blocked", "max_fires": "最多触发", "max_fires_times_other": "{{count}} 次", "missing_value": "请选择一个值。", @@ -1313,6 +1314,7 @@ "other_done": "当 {{issue}} 完成时", "other_ended": "当 {{issue}} 完成或取消时", "other_in_review": "当 {{issue}} 进入审核中时", + "other_blocked": "当 {{issue}} 进入 blocked 时", "another_issue": "另一个任务", "unknown_label": "已删除的标签", "unknown_property": "已删除的属性", @@ -1336,6 +1338,7 @@ "other_done": "等 {{issue}} 完成", "other_ended": "等 {{issue}} 结束", "other_in_review": "等 {{issue}} 进入审核", + "other_blocked": "等 {{issue}} 进入 blocked", "time": "{{time}} 唤醒 {{agent}}", "header": "{{agent}} 在{{what}}", "paused": "唤醒已自动暂停", diff --git a/packages/views/locales/zh-Hant/issues.json b/packages/views/locales/zh-Hant/issues.json index f351e75b2..982f345ec 100644 --- a/packages/views/locales/zh-Hant/issues.json +++ b/packages/views/locales/zh-Hant/issues.json @@ -1260,6 +1260,7 @@ "other_done": "完成", "other_ended": "完成或取消", "other_in_review": "進入稽覈中", + "other_blocked": "進入 blocked", "max_fires": "最多觸發", "max_fires_times_other": "{{count}} 次", "missing_value": "請選擇一個值。", @@ -1313,6 +1314,7 @@ "other_done": "當 {{issue}} 完成時", "other_ended": "當 {{issue}} 完成或取消時", "other_in_review": "當 {{issue}} 進入稽覈中時", + "other_blocked": "當 {{issue}} 進入 blocked 時", "another_issue": "另一個任務", "unknown_label": "已刪除的標籤", "unknown_property": "已刪除的屬性", @@ -1336,6 +1338,7 @@ "other_done": "等 {{issue}} 完成", "other_ended": "等 {{issue}} 結束", "other_in_review": "等 {{issue}} 進入稽覈", + "other_blocked": "等 {{issue}} 進入 blocked", "time": "{{time}} 喚醒 {{agent}}", "header": "{{agent}} 在{{what}}", "paused": "喚醒已自動暫停", diff --git a/server/cmd/multica/cmd_issue_wakeup.go b/server/cmd/multica/cmd_issue_wakeup.go index e99ea6ede..10c6e545f 100644 --- a/server/cmd/multica/cmd_issue_wakeup.go +++ b/server/cmd/multica/cmd_issue_wakeup.go @@ -21,7 +21,7 @@ func init() { issueCmd.AddCommand(newIssueWakeupCommand()) } func newIssueWakeupCommand() *cobra.Command { wake := &cobra.Command{Use: "wakeup", Short: "Manage event and time wakeups that start ordinary runs"} wake.AddCommand(&cobra.Command{Use: "events", Short: "List supported event types and filters", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return cli.PrintJSON(os.Stdout, map[string]any{"event_types": eventcontract.WakeupTypes, "platform_only_event_types": eventcontract.LifecycleTypes, "platform_only_reason": "Creation precedes subscription; deletion withdraws the issue's wakeups. Cross-issue subscriptions are not supported.", "scope": "current issue", "filters": []string{"filter_agent_id (task events; legacy mutation alias)", "filter_task_id (task events only)", "filter_actor_type + filter_actor_id (member or agent; mutation events only)"}, "modes": []string{"once (default)", "continuous"}, "conditions": []string{"--until-status KEY", "--until-assignee member|agent|squad:ID", "--until-label LABEL_ID", "--until-property PROPERTY_ID=VALUE", "--until-children-done [--stage N]", "--until-pr checks|merged|conflicts", "--until-issue ISSUE [--until-issue-state done|ended|in_review]"}, "loop_protection": "Events from the registering run and runs started by the same rule are excluded when source identity is available. Repeating rules stop after --max-fires runs (default 20 for continuous event rules). A rule is paused when its trigger chain passes through it a third time without a person in between, or when it starts more than 12 runs in an hour."}) + return cli.PrintJSON(os.Stdout, map[string]any{"event_types": eventcontract.WakeupTypes, "platform_only_event_types": eventcontract.LifecycleTypes, "platform_only_reason": "Creation precedes subscription; deletion withdraws the issue's wakeups. Cross-issue subscriptions are not supported.", "scope": "current issue", "filters": []string{"filter_agent_id (task events; legacy mutation alias)", "filter_task_id (task events only)", "filter_actor_type + filter_actor_id (member or agent; mutation events only)"}, "modes": []string{"once (default)", "continuous"}, "conditions": []string{"--until-status KEY", "--until-assignee member|agent|squad:ID", "--until-label LABEL_ID", "--until-property PROPERTY_ID=VALUE", "--until-children-done [--stage N]", "--until-pr checks|merged|conflicts", "--until-issue ISSUE [--until-issue-state done|ended|in_review|blocked]"}, "loop_protection": "Events from the registering run and runs started by the same rule are excluded when source identity is available. Repeating rules stop after --max-fires runs (default 20 for continuous event rules). A rule is paused when its trigger chain passes through it a third time without a person in between, or when it starts more than 12 runs in an hour."}) }}) for _, action := range []string{"list", "get", "disable", "create", "update", "trigger", "delete", "checkin", "runs"} { action := action @@ -83,7 +83,7 @@ func newIssueWakeupCommand() *cobra.Command { c.Flags().Int32("stage", 0, "With --until-children-done: wait for this stage and earlier ones") c.Flags().String("until-pr", "", "Condition: a linked pull request's checks finish (checks), it merges (merged), or it has merge conflicts on its current head (conflicts; GitHub only)") c.Flags().String("until-issue", "", "Condition: another issue in this workspace reaches --until-issue-state") - c.Flags().String("until-issue-state", "done", "With --until-issue: done, ended (done or cancelled) or in_review") + c.Flags().String("until-issue-state", "done", "With --until-issue: done, ended (done or cancelled), in_review or blocked") c.Flags().Int32("max-fires", 0, "Repeating rules: stop after this many runs (1-1000; continuous event rules default to 20)") } wake.AddCommand(c) diff --git a/server/internal/service/builtin_skills/multica-platform/references/issues.md b/server/internal/service/builtin_skills/multica-platform/references/issues.md index e7fa9b868..ee621537e 100644 --- a/server/internal/service/builtin_skills/multica-platform/references/issues.md +++ b/server/internal/service/builtin_skills/multica-platform/references/issues.md @@ -459,7 +459,7 @@ multica issue wakeup create --until-issue --until-issue - `--until-status KEY` — this issue's status is that key (built-in or one of the workspace's statuses). - `--until-pr checks` — a linked pull request's checks finished, passing or failing, on its current head. `--until-pr merged` — a linked pull request merged. `--until-pr conflicts` — an open linked GitHub pull request has merge conflicts on its current head, for example after another PR merged into its base; with `--mode continuous` it fires again each time a resolved conflict comes back or a new head is still conflicting. With several linked pull requests, any one satisfies it. These conditions inspect only PRs linked directly to this issue; they do not inherit a parent issue's PRs. Checks and conflicts also require server-side GitHub snapshots; conflicts are not detected on Forgejo, Gitea or GitLab pull requests. A missing link or snapshot is recorded in the rule's `last_error` and retried automatically rather than rejecting the rule. - `--until-children-done` — every sub-issue, staged or not, is closed (`done` or `cancelled`). With `--stage N` it waits only for staged sub-issues up to stage N, and stage N must have at least one. A parent with no sub-issues never fires. The parent's assignee already gets the sub-issue wakeup described under Stages; do not add this condition for the same wake. -- `--until-issue ISSUE` — another issue in this workspace (identifier or UUID) reaches `--until-issue-state`: `done` (default), `ended` (done or cancelled), or `in_review`. +- `--until-issue ISSUE` — another issue in this workspace (identifier or UUID) reaches `--until-issue-state`: `done` (default), `ended` (done or cancelled), `in_review`, or `blocked`. - The same form also accepts `--until-assignee member|agent|squad:ID`, `--until-label LABEL_ID`, and `--until-property PROPERTY_ID=VALUE` (VALUE may be JSON). `multica issue wakeup create --help` lists them. - `wakeup create --kind at --after 10m --instruction-file ./instruction.md` schedules one run. Alternatively use `--at `. - `wakeup create --kind every --every 1h --instruction-file ./instruction.md` schedules a repeating check. Or use `--kind cron --cron '0 * * * *' --timezone Asia/Shanghai`. diff --git a/server/internal/service/issue_wakeup_condition.go b/server/internal/service/issue_wakeup_condition.go index 8a413c2d7..b4f25c798 100644 --- a/server/internal/service/issue_wakeup_condition.go +++ b/server/internal/service/issue_wakeup_condition.go @@ -49,7 +49,7 @@ type WakeupCondition struct { EachStage bool `json:"each_stage,omitempty"` // pull_request: checks_finished | merged | conflicts Event string `json:"event,omitempty"` - // other_issue: the watched issue and done | ended | in_review. The + // other_issue: the watched issue and done | ended | in_review | blocked. The // identifier is recorded for display; clients never set it. IssueID string `json:"issue_id,omitempty"` State string `json:"state,omitempty"` @@ -199,8 +199,8 @@ func validateCondition(ctx context.Context, tx pgx.Tx, issue db.Issue, raw json. if id == issue.ID { return nil, nil, badCondition("watch another issue, not this one") } - if c.State != "done" && c.State != "ended" && c.State != "in_review" { - return nil, nil, badCondition("state must be done, ended or in_review") + if c.State != "done" && c.State != "ended" && c.State != "in_review" && c.State != "blocked" { + return nil, nil, badCondition("state must be done, ended, in_review or blocked") } var identifier string err = tx.QueryRow(ctx, "SELECT ws.issue_prefix||'-'||i.number FROM issue i JOIN workspace ws ON ws.id=i.workspace_id WHERE i.workspace_id=$1 AND i.id=$2", ws, id).Scan(&identifier) @@ -414,6 +414,8 @@ func (s *IssueWakeupService) evaluateCondition(ctx context.Context, tx pgx.Tx, q met = category == "done" || category == "closed" case "in_review": met = status == "in_review" + case "blocked": + met = status == "blocked" } return met, "issue:" + status, map[string]any{"issue_id": c.IssueID, "status": status}, nil } diff --git a/server/internal/service/issue_wakeup_condition_test.go b/server/internal/service/issue_wakeup_condition_test.go index f0947347c..15e5c2e03 100644 --- a/server/internal/service/issue_wakeup_condition_test.go +++ b/server/internal/service/issue_wakeup_condition_test.go @@ -158,6 +158,55 @@ func TestWakeupConditionOtherIssueUsesScheduledChecks(t *testing.T) { } } +// A coordinator can wait for a child's blocker handoff: state=blocked matches +// only the blocked status, so a review handoff on the same child does not fire it. +func TestWakeupConditionOtherIssueBlocked(t *testing.T) { + f, s, issue, agent := conditionFixture(t) + ctx := context.Background() + workspace := parseTestUUID(t, f.WorkspaceID) + other := f.Issue(t, "Child that may block", testutil.Cols{"status": "in_progress"}) + w := wakeCreate(t, f, s, issue, WakeupInput{AgentID: agent, Kind: "event", Instruction: "Resolve the child's blocker", + Condition: condition(t, map[string]any{"type": "other_issue", "issue_id": other, "state": "blocked"})}) + tick := func() db.IssueWakeup { + t.Helper() + f.Exec(t, "UPDATE issue_wakeup SET next_fire_at=now()-interval '1 second' WHERE id=$1 AND next_fire_at IS NOT NULL", w.ID) + if err := s.TickWorkspaces(ctx, workspace); err != nil { + t.Fatal(err) + } + got, err := f.q.LocklessWakeup(ctx, w.ID) + if err != nil { + t.Fatal(err) + } + return got + } + wakeSetStatus(t, f, parseTestUUID(t, other), "in_review") + if got := tick(); !got.Enabled || got.FireCount != 0 || wakeRuns(t, f, w.ID) != 0 { + t.Fatalf("in_review fired a blocked rule: enabled=%t fire_count=%d", got.Enabled, got.FireCount) + } + wakeSetStatus(t, f, parseTestUUID(t, other), "blocked") + got := tick() + if got.Enabled || got.FireCount != 1 || wakeRuns(t, f, w.ID) != 1 { + t.Fatalf("blocked child: enabled=%t fire_count=%d last_error=%+v", got.Enabled, got.FireCount, got.LastError) + } + task, err := f.q.GetAgentTask(ctx, got.LastTaskID) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(task.HandoffNote.String, other) || !strings.Contains(task.HandoffNote.String, `"status":"blocked"`) { + t.Fatalf("blocked observation missing from the run: %s", task.HandoffNote.String) + } +} + +func TestWakeupConditionOtherIssueRejectsUnknownState(t *testing.T) { + f, s, issue, agent := conditionFixture(t) + other := f.Issue(t, "Watched", testutil.Cols{"status": "todo"}) + _, err := s.Create(context.Background(), issue, parseTestUUID(t, f.UserID), pgtype.UUID{}, WakeupInput{AgentID: agent, Kind: "event", Instruction: "x", + Condition: condition(t, map[string]any{"type": "other_issue", "issue_id": other, "state": "in_progress"})}) + if err == nil || !strings.Contains(err.Error(), "state must be done, ended, in_review or blocked") { + t.Fatalf("unknown state error = %v", err) + } +} + func TestWakeupConditionRepeatsOnlyWhenItBecomesTrueAgain(t *testing.T) { f, s, issue, agent := conditionFixture(t) label := f.Insert(t, "issue_label", testutil.Cols{"workspace_id": f.WorkspaceID, "name": "approved", "color": "#123456"})