Files
5ed09342af MUL-7680: issue wakeup v2 — conditions, runaway protection, check-ins and visibility (#8807)
* feat(wakeups): add rule deadlines and expose the child-done system rule (MUL-7680)

Wakeups can now end: an absolute deadline (expires_at) or a relative wait
(expires_in_seconds) that restarts when the rule is re-enabled. When the
deadline passes first, the scheduler ends the rule; event rules with
on_timeout=wake run the target once with a wakeup.timeout fact. A timed-out
rule keeps disabled_at NULL so its timeout run stays claimable.

The implicit "wake the parent's assignee when a stage of sub-issues
finishes" behavior is now described by GET /api/issues/{id}/system-wakeups
and can be turned off or given a supplementary instruction per issue via
PUT /api/issues/{id}/system-wakeups/child_done. Rule reads fail open to the
previous behavior.

List responses add the creator (member or agent) and the new expiry fields.
The CLI gains --expires-in, --expires-at and --on-timeout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): let members create wakeups and show the child-done system rule (MUL-7680)

The issue sidebar's Wakeups section is always available on open issues and
gains a New wakeup popover. Members pick a condition (a time, a recurring
check with an end date, someone's reply, an agent's run ending, or raw
events), the agent to wake, the instruction, how often it fires, how long
to wait and what happens on timeout. It posts the same configuration agents
create with the CLI.

The parent's child-done wake appears as a System rule with its stage,
remaining sub-issues and target, a per-issue toggle and a supplementary
instruction. Rows show when a rule ends, timed-out rules read as such, and
details name who created the rule. Rule titles wrap to two lines instead of
truncating.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* docs(wakeups): document deadlines, member-created rules and the system rule (MUL-7680)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(wakeups): platform conditions, runaway protection and check-ins (MUL-7680)

Conditions: a wakeup can now wait for a fact the platform checks itself
(a status, assignee, label or property value; sub-issues or one stage
finishing; a linked PR's checks finishing or merging; another issue's
status). Related events only prompt an early evaluation; the rule wakes
its agent when the predicate becomes true, and a repeating rule fires
again only after it turned false or its facts changed. Finished PR checks
from before registration are ignored.

Runaway protection: repeating event rules stop after max_fires runs
(default 20), a rule pauses when its causal chain passes through it a
third time without a person in between, or when it starts 12 runs in an
hour. The reason is stored and shown.

Also: silent check-ins for every/cron checks (the run then posts no
fallback comment), wake now, delete, a per-rule run history, paused-rule
lists, timeline entries for created/triggered/timed-out/paused/check-in,
and source, paused scope, 7-day runs and child-done system rows in the
workspace list. The CLI gains --until-* conditions, --max-fires and the
trigger/delete/checkin/runs commands; the brief and wakeup prompt state
the check-in exception.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(issues): record child-done transitions in the status write (MUL-7680)

The child-done system rule ran after the status write committed and gave
up on any error, so a crash, deploy or transient failure lost the
parent's wake. A trigger now records each child's move into a closed
status in the writing transaction, for every writer. The request that
wrote it processes the rows right away and a scheduler sweep retries
anything left over; claims make the two paths process a transition
once.

The rule also follows a workspace default (settings key
system_wakeup_child_done) until an issue sets its own, the per-issue
update accepts partial bodies, and each trigger adds a timeline entry
that names its system comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): wakeup conditions, history, timeline and workspace list (MUL-7680)

