Preserve batch cancellation outcomes
This commit is contained in:
11
docs/cli.md
11
docs/cli.md
@@ -70,7 +70,10 @@ remains available.
|
|||||||
|
|
||||||
`SIGINT` and `SIGTERM` cancel an active action. Weatherreporter lets that
|
`SIGINT` and `SIGTERM` cancel an active action. Weatherreporter lets that
|
||||||
cancellation reach the action before exiting; when the action has a result, it
|
cancellation reach the action before exiting; when the action has a result, it
|
||||||
emits the usual failed summary and exits nonzero.
|
emits the usual failed summary and exits nonzero. A canceled batch retains any
|
||||||
|
reports that were already published, marks interrupted and unstarted reports
|
||||||
|
as `canceled`, skips batch notification, and identifies cancellation separately
|
||||||
|
from report failures.
|
||||||
|
|
||||||
Action commands (`generate`, `run`, and `compare`) write a JSON summary to
|
Action commands (`generate`, `run`, and `compare`) write a JSON summary to
|
||||||
stdout unless `--quiet` is set. `run` also writes compact per-report and batch
|
stdout unless `--quiet` is set. `run` also writes compact per-report and batch
|
||||||
@@ -119,11 +122,15 @@ The `total`, `succeeded`, and `failed` counters describe report items only, so
|
|||||||
a failed batch notification can leave `failed` at `0` while the top-level
|
a failed batch notification can leave `failed` at `0` while the top-level
|
||||||
notification and action status are `failed`.
|
notification and action status are `failed`.
|
||||||
|
|
||||||
|
When cancellation stops a batch, the summary also includes a nonzero
|
||||||
|
`canceled` count. Canceled reports have `"status": "canceled"`; they are not
|
||||||
|
included in `failed`, and the action still has failed status and exits nonzero.
|
||||||
|
|
||||||
Without `--quiet`, batch status lines use this form:
|
Without `--quiet`, batch status lines use this form:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
report=today status=succeeded output="/srv/weather/reports/today.md"
|
report=today status=succeeded output="/srv/weather/reports/today.md"
|
||||||
batch=morning total=2 succeeded=2 failed=0
|
batch=morning total=2 succeeded=2 failed=0 canceled=0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compare Summary
|
### Compare Summary
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ Failures return an active partial result with safe identity, profile, warning, v
|
|||||||
|
|
||||||
Each item has an independent result. A failed item does not stop later items; successful items retain their published output paths. Per-report notification is suppressed during a batch. Batch notification runs only after every planned report has published successfully. It is skipped when any item failed. Batch result counters count report items only; a batch notification failure is represented by the top-level notification result and still produces a failed batch outcome.
|
Each item has an independent result. A failed item does not stop later items; successful items retain their published output paths. Per-report notification is suppressed during a batch. Batch notification runs only after every planned report has published successfully. It is skipped when any item failed. Batch result counters count report items only; a batch notification failure is represented by the top-level notification result and still produces a failed batch outcome.
|
||||||
|
|
||||||
|
Cancellation and deadline expiry stop the sequential loop before another report
|
||||||
|
starts. Completed report results and published paths remain successful; the
|
||||||
|
interrupted and unstarted planned reports have `canceled` status and are counted
|
||||||
|
separately from failed reports. The batch notification result records that
|
||||||
|
delivery was skipped, and the returned error retains the original context cause
|
||||||
|
for callers and CLI projection.
|
||||||
|
|
||||||
## Comparisons
|
## Comparisons
|
||||||
|
|
||||||
`CompareDetailed` validates ordered explicit profile IDs, resolves the report,
|
`CompareDetailed` validates ordered explicit profile IDs, resolves the report,
|
||||||
|
|||||||
@@ -69,7 +69,10 @@ final output destination before executing its first report prompt. A destination
|
|||||||
collision, such as a directory named `tomorrow.md`, stops the batch before any
|
collision, such as a directory named `tomorrow.md`, stops the batch before any
|
||||||
report output is created or replaced. After successful validation, each selected
|
report output is created or replaced. After successful validation, each selected
|
||||||
report processes independently and successful outputs remain available if
|
report processes independently and successful outputs remain available if
|
||||||
another report fails.
|
another report fails. If cancellation or a deadline is observed during the
|
||||||
|
sequence, Weatherreporter stops before starting another report. It retains
|
||||||
|
already published files, marks interrupted and unstarted reports as canceled in
|
||||||
|
the result, and skips batch notification.
|
||||||
|
|
||||||
When `notify.distributor.enabled` and batch notification are enabled,
|
When `notify.distributor.enabled` and batch notification are enabled,
|
||||||
Weatherreporter sends one Distributor upload only after every selected output
|
Weatherreporter sends one Distributor upload only after every selected output
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
@@ -100,6 +101,7 @@ type BatchResult struct {
|
|||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
Succeeded int `json:"succeeded"`
|
Succeeded int `json:"succeeded"`
|
||||||
Failed int `json:"failed"`
|
Failed int `json:"failed"`
|
||||||
|
Canceled int `json:"canceled,omitempty"`
|
||||||
Notification *BatchNotificationResult `json:"notification,omitempty"`
|
Notification *BatchNotificationResult `json:"notification,omitempty"`
|
||||||
Reports []BatchReportResult `json:"reports"`
|
Reports []BatchReportResult `json:"reports"`
|
||||||
}
|
}
|
||||||
@@ -143,12 +145,19 @@ type BatchReportResult struct {
|
|||||||
|
|
||||||
type BatchError struct {
|
type BatchError struct {
|
||||||
Result *BatchResult
|
Result *BatchResult
|
||||||
|
Cause error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e BatchError) Error() string {
|
func (e BatchError) Error() string {
|
||||||
if e.Result == nil {
|
if e.Result == nil {
|
||||||
return "batch failed"
|
return "batch failed"
|
||||||
}
|
}
|
||||||
|
if errors.Is(e.Cause, context.DeadlineExceeded) {
|
||||||
|
return fmt.Sprintf("batch %s deadline exceeded", e.Result.Batch)
|
||||||
|
}
|
||||||
|
if errors.Is(e.Cause, context.Canceled) || e.Result.Canceled > 0 {
|
||||||
|
return fmt.Sprintf("batch %s canceled", e.Result.Batch)
|
||||||
|
}
|
||||||
failedReports := batchReportFailures(e.Result)
|
failedReports := batchReportFailures(e.Result)
|
||||||
if batchNotificationFailed(e.Result) && failedReports == 0 {
|
if batchNotificationFailed(e.Result) && failedReports == 0 {
|
||||||
if e.Result.Notification.Error != "" {
|
if e.Result.Notification.Error != "" {
|
||||||
@@ -159,6 +168,10 @@ func (e BatchError) Error() string {
|
|||||||
return fmt.Sprintf("batch %s failed: %d of %d reports failed", e.Result.Batch, failedReports, len(e.Result.Reports))
|
return fmt.Sprintf("batch %s failed: %d of %d reports failed", e.Result.Batch, failedReports, len(e.Result.Reports))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e BatchError) Unwrap() error {
|
||||||
|
return e.Cause
|
||||||
|
}
|
||||||
|
|
||||||
func batchNotificationFailed(result *BatchResult) bool {
|
func batchNotificationFailed(result *BatchResult) bool {
|
||||||
return result != nil && result.Notification != nil && result.Notification.Status == "failed"
|
return result != nil && result.Notification != nil && result.Notification.Status == "failed"
|
||||||
}
|
}
|
||||||
@@ -344,7 +357,12 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
if req.Batch == BatchEvening || req.Batch == BatchMorning {
|
if req.Batch == BatchEvening || req.Batch == BatchMorning {
|
||||||
startedAt := now
|
startedAt := now
|
||||||
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
||||||
for _, planned := range plannedReports {
|
var cancellation error
|
||||||
|
for index, planned := range plannedReports {
|
||||||
|
if cancellation = batchCancellationCause(ctx, nil); cancellation != nil {
|
||||||
|
appendCanceledBatchReports(result, plannedReports[index:])
|
||||||
|
break
|
||||||
|
}
|
||||||
resolved := planned.Resolved
|
resolved := planned.Resolved
|
||||||
item := batchReportResult(planned)
|
item := batchReportResult(planned)
|
||||||
reportResult, err := generatePromptReport(ctx, promptReportRequest{
|
reportResult, err := generatePromptReport(ctx, promptReportRequest{
|
||||||
@@ -364,14 +382,26 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
copyBatchReportDetails(&item, reportResult)
|
copyBatchReportDetails(&item, reportResult)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if cancellation = batchCancellationCause(ctx, err); cancellation != nil {
|
||||||
|
item.Status = "canceled"
|
||||||
|
result.Canceled++
|
||||||
|
} else {
|
||||||
item.Status = "failed"
|
item.Status = "failed"
|
||||||
item.Error = err.Error()
|
item.Error = err.Error()
|
||||||
result.Failed++
|
result.Failed++
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
item.Status = "succeeded"
|
item.Status = "succeeded"
|
||||||
result.Succeeded++
|
result.Succeeded++
|
||||||
}
|
}
|
||||||
result.Reports = append(result.Reports, item)
|
result.Reports = append(result.Reports, item)
|
||||||
|
if cancellation == nil {
|
||||||
|
cancellation = batchCancellationCause(ctx, nil)
|
||||||
|
}
|
||||||
|
if cancellation != nil {
|
||||||
|
appendCanceledBatchReports(result, plannedReports[index+1:])
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result.Total = len(result.Reports)
|
result.Total = len(result.Reports)
|
||||||
batchNotification := notifyBatch(ctx, req.Config, req.Batch, batchRunID(startedAt, req.Batch), startedAt, result, plannedReports, req.Notifier)
|
batchNotification := notifyBatch(ctx, req.Config, req.Batch, batchRunID(startedAt, req.Batch), startedAt, result, plannedReports, req.Notifier)
|
||||||
@@ -379,11 +409,38 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
result.Notification = batchNotification
|
result.Notification = batchNotification
|
||||||
}
|
}
|
||||||
result.FinishedAt = time.Now()
|
result.FinishedAt = time.Now()
|
||||||
return result, nil
|
return result, cancellation
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("run is not implemented")
|
return nil, fmt.Errorf("run is not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func batchCancellationCause(ctx context.Context, err error) error {
|
||||||
|
if ctx != nil {
|
||||||
|
if contextErr := ctx.Err(); contextErr != nil {
|
||||||
|
return contextErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return context.Canceled
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendCanceledBatchReports(result *BatchResult, plannedReports []plannedBatchReport) {
|
||||||
|
if result == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, planned := range plannedReports {
|
||||||
|
item := batchReportResult(planned)
|
||||||
|
item.Status = "canceled"
|
||||||
|
result.Reports = append(result.Reports, item)
|
||||||
|
result.Canceled++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func copyBatchReportDetails(item *BatchReportResult, result *ReportResult) {
|
func copyBatchReportDetails(item *BatchReportResult, result *ReportResult) {
|
||||||
item.LLMDebugPath = result.LLMDebugPath
|
item.LLMDebugPath = result.LLMDebugPath
|
||||||
item.OutputPath = result.OutputPath
|
item.OutputPath = result.OutputPath
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func TestRunBatchDetailedKeepsSuccessfulOutputAndSkipsNotificationAfterPartialFa
|
|||||||
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
||||||
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
|
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
|
||||||
})
|
})
|
||||||
if err != nil || result == nil || result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 || result.Notification == nil || result.Notification.Status != "skipped" || notifier.batchCalls != 0 {
|
if err != nil || result == nil || result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 || result.Canceled != 0 || result.Notification == nil || result.Notification.Status != "skipped" || notifier.batchCalls != 0 {
|
||||||
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
||||||
}
|
}
|
||||||
if result.Reports[0].Status != "succeeded" || result.Reports[0].OutputPath == "" || result.Reports[1].Status != "failed" || result.Reports[1].OutputPath != "" {
|
if result.Reports[0].Status != "succeeded" || result.Reports[0].OutputPath == "" || result.Reports[1].Status != "failed" || result.Reports[1].OutputPath != "" {
|
||||||
@@ -32,6 +32,65 @@ func TestRunBatchDetailedKeepsSuccessfulOutputAndSkipsNotificationAfterPartialFa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedStopsAfterReportCancellation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
executor := &generationExecutor{cancelBeforeReturn: cancel}
|
||||||
|
|
||||||
|
result, err := RunBatchDetailed(ctx, BatchRequest{
|
||||||
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||||
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, context.Canceled) || result == nil || result.Total != 2 || result.Succeeded != 0 || result.Failed != 0 || result.Canceled != 2 || executor.executeCalls != 1 || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
|
||||||
|
t.Fatalf("RunBatchDetailed() result/error/executor/notifier = %#v/%v/%#v/%#v", result, err, executor, notifier)
|
||||||
|
}
|
||||||
|
for _, item := range result.Reports {
|
||||||
|
if item.Status != "canceled" || item.OutputPath != "" {
|
||||||
|
t.Fatalf("canceled report = %#v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedRetainsPublishedReportBeforeCancellation(t *testing.T) {
|
||||||
|
for _, cause := range []error{context.Canceled, context.DeadlineExceeded} {
|
||||||
|
t.Run(cause.Error(), func(t *testing.T) {
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||||
|
notifier := &generationNotifier{}
|
||||||
|
ctx := &publicationGateContext{Context: context.Background(), err: cause, afterChecks: 4}
|
||||||
|
|
||||||
|
result, err := RunBatchDetailed(ctx, BatchRequest{
|
||||||
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||||
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, cause) || result == nil || result.Total != 2 || result.Succeeded != 1 || result.Failed != 0 || result.Canceled != 1 || len(result.Reports) != 2 || result.Reports[0].Status != "succeeded" || result.Reports[0].OutputPath == "" || result.Reports[1].Status != "canceled" || result.Reports[1].OutputPath != "" || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
|
||||||
|
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(result.Reports[0].OutputPath); statErr != nil {
|
||||||
|
t.Fatalf("published report %q: %v", result.Reports[0].OutputPath, statErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchPreservesCancellationCause(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
bundle := generationBundle(t)
|
||||||
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||||
|
err := RunBatch(ctx, BatchRequest{
|
||||||
|
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||||
|
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
||||||
|
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{cancelBeforeReturn: cancel}, Notifier: &generationNotifier{},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("RunBatch() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunBatchDetailedNotifiesOnlyAfterAllOutputsExist(t *testing.T) {
|
func TestRunBatchDetailedNotifiesOnlyAfterAllOutputsExist(t *testing.T) {
|
||||||
bundle := generationBundle(t)
|
bundle := generationBundle(t)
|
||||||
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID
|
|||||||
if result == nil {
|
if result == nil {
|
||||||
return failedBatchNotificationResult(batchNotificationRequest{}, fmt.Errorf("batch result is required"))
|
return failedBatchNotificationResult(batchNotificationRequest{}, fmt.Errorf("batch result is required"))
|
||||||
}
|
}
|
||||||
|
if result.Canceled > 0 {
|
||||||
|
return &BatchNotificationResult{
|
||||||
|
Status: "skipped",
|
||||||
|
Reason: "batch canceled",
|
||||||
|
}
|
||||||
|
}
|
||||||
if result.Failed > 0 {
|
if result.Failed > 0 {
|
||||||
return &BatchNotificationResult{
|
return &BatchNotificationResult{
|
||||||
Status: "skipped",
|
Status: "skipped",
|
||||||
|
|||||||
@@ -30,11 +30,16 @@ type publicationGateContext struct {
|
|||||||
context.Context
|
context.Context
|
||||||
err error
|
err error
|
||||||
checks int
|
checks int
|
||||||
|
afterChecks int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *publicationGateContext) Err() error {
|
func (c *publicationGateContext) Err() error {
|
||||||
c.checks++
|
c.checks++
|
||||||
if c.checks >= 2 {
|
afterChecks := c.afterChecks
|
||||||
|
if afterChecks == 0 {
|
||||||
|
afterChecks = 2
|
||||||
|
}
|
||||||
|
if c.checks >= afterChecks {
|
||||||
return c.err
|
return c.err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ func writeBatchStatus(stderr io.Writer, result *app.BatchResult) {
|
|||||||
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q\n", item.ReportID, item.Error)
|
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q\n", item.ReportID, item.Error)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if item.Status == "canceled" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "report=%s status=canceled\n", item.ReportID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q\n", item.ReportID, item.OutputPath)
|
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q\n", item.ReportID, item.OutputPath)
|
||||||
}
|
}
|
||||||
if result.Notification != nil {
|
if result.Notification != nil {
|
||||||
@@ -58,5 +62,5 @@ func writeBatchStatus(stderr io.Writer, result *app.BatchResult) {
|
|||||||
}
|
}
|
||||||
_, _ = fmt.Fprintln(stderr)
|
_, _ = fmt.Fprintln(stderr)
|
||||||
}
|
}
|
||||||
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
|
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d canceled=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed, result.Canceled)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ type batchSummary struct {
|
|||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
Succeeded int `json:"succeeded"`
|
Succeeded int `json:"succeeded"`
|
||||||
Failed int `json:"failed"`
|
Failed int `json:"failed"`
|
||||||
|
Canceled int `json:"canceled,omitempty"`
|
||||||
Notification *app.BatchNotificationResult `json:"notification,omitempty"`
|
Notification *app.BatchNotificationResult `json:"notification,omitempty"`
|
||||||
Reports []app.BatchReportResult `json:"reports"`
|
Reports []app.BatchReportResult `json:"reports"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
@@ -161,7 +162,7 @@ func newGenerateNotificationSummary(result *app.NotificationResult) *generateNot
|
|||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBatchSummary(result *app.BatchResult) batchSummary {
|
func newBatchSummary(result *app.BatchResult, err error) batchSummary {
|
||||||
summary := batchSummary{Command: commandRun}
|
summary := batchSummary{Command: commandRun}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return summary
|
return summary
|
||||||
@@ -174,10 +175,11 @@ func newBatchSummary(result *app.BatchResult) batchSummary {
|
|||||||
summary.Total = result.Total
|
summary.Total = result.Total
|
||||||
summary.Succeeded = result.Succeeded
|
summary.Succeeded = result.Succeeded
|
||||||
summary.Failed = result.Failed
|
summary.Failed = result.Failed
|
||||||
|
summary.Canceled = result.Canceled
|
||||||
summary.Notification = result.Notification
|
summary.Notification = result.Notification
|
||||||
summary.Reports = append([]app.BatchReportResult(nil), result.Reports...)
|
summary.Reports = append([]app.BatchReportResult(nil), result.Reports...)
|
||||||
if summary.Status == summaryStatusFailed {
|
if summary.Status == summaryStatusFailed {
|
||||||
summary.Error = app.BatchError{Result: result}.Error()
|
summary.Error = app.BatchError{Result: result, Cause: err}.Error()
|
||||||
}
|
}
|
||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
@@ -267,7 +269,7 @@ func batchSummaryStatus(result *app.BatchResult) string {
|
|||||||
if result == nil {
|
if result == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if result.Failed > 0 || (result.Notification != nil && result.Notification.Status == summaryStatusFailed) {
|
if result.Failed > 0 || result.Canceled > 0 || (result.Notification != nil && result.Notification.Status == summaryStatusFailed) {
|
||||||
return summaryStatusFailed
|
return summaryStatusFailed
|
||||||
}
|
}
|
||||||
return summaryStatusSucceeded
|
return summaryStatusSucceeded
|
||||||
|
|||||||
@@ -111,12 +111,15 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
|
|||||||
}
|
}
|
||||||
result, err := runBatchDetailed(ctx, req)
|
result, err := runBatchDetailed(ctx, req)
|
||||||
if result != nil {
|
if result != nil {
|
||||||
summary := newBatchSummary(result)
|
summary := newBatchSummary(result, err)
|
||||||
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) {
|
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) {
|
||||||
writeBatchStatus(w, result)
|
writeBatchStatus(w, result)
|
||||||
}); encodeErr != nil {
|
}); encodeErr != nil {
|
||||||
return encodeErr
|
return encodeErr
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if summary.Status == summaryStatusFailed {
|
if summary.Status == summaryStatusFailed {
|
||||||
return app.BatchError{Result: result}
|
return app.BatchError{Result: result}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,6 +193,43 @@ func TestRunActionReturnsFailureForBatchNotificationFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunActionPreservesBatchCancellation(t *testing.T) {
|
||||||
|
configPath := actionConfigPath(t)
|
||||||
|
for _, cause := range []error{context.Canceled, context.DeadlineExceeded} {
|
||||||
|
t.Run(cause.Error(), func(t *testing.T) {
|
||||||
|
result := &app.BatchResult{
|
||||||
|
Batch: app.BatchMorning, Total: 2, Succeeded: 1, Canceled: 1,
|
||||||
|
Reports: []app.BatchReportResult{
|
||||||
|
{ReportID: "today", Status: "succeeded", OutputPath: "/reports/today.md"},
|
||||||
|
{ReportID: "tomorrow", Status: "canceled"},
|
||||||
|
},
|
||||||
|
Notification: &app.BatchNotificationResult{Status: "skipped", Reason: "batch canceled"},
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
runner := Runner{
|
||||||
|
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)},
|
||||||
|
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
|
||||||
|
return &factoryExecutor{}, nil
|
||||||
|
},
|
||||||
|
runBatchDetailed: func(context.Context, app.BatchRequest) (*app.BatchResult, error) {
|
||||||
|
return result, cause
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := runner.Run(context.Background(), []string{"run", "morning", "--config", configPath}, &stdout, &stderr)
|
||||||
|
if !errors.Is(err, cause) {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
var summary batchSummary
|
||||||
|
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
|
||||||
|
t.Fatal(decodeErr)
|
||||||
|
}
|
||||||
|
if summary.Status != summaryStatusFailed || summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 0 || summary.Canceled != 1 || summary.Error == "" || len(summary.Reports) != 2 || summary.Reports[1].Status != "canceled" || !strings.Contains(stderr.String(), "report=tomorrow status=canceled") || !strings.Contains(stderr.String(), "canceled=1") {
|
||||||
|
t.Fatalf("summary/stderr = %#v/%q", summary, stderr.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunCommandProjectsSuccessAndReportFailure(t *testing.T) {
|
func TestRunCommandProjectsSuccessAndReportFailure(t *testing.T) {
|
||||||
configPath := actionConfigPath(t)
|
configPath := actionConfigPath(t)
|
||||||
for _, tt := range []struct {
|
for _, tt := range []struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user