返回 DeepSeek-Reasonix
recorder.go
根目录 / internal / trajectory / recorder.go
1 // Package trajectory appends a run's typed event stream to a JSONL file so a
2 // run's sequence, timing, and decisions can be replayed and analyzed offline.
3 // Records reuse the eventwire JSON contract and include content (prompts, tool
4 // arguments, reasoning) — the file is as sensitive as a session transcript.
5 package trajectory
6
7 import (
8 "bufio"
9 "encoding/json"
10 "os"
11 "sync"
12 "time"
13
14 "reasonix/internal/event"
15 "reasonix/internal/eventwire"
16 "reasonix/internal/evidence"
17 )
18
19 // SchemaVersion identifies the record layout; bump on breaking changes.
20 const SchemaVersion = 1
21
22 // Record is one observed occurrence. Exactly one payload field is set; Seq
23 // orders them and TS is the unix-millisecond observation time at the recorder.
24 type Record struct {
25 SchemaVersion int `json:"schema_version"`
26 Seq uint64 `json:"seq"`
27 TS int64 `json:"ts"`
28 Event *eventwire.Event `json:"event,omitempty"`
29 ReadinessAudit *ReadinessAudit `json:"readiness_audit,omitempty"`
30 AnchorSafetyAudit *AnchorSafetyAudit `json:"anchor_safety_audit,omitempty"`
31 ProtocolRecovery string `json:"protocol_recovery,omitempty"`
32 TurnCompletion bool `json:"turn_completion,omitempty"`
33 ContractShadow *ContractShadowAudit `json:"contract_shadow,omitempty"`
34 CompletionReport *CompletionReport `json:"completion_report,omitempty"`
35 OutcomeProgress *OutcomeProgress `json:"outcome_progress,omitempty"`
36 DelegationAdmission *DelegationAdmission `json:"delegation_admission,omitempty"`
37 MemoryRecall *MemoryRecall `json:"memory_recall,omitempty"`
38 SubagentLifecycle *SubagentLifecycle `json:"subagent_lifecycle,omitempty"`
39 }
40
41 // SubagentLifecycle mirrors the content-free child lifecycle audit.
42 type SubagentLifecycle struct {
43 Phase string `json:"phase"`
44 Ref string `json:"ref,omitempty"`
45 ParentToolCallID string `json:"parent_tool_call_id,omitempty"`
46 Skill string `json:"skill,omitempty"`
47 Model string `json:"model,omitempty"`
48 Effort string `json:"effort,omitempty"`
49 Status string `json:"status,omitempty"`
50 ErrorCode string `json:"error_code,omitempty"`
51 Retryable bool `json:"retryable,omitempty"`
52 OutputBytes int `json:"output_bytes,omitempty"`
53 StartUnixMs int64 `json:"start_unix_ms,omitempty"`
54 EndUnixMs int64 `json:"end_unix_ms,omitempty"`
55 ValidatorMode string `json:"validator_mode,omitempty"`
56 ValidatorOutcome string `json:"validator_outcome,omitempty"`
57 ValidatorAttempt int `json:"validator_attempt,omitempty"`
58 ProviderRequestID string `json:"provider_request_id,omitempty"`
59 }
60
61 // MemoryRecall mirrors event.MemoryRecallAudit with stable snake_case keys.
62 type MemoryRecall struct {
63 Hits []MemoryRecallHit `json:"hits,omitempty"`
64 UsedChars int `json:"used_chars,omitempty"`
65 Omitted int `json:"omitted,omitempty"`
66 Suppressed string `json:"suppressed,omitempty"`
67 ShadowHits []MemoryRecallHit `json:"shadow_hits,omitempty"`
68 }
69
70 type AnchorSafetyAudit struct {
71 Mode string `json:"mode"`
72 TaskMode string `json:"task_mode"`
73 RangeLines int `json:"range_lines"`
74 ObservationAge int `json:"observation_age"`
75 LegacyAllowed bool `json:"legacy_allowed"`
76 ShadowAllowed bool `json:"shadow_allowed"`
77 Reason string `json:"reason"`
78 SameBatchReadRejected bool `json:"same_batch_read_rejected,omitempty"`
79 }
80
81 // MemoryRecallHit is one recalled fact's content-free fingerprint.
82 type MemoryRecallHit struct {
83 ID string `json:"id"`
84 Revision int `json:"revision,omitempty"`
85 Scope string `json:"scope,omitempty"`
86 Type string `json:"type,omitempty"`
87 Freshness string `json:"freshness,omitempty"`
88 Score float64 `json:"score,omitempty"`
89 }
90
91 // DelegationAdmission mirrors event.DelegationAdmissionAudit with stable keys.
92 type DelegationAdmission struct {
93 Tool string `json:"tool"`
94 Verdict string `json:"verdict"`
95 Reason string `json:"reason,omitempty"`
96 Intent string `json:"intent,omitempty"`
97 }
98
99 // OutcomeProgress mirrors evidence.OutcomeSample with stable snake_case keys.
100 type OutcomeProgress struct {
101 Round int `json:"round"`
102 Exploration int `json:"exploration,omitempty"`
103 Verification int `json:"verification,omitempty"`
104 Objective int `json:"objective,omitempty"`
105 Regression int `json:"regression,omitempty"`
106 Churn int `json:"churn,omitempty"`
107 LegacyGain int `json:"legacy_gain,omitempty"`
108 Discriminating int `json:"discriminating,omitempty"`
109 DebtAge int `json:"debt_age,omitempty"`
110 BlindMutations int `json:"blind_mutations,omitempty"`
111 EBMEligible bool `json:"ebm_eligible,omitempty"`
112 EBMFired bool `json:"ebm_fired,omitempty"`
113 LocalExecSeen bool `json:"local_exec_seen,omitempty"`
114 GovernorEligible bool `json:"governor_eligible,omitempty"`
115 GovernorEngaged bool `json:"governor_engaged,omitempty"`
116 // Runway is a pointer so old records (nil: not observed) stay distinct from
117 // a new record whose counterfactual account genuinely reached zero.
118 Runway *int `json:"runway,omitempty"`
119 RunwayDry int `json:"runway_dry,omitempty"`
120 RunwayIdle int `json:"runway_idle,omitempty"`
121 RunwaySpent bool `json:"runway_spent,omitempty"`
122 }
123
124 // ContractShadowAudit mirrors event.ContractShadowAudit with stable keys.
125 type ContractShadowAudit struct {
126 Intent string `json:"intent"`
127 Requirements int `json:"requirements,omitempty"`
128 RequirementsSatisfied int `json:"requirements_satisfied,omitempty"`
129 Checks int `json:"checks,omitempty"`
130 ChecksSatisfied int `json:"checks_satisfied,omitempty"`
131 Epoch uint64 `json:"epoch,omitempty"`
132 Verdict string `json:"verdict"`
133 Complete bool `json:"complete,omitempty"`
134 ReadyToFinalize bool `json:"ready_to_finalize,omitempty"`
135 }
136
137 // CompletionReport mirrors event.CompletionReportAudit with stable keys.
138 type CompletionReport struct {
139 Verdict string `json:"verdict"`
140 Risk string `json:"risk,omitempty"`
141 Criteria int `json:"criteria,omitempty"`
142 CriteriaSatisfied int `json:"criteria_satisfied,omitempty"`
143 Changes int `json:"changes,omitempty"`
144 ChangesUnreviewed int `json:"changes_unreviewed,omitempty"`
145 Verifications int `json:"verifications,omitempty"`
146 VerificationsFailed int `json:"verifications_failed,omitempty"`
147 VerificationsStale int `json:"verifications_stale,omitempty"`
148 Gaps int `json:"gaps,omitempty"`
149 GapKinds []string `json:"gap_kinds,omitempty"`
150 ClaimsVerified int `json:"claims_verified,omitempty"`
151 ClaimsUnbacked int `json:"claims_unbacked,omitempty"`
152 }
153
154 // ReadinessAudit mirrors evidence.ReadinessAudit with stable snake_case keys.
155 type ReadinessAudit struct {
156 Result string `json:"result"`
157 Recovered bool `json:"recovered,omitempty"`
158 MissingProjectChecks int `json:"missing_project_checks,omitempty"`
159 IncompleteTodos int `json:"incomplete_todos,omitempty"`
160 CommandMismatchMissing int `json:"command_mismatch_missing,omitempty"`
161 MissingAcceptanceCriteria int `json:"missing_acceptance_criteria,omitempty"`
162 MissingVerification int `json:"missing_verification,omitempty"`
163 MissingReview int `json:"missing_review,omitempty"`
164 MissingSignoff int `json:"missing_signoff,omitempty"`
165 MissingActionEvidence int `json:"missing_action_evidence,omitempty"`
166 MissingMutation int `json:"missing_mutation,omitempty"`
167 MissingCapabilities int `json:"missing_capabilities,omitempty"`
168 }
169
170 // Recorder is an event.Sink decorator: every event (and optional-capability
171 // audit) is appended as one JSONL record, then forwarded to the inner sink.
172 // Recording failures never block forwarding — the first error is kept and
173 // returned by Close.
174 type Recorder struct {
175 inner event.Sink
176 clock func() time.Time
177
178 mu sync.Mutex
179 file *os.File
180 buf *bufio.Writer
181 enc *json.Encoder
182 seq uint64
183 err error
184 closed bool
185 }
186
187 var _ event.OptionalSinkCapabilities = (*Recorder)(nil)
188
189 // New opens (or truncates) path and returns a Recorder forwarding to inner.
190 // A nil clock means time.Now.
191 func New(inner event.Sink, path string, clock func() time.Time) (*Recorder, error) {
192 f, err := os.Create(path)
193 if err != nil {
194 return nil, err
195 }
196 if clock == nil {
197 clock = time.Now
198 }
199 buf := bufio.NewWriter(f)
200 return &Recorder{inner: inner, clock: clock, file: f, buf: buf, enc: json.NewEncoder(buf)}, nil
201 }
202
203 func (r *Recorder) append(rec Record) {
204 r.mu.Lock()
205 defer r.mu.Unlock()
206 if r.closed || r.err != nil {
207 return
208 }
209 r.seq++
210 rec.SchemaVersion = SchemaVersion
211 rec.Seq = r.seq
212 rec.TS = r.clock().UnixMilli()
213 if err := r.enc.Encode(rec); err != nil {
214 r.err = err
215 return
216 }
217 // Flush per record so a killed run still leaves every completed line.
218 if err := r.buf.Flush(); err != nil {
219 r.err = err
220 }
221 }
222
223 func (r *Recorder) Emit(e event.Event) {
224 w := eventwire.ToWire(e)
225 r.append(Record{Event: &w})
226 r.inner.Emit(e)
227 }
228
229 // RecordDelegationAudit forwards without persisting: delegation receipts are
230 // aggregated by run metrics, and the trajectory schema stays unchanged.
231 func (r *Recorder) RecordDelegationAudit(a evidence.DelegationAudit) {
232 event.RecordDelegationAudit(r.inner, a)
233 }
234
235 func (r *Recorder) RecordReadinessAudit(a evidence.ReadinessAudit) {
236 r.append(Record{ReadinessAudit: &ReadinessAudit{
237 Result: string(a.Result),
238 Recovered: a.Recovered,
239 MissingProjectChecks: a.MissingProjectChecks,
240 IncompleteTodos: a.IncompleteTodos,
241 CommandMismatchMissing: a.CommandMismatchMissing,
242 MissingAcceptanceCriteria: a.MissingAcceptanceCriteria,
243 MissingVerification: a.MissingVerification,
244 MissingReview: a.MissingReview,
245 MissingSignoff: a.MissingSignoff,
246 MissingActionEvidence: a.MissingActionEvidence,
247 MissingMutation: a.MissingMutation,
248 MissingCapabilities: a.MissingCapabilities,
249 }})
250 event.RecordReadinessAudit(r.inner, a)
251 }
252
253 func (r *Recorder) RecordAnchorSafetyAudit(a event.AnchorSafetyAudit) {
254 r.append(Record{AnchorSafetyAudit: &AnchorSafetyAudit{
255 Mode: a.Mode, TaskMode: a.TaskMode, RangeLines: a.RangeLines,
256 ObservationAge: a.ObservationAge, LegacyAllowed: a.LegacyAllowed,
257 ShadowAllowed: a.ShadowAllowed, Reason: a.Reason,
258 SameBatchReadRejected: a.SameBatchReadRejected,
259 }})
260 event.RecordAnchorSafetyAudit(r.inner, a)
261 }
262
263 func (r *Recorder) RecordContractShadow(a event.ContractShadowAudit) {
264 r.append(Record{ContractShadow: &ContractShadowAudit{
265 Intent: a.Intent,
266 Requirements: a.Requirements,
267 RequirementsSatisfied: a.RequirementsSatisfied,
268 Checks: a.Checks,
269 ChecksSatisfied: a.ChecksSatisfied,
270 Epoch: a.Epoch,
271 Verdict: a.Verdict,
272 Complete: a.Complete,
273 ReadyToFinalize: a.ReadyToFinalize,
274 }})
275 event.RecordContractShadow(r.inner, a)
276 }
277
278 func (r *Recorder) RecordCompletionReport(a event.CompletionReportAudit) {
279 r.append(Record{CompletionReport: &CompletionReport{
280 Verdict: a.Verdict,
281 Risk: a.Risk,
282 Criteria: a.Criteria,
283 CriteriaSatisfied: a.CriteriaSatisfied,
284 Changes: a.Changes,
285 ChangesUnreviewed: a.ChangesUnreviewed,
286 Verifications: a.Verifications,
287 VerificationsFailed: a.VerificationsFailed,
288 VerificationsStale: a.VerificationsStale,
289 Gaps: a.Gaps,
290 GapKinds: a.GapKinds,
291 ClaimsVerified: a.ClaimsVerified,
292 ClaimsUnbacked: a.ClaimsUnbacked,
293 }})
294 event.RecordCompletionReport(r.inner, a)
295 }
296
297 func (r *Recorder) RecordOutcomeProgress(sample evidence.OutcomeSample) {
298 runway := sample.Runway
299 r.append(Record{OutcomeProgress: &OutcomeProgress{
300 Round: sample.Round,
301 Exploration: sample.Exploration,
302 Verification: sample.Verification,
303 Objective: sample.Objective,
304 Regression: sample.Regression,
305 Churn: sample.Churn,
306 LegacyGain: sample.LegacyGain,
307 Discriminating: sample.Discriminating,
308 DebtAge: sample.DebtAge,
309 BlindMutations: sample.BlindMutations,
310 EBMEligible: sample.EBMEligible,
311 EBMFired: sample.EBMFired,
312 LocalExecSeen: sample.LocalExecSeen,
313 GovernorEligible: sample.GovernorEligible,
314 GovernorEngaged: sample.GovernorEngaged,
315 Runway: &runway,
316 RunwayDry: sample.RunwayDry,
317 RunwayIdle: sample.RunwayIdle,
318 RunwaySpent: sample.RunwaySpent,
319 }})
320 event.RecordOutcomeProgress(r.inner, sample)
321 }
322
323 func (r *Recorder) RecordMemoryRecall(a event.MemoryRecallAudit) {
324 rec := &MemoryRecall{UsedChars: a.UsedChars, Omitted: a.Omitted, Suppressed: a.Suppressed}
325 for _, hit := range a.Hits {
326 rec.Hits = append(rec.Hits, MemoryRecallHit{
327 ID: hit.ID, Revision: hit.Revision, Scope: hit.Scope,
328 Type: hit.Type, Freshness: hit.Freshness, Score: hit.Score,
329 })
330 }
331 for _, hit := range a.Shadow {
332 rec.ShadowHits = append(rec.ShadowHits, MemoryRecallHit{ID: hit.ID, Score: hit.Score})
333 }
334 r.append(Record{MemoryRecall: rec})
335 event.RecordMemoryRecall(r.inner, a)
336 }
337
338 func (r *Recorder) RecordDelegationAdmission(a event.DelegationAdmissionAudit) {
339 r.append(Record{DelegationAdmission: &DelegationAdmission{
340 Tool: a.Tool, Verdict: a.Verdict, Reason: a.Reason, Intent: a.Intent,
341 }})
342 event.RecordDelegationAdmission(r.inner, a)
343 }
344
345 func (r *Recorder) RecordProtocolRecovery(a event.ProtocolRecoveryAudit) {
346 r.append(Record{ProtocolRecovery: string(a.Kind)})
347 event.RecordProtocolRecovery(r.inner, a)
348 }
349
350 func (r *Recorder) RecordTurnCompletion() {
351 r.append(Record{TurnCompletion: true})
352 event.RecordTurnCompletion(r.inner)
353 }
354
355 func (r *Recorder) RecordWorkspaceMutation(m event.WorkspaceMutation) {
356 event.RecordWorkspaceMutation(r.inner, m)
357 }
358
359 func (r *Recorder) RecordRunBudget(sample event.RunBudgetSample) {
360 event.RecordRunBudget(r.inner, sample)
361 }
362
363 func (r *Recorder) RecordSubagentLifecycle(info event.SubagentLifecycleInfo) {
364 r.append(Record{SubagentLifecycle: &SubagentLifecycle{
365 Phase: info.Phase, Ref: info.Ref, ParentToolCallID: info.ParentToolCallID,
366 Skill: info.Skill, Model: info.Model, Effort: info.Effort, Status: info.Status,
367 ErrorCode: info.ErrorCode, Retryable: info.Retryable, OutputBytes: info.OutputBytes,
368 StartUnixMs: info.StartUnixMs, EndUnixMs: info.EndUnixMs, ValidatorMode: info.ValidatorMode,
369 ValidatorOutcome: info.ValidatorOutcome, ValidatorAttempt: info.ValidatorAttempt,
370 ProviderRequestID: info.ProviderRequestID,
371 }})
372 event.RecordSubagentLifecycle(r.inner, info)
373 }
374
375 // Close flushes and closes the file, returning the first error seen. Events
376 // arriving after Close (late background jobs) are forwarded but not recorded.
377 func (r *Recorder) Close() error {
378 r.mu.Lock()
379 defer r.mu.Unlock()
380 if r.closed {
381 return r.err
382 }
383 r.closed = true
384 if err := r.buf.Flush(); err != nil && r.err == nil {
385 r.err = err
386 }
387 if err := r.file.Close(); err != nil && r.err == nil {
388 r.err = err
389 }
390 return r.err
391 }
392
392 lines GO