mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
MUL-8094: feat(wakeups): accept blocked in --until-issue-state (#9553)
* feat(wakeups): accept blocked in --until-issue-state Lets a coordinator wait for a specific child to reach blocked, alongside the existing in_review handoff, without waiting for the child to close. Exact status key match, same as in_review. Refs #9344 Co-authored-by: multica-agent <[email protected]> * fix(i18n): keep blocked state key in lowercase English in zh wakeup copy Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
parent
2213734215
commit
7c50af7b66
@@ -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 <heure RFC3339>` ; `--kind every --every 1h` pour un intervalle fixe d'au moins 1 minute ; `--kind cron --cron "<expression à 5 champs>"` 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 <tâche> --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 <tâche> --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 <heure RFC3339>` ; `--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 <id-de-commentaire>` garde la réponse de l'exécution réveillée dans le fil d'origine.
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ multica issue wakeup create OPS-56 --until-status in_review --instruction "成
|
||||
|
||||
- **時間**:`--kind at` に `--after 10m` または `--at <RFC3339 の時刻>`。`--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 <RFC3339 の時刻>` で終わりを決めます。`--on-timeout wake` を付けると、イベントのルールは期限が来たときにもう一度起こします。`--max-fires N`(1〜1000)で繰り返すルールの実行回数に上限を付けます。
|
||||
- **返信先**:`--parent <コメント ID>` を付けると、起動後の返信が元のスレッドに入ります。
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ multica issue wakeup create OPS-56 --until-status in_review --instruction "결
|
||||
|
||||
- **시간**: `--kind at`에 `--after 10m` 또는 `--at <RFC3339 시간>`. `--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 <RFC3339 시간>`으로 끝을 정하세요. `--on-timeout wake`를 붙이면 이벤트 규칙이 기한이 되었을 때 한 번 더 깨웁니다. `--max-fires N`(1–1000)은 반복 규칙의 실행 횟수에 상한을 둡니다.
|
||||
- **답이 남는 곳**: `--parent <댓글 ID>`를 붙이면 깨운 뒤의 답글이 원래 스레드에 남습니다.
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ multica issue wakeup create OPS-56 --until-status in_review --instruction "Check
|
||||
|
||||
- **Time**: `--kind at` with `--after 10m` or `--at <RFC3339 time>`; `--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 <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 <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 <RFC3339 time>`; `--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 <comment-id>` keeps the woken run's reply in the original thread.
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ multica issue wakeup create OPS-56 --until-status in_review --instruction "检
|
||||
|
||||
- **时间**:`--kind at` 配 `--after 10m` 或 `--at <RFC3339 时间>`;`--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 <RFC3339 时间>` 设结束时间;`--on-timeout wake` 让事件规则在超时时再唤醒一次。`--max-fires N`(1–1000)限制重复规则最多运行几次。
|
||||
- **结果放在哪**:`--parent <评论 ID>` 让唤醒后的回复留在原来的评论串里。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"] })}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "起動を一時停止中",
|
||||
|
||||
@@ -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": "깨우기 일시중지됨",
|
||||
|
||||
@@ -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": "唤醒已自动暂停",
|
||||
|
||||
@@ -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": "喚醒已自動暫停",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -459,7 +459,7 @@ multica issue wakeup create <issue> --until-issue <other-issue-id> --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 <issue> --kind at --after 10m --instruction-file ./instruction.md` schedules one run. Alternatively use `--at <RFC3339>`.
|
||||
- `wakeup create <issue> --kind every --every 1h --instruction-file ./instruction.md` schedules a repeating check. Or use `--kind cron --cron '0 * * * *' --timezone Asia/Shanghai`.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user