mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* [LOCAL PR-Y] CLI: graceful shutdown of headless exec runs (Ctrl+C) zero exec wrapped agent.Run in context.Background(), so Ctrl+C/SIGTERM killed the process abruptly — leaking the in-flight provider request and any spawned children. runExec now wraps the run in a signal-aware context (signalContext): a signal cancels the run, the loop returns cleanly, and exec reports 'Interrupted.' with exit 130 (stream-json: an 'interrupted' run-end). Scoped to runExec — no dispatch-wide signature churn. NOTE: SIGINT runtime delivery needs a manual Ctrl+C check before PR; unit tests cover the helper + the no-regression of the exec suite. * [LOCAL PR-Y] Observability: crash capture (panic -> saved report) New internal/observability package: a recovered top-level panic is written to a local crash report (timestamp, label, full stack) under ~/.zero/crashes and surfaced as a brief notice instead of a raw stack trace. Wired into runWithDeps via a named-return defer. Fail-open and dependency-free; this is the hook a Sentry/OTEL adapter attaches to for remote reporting (deferred, needs a real endpoint to verify). Tested: report formatting/writing, panic capture sets the crash exit code + writes one report, and the no-panic path is a no-op. --------- Co-authored-by: KRATOS <[email protected]>
64 lines
2.4 KiB
Go
64 lines
2.4 KiB
Go
// Package observability provides dependency-free crash capture for the CLI: a
|
|
// recovered panic is written to a local crash report (timestamp, label, stack)
|
|
// and surfaced to the user as a brief notice instead of a raw stack trace. It is
|
|
// the fail-open foundation for remote crash/metrics reporting — a Sentry/OTEL
|
|
// adapter can hook the same Recover/report path when configured — without
|
|
// pulling those dependencies into the base build.
|
|
package observability
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"time"
|
|
)
|
|
|
|
// crashExitCode is returned when a top-level panic is recovered.
|
|
const crashExitCode = 1
|
|
|
|
// FormatCrashReport renders a human-readable crash report.
|
|
func FormatCrashReport(label string, recovered any, stack []byte, ts time.Time) string {
|
|
return fmt.Sprintf("zero crash report\ntime: %s\nlabel: %s\npanic: %v\n\nstack:\n%s\n",
|
|
ts.UTC().Format(time.RFC3339), label, recovered, stack)
|
|
}
|
|
|
|
// WriteCrashReport writes a crash report file into dir and returns its path.
|
|
func WriteCrashReport(dir, label string, recovered any, stack []byte, ts time.Time) (string, error) {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
path := filepath.Join(dir, "crash-"+ts.UTC().Format("20060102-150405")+".log")
|
|
if err := os.WriteFile(path, []byte(FormatCrashReport(label, recovered, stack, ts)), 0o600); err != nil {
|
|
return "", err
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// DefaultCrashDir is where crash reports are written by default.
|
|
func DefaultCrashDir() string {
|
|
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
|
return filepath.Join(home, ".zero", "crashes")
|
|
}
|
|
return filepath.Join(os.TempDir(), "zero-crashes")
|
|
}
|
|
|
|
// Recover is deferred at a top-level entrypoint. On a panic it captures the
|
|
// stack, writes a crash report under dir, prints a brief notice to stderr, and
|
|
// sets *code to a crash exit code. It is fail-open: if the report can't be
|
|
// written it still reports the crash with the stack inline. No panic escapes.
|
|
func Recover(dir, label string, stderr io.Writer, code *int) {
|
|
recovered := recover()
|
|
if recovered == nil {
|
|
return
|
|
}
|
|
stack := debug.Stack()
|
|
if path, err := WriteCrashReport(dir, label, recovered, stack, time.Now()); err == nil {
|
|
fmt.Fprintf(stderr, "zero crashed: %v\nA crash report was saved to %s\n", recovered, path)
|
|
} else {
|
|
fmt.Fprintf(stderr, "zero crashed: %v\n%s\n", recovered, stack)
|
|
}
|
|
*code = crashExitCode
|
|
}
|