Issue sidebar: members can create the platform-checked conditions (a field
reaching a value, sub-issues or a stage finishing, a linked PR's CI or merge,
another issue's status) and cap how often a repeating wait fires. Rule
details show the fire count, why the platform paused a rule, the latest runs
with silent-check notes, and offer wake now and delete.

The issue header says what the issue is waiting for ("Emacs 在等 Jiayuan 回复
+2") and opens the Wakeups section. Board and list cards use the same short
sentence, or flag a paused rule. Wakeup runs read as "由唤醒触发 · <condition>"
in the execution log, transcript and on the comments they post.

The timeline shows created, triggered, timed-out, paused and check-in entries
with the rule they came from; consecutive check-ins merge, and the child-done
entry stands in for its system comment, which it can reveal.

Automation → Issue wakeups adds a source column and filter, 7-day runs, a
paused scope with a banner, the child-done system rule on each waiting parent,
and "New wakeup" with an issue picker. Settings → Issue statuses sets the
system rule's workspace default. Copy in all five languages.

The server records the watched issue's identifier in other-issue conditions
and the rule's creator in timeline snapshots, and returns board summaries with
their condition.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* docs(wakeups): document conditions, runaway protection and check-ins (MUL-7680)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(wakeups): list system rules without a revision and localize statuses (MUL-7680)

System rule rows returned revision 0, which clients reject as a rule
revision; they now return null, and a list row with an invalid revision no
longer fails the whole page. Built-in statuses in conditions and the status
picker read in the viewer's language. Adds a browser test for a condition
created from the form, the header summary, the scheduler waking the agent
and the workspace list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(views): say why a rule paused without repeating "paused" (MUL-7680)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(i18n): use the product's task terms in wakeup copy (MUL-7680)

ja, ko and fr wakeup strings said イシュー / 이슈 / ticket; the product term
for an issue in those languages is タスク / 태스크 / tâche (and sub-tasks
accordingly), per the conventions page. French strings are rewritten for
the feminine noun.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* test(views): pin the shortcut platform in the wakeup form test (MUL-7680)

The send shortcut is primary+Enter, which is Ctrl on Linux CI runners, so
pressing Cmd+Enter only submitted on macOS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(views): simplify the workspace wakeup list

Put the filters on one row: scope as a segmented control on the left,
source/trigger/agent and a search-as-you-type box on the right. "New
wakeup" moves to the page header, which drops the separate Search button.

Rows are single-line: the identifier sits before the title, frequency and
timezone move into the trigger tooltip, and selection, prompt edit and
the last run's transcript show on row hover or focus. The issue column
takes the remaining width and truncates, so the table fits the page.

Also fix creating from the list: the issue picker closes itself right
after reporting the selection, and that close cancelled the flow before
the form could open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* refactor(server): run the child_done rule as a system issue wakeup

The parent-assignee wake on closed sub-issue stages was its own code path:
a system comment, a mention-style run, an override table and endpoints.
It is now an ordinary issue_wakeup row with system_rule set, sharing the
condition, receipts, runaway protection, timeline entries and run model of
people's rules.

- One children_done evaluator: a stage wakes the assignee when it closes
  while a later stage waits; the wrap-up waits for every sub-issue,
  unstaged ones included. People's sub-issue conditions read the same set.
- issue_child_event records closing, reopening, re-parenting and restaging
  for every writer; the request processes it right after commit and the
  sweep retries. User sub-issue rules become immediate too.
- The target is resolved when it fires: an agent run, a squad leader run,
  an inbox notification for a member, or only the timeline entry.
- A parked (backlog) parent catches up when it leaves backlog; a waiting
  run of the same agent is joined instead of queuing a second one; the
  hourly limit pauses a runaway rule. No system comment is posted.
- The run gets the issue's instruction, else the workspace's, else the
  built-in one, plus each stage's counts and the next stage.
- Workspace defaults move to GET/PUT /api/system-wakeups (owners and
  admins) and apply to every rule nobody customized; turning a rule on
  records what already holds so old facts do not fire.
- Existing open parents get their rule from a one-time backfill.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): workspace wakeup settings and sub-issue notifications

- Settings → Wakeups holds the sub-issue rule's workspace default and
  default instruction; the toggle leaves Issue statuses.
- The issue's system rule shows who it reaches (a member is notified),
  a platform pause, and the instruction it will use.
- Timeline entries say whether the assignee was woken, notified or joined
  a waiting run. The folding of the old system comment is gone.
- Inbox renders the new children_done notification on web, desktop and
  mobile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): stop wakeups from repeating their agent's runs

