Report an unsaved trajectory somewhere that survives

A failed save must not fail the run -- the answer already exists --
but until now the only record of the loss was an error entry
appended to the trajectory that had just failed to save. §6 says
the trajectory is not optional telemetry; losing one silently is
the worst version of losing one.

The runtime has no logger by design, so ExecutionResult gains an
Unsaved list the surface reads and turns into a §10 log line with
run_id, tenant_id, agent_key and agent_version. Never serialised
to the client. Covered on all three run paths, streaming included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
This commit is contained in:
2026-09-22 13:15:39 +05:30
parent 797ee5f2d2
commit db4803c557
4 changed files with 54 additions and 0 deletions

View File

@@ -605,8 +605,10 @@ func (m *ModelExecutor) finish(
// A sink that fails must not fail the run — the answer was already
// produced. It is recorded in the trajectory we could not save, which is
// the best available place for it.
var unsaved []string
if err := m.sink.Save(ctx, traj); err != nil {
rec.Error("runtime.trajectory_unsaved", err.Error())
unsaved = append(unsaved, traj.RunID+": "+err.Error())
}
// Delegated runs are written AFTER this one, because parent_run_id is a
@@ -617,10 +619,12 @@ func (m *ModelExecutor) finish(
if err := m.sink.Save(ctx, child); err != nil {
rec.Error("runtime.subrun_unsaved",
fmt.Sprintf("%s: %s", child.RunID, err.Error()))
unsaved = append(unsaved, child.RunID+": "+err.Error())
}
}
res := &ExecutionResult{
Unsaved: unsaved,
Success: term == TerminationCompleted,
Output: output,
AgentID: agent.ID,

View File

@@ -265,6 +265,28 @@ func TestSinkFailureDoesNotFailTheRun(t *testing.T) {
if res.Output != "the answer" {
t.Errorf("Output = %q, want the answer through", res.Output)
}
// ...but the loss must be reported somewhere that survives. The runtime
// has no logger; the surface reads this and writes the operator's line.
// Before this field existed the only record was an entry in the very
// trajectory that had failed to save.
if len(res.Unsaved) != 1 || !strings.Contains(res.Unsaved[0], res.RunID) ||
!strings.Contains(res.Unsaved[0], context.DeadlineExceeded.Error()) {
t.Errorf("Unsaved = %v, want one entry naming run %s and the error", res.Unsaved, res.RunID)
}
}
// A healthy run reports nothing unsaved. Guarded so the surface never logs a
// phantom loss.
func TestHealthySaveReportsNothingUnsaved(t *testing.T) {
gw := &fakeGateway{text: "the answer"}
exec := NewModelExecutor(gw, &MemorySink{}, nil)
res, err := exec.ExecuteAgent(context.Background(), testAgent(), testInput("q"))
if err != nil {
t.Fatal(err)
}
if len(res.Unsaved) != 0 {
t.Errorf("Unsaved = %v on a healthy run, want none", res.Unsaved)
}
}
type failingSink struct{}

View File

@@ -167,6 +167,18 @@ type ExecutionResult struct {
// the token of whichever the person approves as ExecutionInput.Confirmation
// on the next call.
Confirmations []*tools.Confirmation `json:"confirmations,omitempty"`
// Unsaved names the trajectories this run produced that could not be
// persisted, as "<run id>: <error>". Empty on every healthy run.
//
// A failed save must not fail the run — the answer already exists — but
// it must not vanish either. Until 2026-09-22 the only record of it was
// an entry appended to the trajectory that had just failed to save, which
// is a note left in a bottle that sank. The runtime has no logger by
// design; the surface does, and reads this to write the §10 line an
// operator can grep for. Never serialised to the client: it is an
// operator's concern, not the caller's.
Unsaved []string `json:"-"`
}
// RuntimeError is a structured error containing context for execution failures.