diff --git a/internal/cli/incident_summary_script_test.go b/internal/cli/incident_summary_script_test.go index 2eeab13..e368fc5 100644 --- a/internal/cli/incident_summary_script_test.go +++ b/internal/cli/incident_summary_script_test.go @@ -9,7 +9,11 @@ import ( "testing" ) -func TestIncidentSummaryScriptCompactOutput(t *testing.T) { +// runIncidentSummaryScript runs skills/flashduty/scripts/incident-summary.sh against a fake +// fduty that logs every invocation. detailJSON is what the fake prints for `--json` calls; every +// other call prints a one-line placeholder. +func runIncidentSummaryScript(t *testing.T, detailJSON string) (output string, calls []string) { + t.Helper() if runtime.GOOS == "windows" { t.Skip("Bash fixture is unavailable on Windows") } @@ -19,31 +23,67 @@ func TestIncidentSummaryScriptCompactOutput(t *testing.T) { t.Fatalf("resolve repository root: %v", err) } script := filepath.Join(root, "skills", "flashduty", "scripts", "incident-summary.sh") - log := filepath.Join(t.TempDir(), "fduty.log") - bin := filepath.Join(t.TempDir(), "fduty") - if err := os.WriteFile(bin, []byte("#!/usr/bin/env bash\nprintf '%s\\n' \"$*\" >> \"$FDUTY_LOG\"\nprintf 'compact result\\n'\n"), 0o755); err != nil { + dir := t.TempDir() + log := filepath.Join(dir, "fduty.log") + bin := filepath.Join(dir, "fduty") + fake := "#!/usr/bin/env bash\n" + + "printf '%s\\n' \"$*\" >> \"$FDUTY_LOG\"\n" + + "case \" $* \" in\n" + + " *' --json '*) printf '%s\\n' \"$FDUTY_DETAIL_JSON\" ;;\n" + + " *) printf 'compact result\\n' ;;\n" + + "esac\n" + if err := os.WriteFile(bin, []byte(fake), 0o755); err != nil { t.Fatalf("write fake fduty: %v", err) } t.Setenv("FDUTY_LOG", log) + t.Setenv("FDUTY_DETAIL_JSON", detailJSON) t.Setenv("PATH", filepath.Dir(bin)+string(os.PathListSeparator)+os.Getenv("PATH")) - output, err := exec.Command("bash", script, "inc-1").CombinedOutput() + out, err := exec.Command("bash", script, "inc-1").CombinedOutput() if err != nil { - t.Fatalf("run incident summary: %v\n%s", err, output) + t.Fatalf("run incident summary: %v\n%s", err, out) } invocations, err := os.ReadFile(log) if err != nil { t.Fatalf("read fake fduty log: %v", err) } - lines := strings.FieldsFunc(strings.TrimSpace(string(invocations)), func(r rune) bool { return r == '\n' }) - if len(lines) != 6 { - t.Fatalf("fduty calls = %d, want 6:\n%s", len(lines), invocations) + calls = strings.FieldsFunc(strings.TrimSpace(string(invocations)), func(r rune) bool { return r == '\n' }) + return string(out), calls +} + +func TestIncidentSummaryScriptCompactOutput(t *testing.T) { + // 2026-01-01T00:00:00Z = 1767225600; the concurrent-incidents window is ±15 min around it. + _, calls := runIncidentSummaryScript(t, `{"incident_id":"inc-1","start_time":"2026-01-01T00:00:00Z"}`) + if len(calls) != 8 { + t.Fatalf("fduty calls = %d, want 8:\n%s", len(calls), strings.Join(calls, "\n")) } wantDetail := "incident detail inc-1 --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon" - if lines[0] != wantDetail { - t.Fatalf("detail call = %q, want compact projection %q", lines[0], wantDetail) + if calls[0] != wantDetail { + t.Fatalf("detail call = %q, want compact projection %q", calls[0], wantDetail) + } + if strings.Contains(strings.Join(calls[1:6], "\n"), "--output-format toon") { + t.Fatalf("non-detail reads force raw toon instead of their compact defaults:\n%s", strings.Join(calls, "\n")) + } + if calls[6] != "incident detail inc-1 --json" { + t.Fatalf("start_time probe = %q, want %q", calls[6], "incident detail inc-1 --json") + } + // ⑦ is a projected read like ①: explicit --fields keeps full titles and the num/channel_id + // columns the card tells the agent to reason from, so toon is the compact form here. + wantConcurrent := "incident list --since 1767224700 --until 1767226500 --limit 50 --fields incident_id,num,title,incident_severity,progress,start_time,channel_id --output-format toon" + if calls[7] != wantConcurrent { + t.Fatalf("concurrent-incidents call = %q, want %q", calls[7], wantConcurrent) + } +} + +func TestIncidentSummaryScriptSkipsConcurrentWithoutStartTime(t *testing.T) { + output, calls := runIncidentSummaryScript(t, `{"incident_id":"inc-1"}`) + if len(calls) != 7 { + t.Fatalf("fduty calls = %d, want 7 (six reads + the start_time probe, no list):\n%s", len(calls), strings.Join(calls, "\n")) + } + if !strings.Contains(output, "⑦ concurrent incidents: SKIPPED") { + t.Fatalf("output lacks the SKIPPED marker for ⑦:\n%s", output) } - if strings.Contains(strings.Join(lines[1:], "\n"), "--output-format toon") { - t.Fatalf("non-detail reads force raw toon instead of their compact defaults:\n%s", invocations) + if !strings.Contains(output, "fduty incident list --since --until ") { + t.Fatalf("SKIPPED marker lacks the manual command:\n%s", output) } } diff --git a/skills/flashduty/SKILL.md b/skills/flashduty/SKILL.md index d3afbec..fcf7daf 100644 --- a/skills/flashduty/SKILL.md +++ b/skills/flashduty/SKILL.md @@ -61,7 +61,7 @@ Read verbs (`list`, `get`, `info`, `detail`, `timeline`) are free. Mutating verb Some asks span several commands. For those the skill ships a script that fetches everything in one call — run it as your **first action** for that ask, rather than hand-picking commands and writing the rest from memory: -- **Full incident fault analysis** (详情 + 关联告警 + 变更 + 时间线 + 相似故障 + 复盘 / detail + alerts + changes + timeline + similar + post-mortems): `bash scripts/incident-summary.sh ` — runs all six reads and prints them in one block, so the summary is written from real output. See `reference/incident.md`. +- **Full incident fault analysis** (详情 + 关联告警 + 变更 + 时间线 + 相似故障 + 复盘 + 同时段故障 / detail + alerts + changes + timeline + similar + post-mortems + concurrent incidents): `bash scripts/incident-summary.sh ` — runs all seven reads and prints them in one block, so the summary is written from real output. See `reference/incident.md`. ## Domain index — read the card for the task diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index e454ec4..0a2e75d 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -83,7 +83,7 @@ Projected `similar` lists stay below 16 KiB: when the page would overflow, only ## Hot flow — full fault analysis (read-only summary) -When asked to **summarize / analyze** an incident — 详情 + 关联告警 + 变更 + 时间线 + 相似故障 + 复盘 — `incident detail` does **not** contain the alerts / timeline / similar / post-mortem / change data; each is its own command. **Your first action must be the bundled script** — do not hand-pick one or two commands and write the rest from memory. One call fetches all six aspects: +When asked to **summarize / analyze** an incident — 详情 + 关联告警 + 变更 + 时间线 + 相似故障 + 复盘 + 同时段故障 — `incident detail` does **not** contain the alerts / timeline / similar / post-mortem / change data; each is its own command. **Your first action must be the bundled script** — do not hand-pick one or two commands and write the rest from memory. One call fetches all seven aspects: ```bash bash /scripts/incident-summary.sh @@ -91,7 +91,7 @@ bash /scripts/incident-summary.sh `` is this skill's base directory — you were given it when the skill loaded (it is also the folder you read this card from). The script runs every command below and prints the results in one block, so each section of your summary is backed by real output and there is nothing to guess. (To tie post-mortems to *this* incident, re-run `incident post-mortem-list --channel-ids ` with the `channel_id` from `detail`.) -If you fetch the pieces by hand instead, run **all six** — they are cheap reads: +If you fetch the pieces by hand instead, run **all seven** — they are cheap reads: ```bash ID= # 24-char id from `incident list` @@ -101,10 +101,13 @@ fduty incident timeline "$ID" # ④ timeline (or `i fduty incident similar "$ID" --limit 5 --output-format toon # ⑤ similar past incidents (channel-backed; see Gotchas; compact by default) fduty incident post-mortem-list --channel-ids # ⑥ post-mortems for this incident's channel (verb card: reference/postmortem.md) fduty change list --since 24h # ③ correlated changes — by shared labels + time; see reference/change.md +fduty incident list --since --until --limit 50 --fields incident_id,num,title,incident_severity,progress,start_time,channel_id --output-format toon # ⑦ concurrent incidents — all channels, any progress, ±15 min around this incident's start_time (from ①) ``` > **Never report a result you didn't fetch.** Do not write "返回空" / "无" / a count for any aspect whose command is **absent from your tool-call history this turn** — write `未查询 — 可运行 ` instead. "Empty" is a claim only a command you actually ran can make; inventing it is the worst failure mode of a fault summary. +**Reading ⑦ (concurrent incidents).** Alert grouping runs per channel, so one root cause that spans several services or channels arrives as several incidents opened within minutes of each other — ⑦ is the only section that looks sideways at them, and the incident you were given stays the one you are analysing (⑦ is context, not a re-identification). When it lists other incidents: compare titles, channels, and labels (re-run the same window with `--fields incident_id,num,title,channel_id,labels` and a small `--limit`, or `incident alerts ` per sibling) for a shared factor — same host, cluster, upstream dependency, or deploy. A shared factor is a root-cause hypothesis to test against ② and ③, not a conclusion; in the report say which siblings you judge to share the cause and why, and name them by `num` in the timeline comment so responders can merge. When the list is cut at `--limit` (the trailing note carries the window total), you are looking at a storm: analyse the common factor across the batch instead of this incident in isolation. `incident merge` is irreversible — only on an explicit human confirmation, never on an unattended channel. + ## Hot flow — resolve, document, and merge duplicates ```bash diff --git a/skills/flashduty/scripts/incident-summary.sh b/skills/flashduty/scripts/incident-summary.sh index 2833e17..f66166d 100644 --- a/skills/flashduty/scripts/incident-summary.sh +++ b/skills/flashduty/scripts/incident-summary.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # incident-summary.sh — one-shot, read-only fault-analysis fetch. # -# A full incident summary needs six different commands (detail does NOT bundle them). +# A full incident summary needs seven different commands (detail does NOT bundle them). # This runs all of them and prints the results in one block, so the summary is written # from real output with nothing to guess or fabricate. Read-only; safe to run anytime. # @@ -33,3 +33,24 @@ run incident timeline "$ID" # ④ timeline run incident similar "$ID" --limit 5 # ⑤ similar past incidents (channel-backed) run incident post-mortem-list --limit 10 # ⑥ recent post-mortems (add --channel-ids to scope) run change list --since 24h # ③ correlated changes (shared labels + time) + +# ⑦ concurrent incidents — every incident in this account, any channel, any progress, whose +# start_time falls within ±15 min of this one's. Alert grouping runs per channel, so one root +# cause spanning several services/channels arrives as several incidents; this is the only +# section that looks sideways at them. The reference incident itself appears in the list. +START_RAW=$(fduty incident detail "$ID" --json 2>/dev/null | jq -r '.start_time // empty') +START_TS="" +if [ -n "$START_RAW" ]; then + # The CLI renders start_time as RFC3339 in its local zone. GNU date (sandbox / Linux runners) + # parses it as is; BSD date (macOS runners) wants a literal Z spelled out as an offset and no + # colon inside the offset. + NORM=${START_RAW/%Z/+00:00}; NORM=${NORM%:*}${NORM##*:} + START_TS=$(date -d "$START_RAW" +%s 2>/dev/null || date -j -f '%Y-%m-%dT%H:%M:%S%z' "$NORM" +%s 2>/dev/null) +fi +if [ -n "$START_TS" ]; then + echo "# ⑦ concurrent incidents: every incident in this account (any channel, any progress) started within ±15 min of $START_RAW — includes $ID itself; the trailing note carries the window total" + run incident list --since "$((START_TS - 900))" --until "$((START_TS + 900))" --limit 50 --fields incident_id,num,title,incident_severity,progress,start_time,channel_id --output-format toon +else + echo "===== ⑦ concurrent incidents: SKIPPED — could not read start_time of $ID; run: fduty incident list --since --until --limit 50 --output-format toon =====" + echo +fi