Runs are serialized per issue and agent, so a wakeup never ran beside
another run of its agent, but it queued behind one and repeated it. Each
rule now checks two things before it starts a run:

- Acknowledged: every input came from the agent itself. Its own comments
  and issue changes never wake it, and a condition its own unfinished run
  on the issue satisfied does not wake it when the platform or the agent
  set the rule up. This fixes #8849: a coordinator closing its own stage is
  not woken again while that run is going. A person's condition rule still
  runs afterwards, since the running agent lacks its instruction.
- Merged: when a run of the agent is already waiting to start on the issue
  (assignment, comment, another rule), the rule's instruction and facts
  join that run (context.wakeup_joined, sent to the daemon as
  wakeup_joined and rendered for every prompt kind) instead of queuing
  another. Turning the rule off or replacing it withdraws what it joined.

Sub-issue changes now record the run that made them, and hints and
condition.met inputs carry those runs so a satisfied condition knows
whether the agent caused it. A rule that names the agent itself as the
actor keeps waking it on its own changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): show merged and acknowledged wakeups, hint assignee comment rules

- Timeline entries say when a wakeup joined the agent's waiting run or was
  the agent's own doing, for people's rules and the sub-issue rule.
- The create form tells a member that a reply or comment rule for the
  issue's agent assignee joins the run a comment already starts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): hand waiting wakeups to a run when it is claimed

A rule that fired while its agent had a run waiting used to write its
instruction into that run right away and consume its inputs. That let a
member's rule run under another member's identity, kept deleted rules'
instructions in the run, lost the inputs when the run was cancelled or
claimed by an older daemon, and skipped the fire cap and loop check.

Now the rule only waits for a run that runs as the same person its own
run would, keeping its inputs. When a daemon advertising
joined-wakeups-v1 claims that run, JoinWaitingWakeups rechecks each rule
(on, same agent and person, creator still allowed, not the agent's own
input, no loop), consumes its inputs, counts the fire toward max_fires,
adds its chain and records the merge. A re-claim drops entries of rules
turned off or changed since. Anything else leaves the rule its inputs to
start its own run.

A child_done rule created by the backfill or by processing a change now
treats sub-issues with unprocessed close events as still open, so the
backfill no longer swallows a close waiting for the sweep.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(server): consume joined wakeup inputs only once the run starts

A claim used to consume the inputs of the rules that joined the run. If
that claim did not go through, the run went back to the queue carrying
inputs the rules no longer had: turning the sub-issue rule off on the
issue or for the workspace (which only changes enabled) did not remove
them from the next claim, and cancelling the run lost a once rule that
had already ended.

A claim now reserves the inputs for the run (receipt task_id, still
unprocessed). The rule's next dispatch settles them: a run that started
consumes them as a merged firing (once ends, max_fires counts and can
pause, timeline entry); a run that ended without starting gives them
back; one that has not started keeps them. Turning a rule off or
changing it discards its pending inputs, reserved ones included, and a
later claim of the same run rechecks every entry and drops the ones
without reserved inputs or no longer allowed to reach the run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-27 21:08:54 +08:00

348 lines
13 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestBatchUpdateNoMutationReturnsZero — regression for #1660.
//
// When the request payload has valid issue_ids but the "updates" field
// is empty, missing, or doesn't decode any known mutation field, the
// handler used to walk every issue, run a no-op UPDATE, and increment
// `updated` for each one — returning {"updated": N} despite changing
// nothing. Reporters saw 200 + a positive count and assumed the call
// worked, then chased a phantom persistence bug.
//
// The fix is "tell the truth": when no mutation field is present, return
// {"updated": 0} immediately so the count matches reality.
func TestBatchUpdateNoMutationReturnsZero(t *testing.T) {
// Two fresh issues so we can also assert no fields actually changed.
a := createTestIssue(t, "BU-no-mut A", "todo", "low")
b := createTestIssue(t, "BU-no-mut B", "todo", "low")
t.Cleanup(func() { deleteTestIssue(t, a) })
t.Cleanup(func() { deleteTestIssue(t, b) })
cases := []struct {
desc string
body map[string]any
}{
{
desc: "updates_missing",
// Most common reporter pattern: status at top level.
body: map[string]any{"issue_ids": []string{a, b}, "status": "in_progress"},
},
{
desc: "updates_empty_object",
body: map[string]any{"issue_ids": []string{a, b}, "updates": map[string]any{}},
},
{
desc: "updates_misnamed",
// Singular "update" instead of plural "updates".
body: map[string]any{"issue_ids": []string{a, b}, "update": map[string]any{"status": "done"}},
},
{
desc: "updates_unknown_field_only",
// Payload IS nested correctly, but every key inside `updates` is
// unknown to the handler — same class of caller mistake as the
// shapes above. hasMutation must stay false; behavior is already
// correct, this case locks it in against future regressions.
body: map[string]any{"issue_ids": []string{a, b}, "updates": map[string]any{"foo": "bar"}},
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/batch-update", tc.body)
testHandler.BatchUpdateIssues(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Updated int `json:"updated"`
}
json.NewDecoder(w.Body).Decode(&resp)
if resp.Updated != 0 {
t.Errorf("expected updated=0 when no mutation field present, got %d", resp.Updated)
}
// Belt and braces: confirm the issues weren't touched.
for _, id := range []string{a, b} {
gw := httptest.NewRecorder()
gr := newRequest("GET", "/api/issues/"+id, nil)
gr = withURLParam(gr, "id", id)
testHandler.GetIssue(gw, gr)
var got IssueResponse
json.NewDecoder(gw.Body).Decode(&got)
if got.Status != "todo" {
t.Errorf("issue %s: status changed to %q despite no-mutation request", id, got.Status)
}
}
})
}
}
// TestBatchUpdateValidUpdatesPersistAndCount — positive case to lock in
// happy-path behavior alongside the regression test above.
func TestBatchUpdateValidUpdatesPersistAndCount(t *testing.T) {
a := createTestIssue(t, "BU-ok A", "todo", "low")
b := createTestIssue(t, "BU-ok B", "todo", "low")
t.Cleanup(func() { deleteTestIssue(t, a) })
t.Cleanup(func() { deleteTestIssue(t, b) })
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/batch-update", map[string]any{
"issue_ids": []string{a, b},
"updates": map[string]any{"status": "in_progress"},
})
testHandler.BatchUpdateIssues(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Updated int `json:"updated"`
}
json.NewDecoder(w.Body).Decode(&resp)
if resp.Updated != 2 {
t.Errorf("expected updated=2, got %d", resp.Updated)
}
for _, id := range []string{a, b} {
gw := httptest.NewRecorder()
gr := newRequest("GET", "/api/issues/"+id, nil)
gr = withURLParam(gr, "id", id)
testHandler.GetIssue(gw, gr)
var got IssueResponse
json.NewDecoder(gw.Body).Decode(&got)
if got.Status != "in_progress" {
t.Errorf("issue %s: expected status=in_progress, got %q", id, got.Status)
}
}
}
// TestBatchUpdateStageOnly — regression for the stage barrier feature: a
// batch update whose only field is `stage` must count as a mutation (hasMutation
// includes "stage") and actually persist, not silently return {"updated": 0}.
func TestBatchUpdateStageOnly(t *testing.T) {
a := createTestIssue(t, "BU-stage A", "todo", "low")
t.Cleanup(func() { deleteTestIssue(t, a) })
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/batch-update", map[string]any{
"issue_ids": []string{a},
"updates": map[string]any{"stage": 2},
})
testHandler.BatchUpdateIssues(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Updated int `json:"updated"`
}
json.NewDecoder(w.Body).Decode(&resp)
if resp.Updated != 1 {
t.Fatalf("expected updated=1 for a stage-only batch update, got %d", resp.Updated)
}
gw := httptest.NewRecorder()
gr := newRequest("GET", "/api/issues/"+a, nil)
gr = withURLParam(gr, "id", a)
testHandler.GetIssue(gw, gr)
var got IssueResponse
json.NewDecoder(gw.Body).Decode(&got)
if got.Stage == nil || *got.Stage != 2 {
t.Errorf("expected stage=2 to persist, got %v", got.Stage)
}
}
// createTestIssue is a small helper to keep the table-driven cases clean.
// Returns the new issue's id; caller is responsible for cleanup.
func createTestIssue(t *testing.T, title, status, priority string) string {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": status,
"priority": priority,
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue %q: expected 201, got %d: %s", title, w.Code, w.Body.String())
}
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
return issue.ID
}
func deleteTestIssue(t *testing.T, id string) {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("DELETE", "/api/issues/"+id, nil)
req = withURLParam(req, "id", id)
testHandler.DeleteIssue(w, req)
}
// --- MUL-4155: batch cross-stage child-done aggregation ---
//
// A single batch that finishes sub-issues spanning multiple stages must
// evaluate the parent stage barrier ONCE against the batch's final committed
// state, not per-child mid-batch. The mid-batch behaviour fired one comment per
// intermediate stage, pinned the parent assignee's wake to a stale "advance the
// next stage" instruction, and produced order-dependent output. These tests
// lock in the aggregated behaviour: at most one accurate comment + one wake per
// parent, identical regardless of issue_ids order.
// stagedBatchFixture is a parent (agent-assigned, in_progress) with two ordered
// stages of two in_progress children each, so a single batch that finishes all
// four exercises the cross-stage barrier aggregation.
type stagedBatchFixture struct {
parent IssueResponse
agentID string
stage1 []IssueResponse
stage2 []IssueResponse
}
func newStagedBatchFixture(t *testing.T) stagedBatchFixture {
t.Helper()
if testHandler == nil {
t.Skip("database not available")
}
pw := httptest.NewRecorder()
preq := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "batch-stage parent " + time.Now().Format(time.RFC3339Nano),
"status": "in_progress",
})
testHandler.CreateIssue(pw, preq)
if pw.Code != http.StatusCreated {
t.Fatalf("create parent: expected 201, got %d: %s", pw.Code, pw.Body.String())
}
var parent IssueResponse
json.NewDecoder(pw.Body).Decode(&parent)
// Assign the parent to the ready test agent via direct SQL so the child-done
// wake actually enqueues a task we can pin to the final comment — without the
// assignment trigger queuing an unrelated task at setup.
var agentID string
if err := testPool.QueryRow(context.Background(),
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, "Handler Test Agent",
).Scan(&agentID); err != nil {
t.Fatalf("locate test agent: %v", err)
}
setIssueAssigneeDirect(t, parent.ID, "agent", agentID)
mkChild := func(stage int32) IssueResponse {
cw := httptest.NewRecorder()
creq := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "batch-stage child " + time.Now().Format(time.RFC3339Nano),
"status": "in_progress",
"parent_issue_id": parent.ID,
})
testHandler.CreateIssue(cw, creq)
if cw.Code != http.StatusCreated {
t.Fatalf("create child: expected 201, got %d: %s", cw.Code, cw.Body.String())
}
var child IssueResponse
json.NewDecoder(cw.Body).Decode(&child)
// Set the stage directly — the barrier logic only reads issue.stage.
if _, err := testPool.Exec(context.Background(),
`UPDATE issue SET stage = $2 WHERE id = $1`, child.ID, stage); err != nil {
t.Fatalf("set child stage: %v", err)
}
return child
}
fx := stagedBatchFixture{parent: parent, agentID: agentID}
fx.stage1 = []IssueResponse{mkChild(1), mkChild(1)}
fx.stage2 = []IssueResponse{mkChild(2), mkChild(2)}
t.Cleanup(func() {
for _, c := range append(append([]IssueResponse{}, fx.stage1...), fx.stage2...) {
cleanupChildDoneIssue(c.ID)
}
cleanupChildDoneIssue(parent.ID)
})
return fx
}
// batchSetStatus drives BatchUpdateIssues over the given ids, in order.
func batchSetStatus(t *testing.T, ids []string, status string) {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/batch-update", map[string]any{
"issue_ids": ids,
"updates": map[string]any{"status": status},
})
testHandler.BatchUpdateIssues(w, req)
if w.Code != http.StatusOK {
t.Fatalf("BatchUpdateIssues status=%q: expected 200, got %d: %s", status, w.Code, w.Body.String())
}
}
// TestBatchChildDoneCrossStage_OneWake is the MUL-4155 core. A single batch
// that finishes children across two stages must wake the parent once, from
// the final state: every sub-issue closed, never a stale "Stage 2 is next",
// regardless of id order.
func TestBatchChildDoneCrossStage_OneWake(t *testing.T) {
assertFinal := func(t *testing.T, parentID, agentID string) {
t.Helper()
entries := childDoneEntries(t, parentID)
if len(entries) != 1 || entries[0].Stage != nil || entries[0].Total != 4 || entries[0].Outcome != "woke" {
t.Fatalf("entries = %+v, want one wrap-up wake", entries)
}
runs := childDoneRuns(t, parentID)
if len(runs) != 1 || runs[0].ID != entries[0].TaskID || strings.Contains(runs[0].Note, "next_stage") || !strings.Contains(runs[0].Note, `"all":true`) {
t.Fatalf("runs = %+v, want one run for the final state", runs)
}
if got := countPendingTasksForAgent(t, parentID, agentID); got != 1 {
t.Fatalf("expected exactly 1 pending parent task, got %d", got)
}
}
t.Run("forward order [stage1, stage2]", func(t *testing.T) {
fx := newStagedBatchFixture(t)
batchSetStatus(t, []string{fx.stage1[0].ID, fx.stage1[1].ID, fx.stage2[0].ID, fx.stage2[1].ID}, "done")
assertFinal(t, fx.parent.ID, fx.agentID)
})
t.Run("reverse order [stage2, stage1]", func(t *testing.T) {
fx := newStagedBatchFixture(t)
batchSetStatus(t, []string{fx.stage2[0].ID, fx.stage2[1].ID, fx.stage1[0].ID, fx.stage1[1].ID}, "done")
assertFinal(t, fx.parent.ID, fx.agentID)
})
}
// TestBatchChildDoneCrossStage_Cancelled — cancelling every stage in one batch
// closes them too; the facts count the cancellations apart from finished work.
func TestBatchChildDoneCrossStage_Cancelled(t *testing.T) {
fx := newStagedBatchFixture(t)
batchSetStatus(t, []string{fx.stage1[0].ID, fx.stage1[1].ID, fx.stage2[0].ID, fx.stage2[1].ID}, "cancelled")
runs := childDoneRuns(t, fx.parent.ID)
if len(runs) != 1 || !strings.Contains(runs[0].Note, `"cancelled":4`) || strings.Contains(runs[0].Note, "next_stage") {
t.Fatalf("runs = %+v, want one wake counting 4 cancellations", runs)
}
}
// TestBatchChildDoneClosesLowerStageOnly — when a batch finishes only the lower
// stage, the parent is told Stage 1 closed and pointed at Stage 2.
func TestBatchChildDoneClosesLowerStageOnly(t *testing.T) {
fx := newStagedBatchFixture(t)
batchSetStatus(t, []string{fx.stage1[0].ID, fx.stage1[1].ID}, "done")
entries := childDoneEntries(t, fx.parent.ID)
if len(entries) != 1 || entries[0].Stage == nil || *entries[0].Stage != 1 || entries[0].Total != 2 {
t.Fatalf("entries = %+v, want stage 1", entries)
}
runs := childDoneRuns(t, fx.parent.ID)
if len(runs) != 1 || !strings.Contains(runs[0].Note, `"next_stage":2`) {
t.Fatalf("runs = %+v, want the next stage named", runs)
}
}