Skip to content

Add transcript-grounded structured task state - #761

Merged
anandh8x merged 2 commits into
mainfrom
perf/structured-task-state
Jul 19, 2026
Merged

Add transcript-grounded structured task state#761
anandh8x merged 2 commits into
mainfrom
perf/structured-task-state

Conversation

@anandh8x

@anandh8x anandh8x commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add a deterministic per-run TaskState projection for objective, plan, tool results, changed files, verification, and completion
  • emit bounded, content-free aggregate snapshots through the existing trace recorder
  • preserve the immutable objective across repeated compaction and ground completion checks in that objective
  • use structured plan state only when plan parity matches the transcript; mismatches retain transcript-derived completion behavior and omit uncorroborated mutable compact state

Scope and safety

  • no new dependency, model call, tool, database, UI, or autonomous execution behavior
  • trace events exclude objective text, objective fingerprints, plan text, tool output, and file paths
  • task-state trace history is coalesced and capped while retaining the latest aggregate
  • compact objective context is capped at 512 bytes
  • plan parsing now matches update_plan rejection and normalization behavior

Validation

  • make fmt-check
  • go vet ./...
  • go test ./...
  • go test -race ./internal/agent ./internal/trace
  • go run ./cmd/zero-release build
  • go run ./cmd/zero-release smoke
  • govulncheck ./... (no vulnerabilities found)
  • git diff HEAD --check

The pinned advisory golangci-lint command reports only unrelated pre-existing repository findings; no finding points to a file changed by this PR.

Project existing plan, tool-result, changed-file, verification, and completion events into a deterministic per-run snapshot.

Emit content-free aggregate snapshots to traces. Carry bounded objective context through compaction and ground completion checks only when the projection matches the transcript; mismatches retain transcript-derived behavior and drop stale carried state.

Tested: make fmt-check

Tested: go vet ./...

Tested: go test ./...

Tested: go test -race ./internal/agent ./internal/trace

Tested: go run ./cmd/zero-release build

Tested: go run ./cmd/zero-release smoke

Tested: govulncheck ./... (no vulnerabilities found)

Advisory: pinned golangci-lint reports only unrelated pre-existing findings

Tested: git diff HEAD --check
@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: d9ed9c0084cd
Changed files (15): internal/agent/compaction.go, internal/agent/compaction_preserve.go, internal/agent/compaction_preserve_test.go, internal/agent/compaction_test.go, internal/agent/completion_policy.go, internal/agent/completion_policy_test.go, internal/agent/guardrails.go, internal/agent/loop.go, internal/agent/task_state.go, internal/agent/task_state_test.go, internal/trace/emit.go, internal/trace/parse.go, and 3 more

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@anandh8x
anandh8x requested review from Vasanthdev2004 and gnanam1990 and removed request for gnanam1990 July 19, 2026 15:31

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST CHANGES — one real defect, small fix. Everything else is solid.

The projection is well-built: deterministic, side-effect-free on the loop, nil-safe throughout, and genuinely additive. My one blocking issue is that the headline compaction behavior stops working in exactly the scenario it exists for. Details below, then the things I checked that are fine.


[Major] The objective is dropped at the second compaction

compareTranscript treats a plan mismatch as a whole-state mismatch:

if !valid || found != state.planObserved || (found && !reflect.DeepEqual(transcriptPlan, tracked)) {
    parity = taskStateParityMismatch
}

latestTaskPlan scans messages[i].ToolCalls for planToolName. But compaction elides the middle of the transcript, which is where that tool call lives — extractLatestPlan(middle) exists precisely because the plan is being removed and has to be re-preserved as text. So after the first compaction, found is false while planObserved stays true, and parity flips to mismatch.

That alone would be harmless, but appendPreservedState also drops the carried copy:

task := priorState.Task
if taskStateChecked { task = nil }        // taskStateChecked is always true in Run
if taskSnapshot != nil { task = ... }     // nil when parity mismatched

So on compaction #2, taskSnapshot is nil and the previously preserved task block is deleted rather than carried. Reproduced:

compaction #1: snapshot=true   objective preserved=true
compaction #2: snapshot=false  objective preserved=false
>>> OBJECTIVE LOST at compaction #2

Parity does recover once the model issues a fresh update_plan (I checked — it returns to match), so this is a window rather than a permanent state. But the window is unbounded: nothing forces the model to re-plan, and a run that compacts twice without an intervening plan update loses the objective entirely. That's the long-running case the feature is built for, and it fails silently — the trace still says parity=mismatch but nothing tells you the objective went missing.

The fix is small. The objective is captured once from the run's own prompt (newTaskState(prompt, …)) and is immutable for the lifetime of the run — it cannot be stale relative to the transcript, unlike the plan and verification counts. Gating it on plan parity is over-broad. Preserve Objective unconditionally and gate only the mutable fields (Status, plan counts, verification counts) on parity. That keeps the stated safety property — no stale structured state — while making the objective actually survive.

I'd also suggest taskStateParityMismatch not be the trigger for deleting a carried block that a previous, matching compaction wrote. "I can't corroborate the plan right now" is weaker evidence than "the carried objective is wrong."


Minor

  • Parity is plan-only, but named and documented as whole-state. compareTranscript compares Plan.Items and nothing else, yet taskStateParityMatch and the PR body's "use structured state only when it matches the transcript" read as though the whole projection is corroborated. Today the exposure is small (preservedTaskState only carries objective, status, and counts), but the naming invites a future field to be added to the snapshot and silently assumed verified. Consider planParityMatch, or a comment stating exactly what is and isn't compared.
  • parseTaskPlan is more lenient than the tool it now mirrors. It accepts a step alias and silently skips empty-content items; update_plan's parsePlanItems requires content via stringArg(..., true) and hard-rejects the entire call on a bad item. So guardrails and TaskState can track a plan the tool refused to store. The underlying shape (observing arguments rather than tool results) predates this PR, but consolidating onto one parser is the moment to make the two agree — or to note in parseTaskPlan's doc that it is deliberately laxer and why.
  • TaskStates grows per tool call, not per turn. observeemit fires on every tool result, so this slice is O(tool calls) while PrefixHashes and friends are O(turns), and there's no cap. A few hundred tool calls is a few hundred KB of extra NDJSON. Worth a cap, or emitting only on plan/verification/completion transitions and letting tool counters ride along with the next event.
  • Variadic optional dependencies. newTaskState(objective, recorders ...*trace.Recorder) and newCompactionState(options, tasks ...*taskState) silently ignore everything past the first argument. A plain nilable parameter says the same thing, can't be mis-called with two, and doesn't need the len(...) > 0 dance.
  • compareTranscript reads like a query but mutates. It bumps Revision and emits a trace event, and it's called from both completionContext and snapshotForCompaction — so a single completion check can bump the revision. Worth a name that signals the write, or a note on the method.
  • Nit on the privacy claim. ObjectiveHash is a bare SHA-256 of the objective. That's not reversible, but it is confirmable: objectives are low-entropy and often short ("fix the login bug"), so anyone with the trace and a candidate list can test guesses. If "content-free" is meant strictly, an HMAC with a per-run random key gives you the same within-run drift detection without the confirmation oracle.

Checked and fine

  • planStatusRemaining consolidation is behavior-preserving. I compared the old inline switch against normalizeTaskPlanStatus term by term — the "not remaining" set is exactly completed|failed under both, and every other vocabulary word lands on pending/in_progress as before.
  • Routing observePlanUpdate and formatPlanArguments through parseTaskPlan is a genuine fix, not just a refactor. Both previously parsed raw arguments without the enforceSingleInProgress coercion the update_plan tool applies, so the guardrail's pending count and the post-compaction plan text could disagree with the plan the tool actually stored. They now agree, and parseTaskPlan's coercion matches enforceSingleInProgress exactly (all but the last in_progresscompleted). Good catch to fold these together.
  • capTaskObjective is UTF-8 safe — backs up with utf8.RuneStart before slicing, so the 512-byte cap can't split a rune.
  • The new defer is sound. result is a named return, so the deferred compareTranscript(result.Messages) observes the final value. EmitTaskState is nil-safe, mutex-guarded, and no-ops once r.finished is set, so it cannot write into a sealed recorder no matter how the defers interleave.
  • The parity fallback on the completion path is safe. When parity is mismatch, completionContext falls back to guards.pendingPlanItems(), and guardState isn't reset by compaction, so the transcript-derived signal survives. Verified the flag flips exactly as documented.
  • Trace payload matches the stated scope — counts, statuses, and an objective hash; no plan text, tool output, or file paths, and only len(ChangedFiles).

Verification

gofmt clean, go vet clean. internal/trace fully green including -race. internal/agent passes under -race -count=2 for the task-state, compaction-preserve, and completion-policy suites.

One internal/agent failure locally — TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp — is a sandbox artifact of my environment, not this PR: it fails identically on this PR's exact base (2e267bd), which I checked out separately to confirm rather than assuming.

Nice piece of work overall — the plan-normalization consolidation is a real correctness win independent of the new feature. Fix the objective-preservation gating and I'm happy.

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The agent now maintains deterministic task state from plans, tools, verification, and completion events; preserves task context during compaction; and records content-free task-state snapshots in traces with NDJSON and text output support.

Changes

Task state lifecycle

Layer / File(s) Summary
Task projection and plan normalization
internal/agent/task_state.go, internal/agent/guardrails.go, internal/agent/task_state_test.go
Task state tracks plan progress, tool results, verification, completion, changed files, and transcript parity using normalized plans and immutable snapshots.
Run-loop task and completion integration
internal/agent/loop.go, internal/agent/completion_policy.go, internal/agent/completion_policy_test.go
The run loop records task events, compares the final transcript, and evaluates completion using structured context including objective and pending-plan state.
Transcript-verified compaction preservation
internal/agent/compaction.go, internal/agent/compaction_preserve.go, internal/agent/*_test.go
Compaction carries task snapshots, caps objectives safely, preserves objectives across parity mismatch, and conditionally retains mutable task fields.
Task-state trace transport
internal/trace/trace.go, internal/trace/recorder.go, internal/trace/emit.go, internal/trace/parse.go, internal/trace/task_state_test.go
Trace recording, copying, NDJSON parsing, text rendering, bounded storage, and optional-event metadata now support content-free task-state events.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Run
  participant taskState
  participant Compaction
  participant Recorder
  Run->>taskState: observe plan, tool, verification, and completion events
  Run->>taskState: observePlanParity(messages)
  taskState->>Compaction: snapshotForCompaction(messages)
  Compaction->>Run: append preserved task state
  taskState->>Recorder: EmitTaskState(snapshot)
  Recorder->>Run: return task-state trace entries
Loading

Possibly related PRs

  • Gitlawb/zero#131: Shares the preserved-state injection and compaction parsing paths.
  • Gitlawb/zero#700: Overlaps in proactive and reactive compaction integration with trace-related state.
  • Gitlawb/zero#719: Provides the completion-policy integration extended with structured completion context here.

Suggested reviewers: vasanthdev2004, gnanam1990

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding transcript-grounded structured task state.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/structured-task-state

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/agent/loop.go (1)

578-628: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Task state can report "complete" for a turn that isn't actually final.

task.observe(..., CompletionComplete) fires at Line 581 (RequireCompletionSignal path) and Line 614 (gate-off path) before the shared postEditDiagnostics.drainFinal check at Lines 622-628, which can still continue the loop instead of returning. Between those two points, the emitted TaskStateEvent trace shows status: complete, only to flip back to active on the next turn's plan/tool/verification event. Result itself is unaffected (it's set only after the gate), so this is purely an observability-accuracy gap in the new trace contract, but it's easy to make exact by deferring the "complete" observation past the diagnostics gate.

🔧 Sketch of the fix
 			if options.RequireCompletionSignal {
 				completionContext := task.completionContext(messages, guards.pendingPlanItems())
 				evaluation := completionPolicy.evaluate(collected.Text, completionContext)
-				task.observe(taskStateEvent{kind: taskStateEventCompletion, completion: evaluation})
 				switch evaluation.Decision {
 				case CompletionIncomplete:
+					task.observe(taskStateEvent{kind: taskStateEventCompletion, completion: evaluation})
 					result.Incomplete = true
 					result.IncompleteReason = evaluation.Reason
 					result.FinalAnswer = collected.Text
 					result.Messages = copyMessages(messages)
 					return result, nil
 				case CompletionUncertain:
+					task.observe(taskStateEvent{kind: taskStateEventCompletion, completion: evaluation})
 					posture.observeUncertain()
 					...
 					continue
 				case CompletionComplete:
-					// Local evidence is sufficient; proceed to final diagnostics.
+					// Deferred: recorded as complete only once past the diagnostics gate below.
 				}
-			} else {
-				task.observe(taskStateEvent{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionComplete}})
 			}
             ...
 			if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
 				messages = append(...)
 				continue
 			}
+			task.observe(taskStateEvent{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionComplete}})
 			result.FinalAnswer = collected.Text
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/loop.go` around lines 578 - 628, Defer the taskStateEvent
completion observation in the RequireCompletionSignal and gate-off branches
until after postEditDiagnostics.drainFinal returns no nudge. Preserve the
existing incomplete and uncertain handling, and emit CompletionComplete only
immediately before the actual final result return so task state does not report
completion when diagnostics cause another loop iteration.
🧹 Nitpick comments (2)
internal/agent/task_state.go (1)

1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Variadic pointer param used to fake an optional argument. Both newTaskState and newCompactionState take ...*T solely to make one pointer argument optional, which silently swallows any extra args and is non-idiomatic Go for "optional" parameters.

  • internal/agent/task_state.go#L97-110: change newTaskState(objective string, recorders ...*trace.Recorder) to newTaskState(objective string, recorder *trace.Recorder) and have callers pass nil explicitly (loop.go's newTaskState(prompt, options.Trace) already works either way; tests would need newTaskState("...", nil)).
  • internal/agent/compaction.go#L377-388: change newCompactionState(options Options, tasks ...*taskState) to newCompactionState(options Options, task *taskState) for the same reason.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/task_state.go` at line 1, Replace the variadic optional
parameters in newTaskState and newCompactionState with single pointer
parameters, *trace.Recorder and *taskState respectively. Update all call sites
and tests to pass nil explicitly when no recorder or task is available, while
preserving existing non-nil behavior.
internal/agent/compaction_preserve_test.go (1)

63-113: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing coverage for the "no task wired, carry prior task forward" branch.

Both new tests cover taskStateChecked=true (match and mismatch). Neither covers taskStateChecked=false with a pre-existing carried preservedTaskState — the case where a later Compact call has no running taskState at all and must pass through whatever was preserved earlier untouched (per appendPreservedState's own doc comment on taskStateChecked). Worth a small regression test mirroring TestCompactDropsCarriedTaskContextAfterParityMismatch but with taskStateChecked: false, asserting the prior Task block survives.

As per coding guidelines, "**/*_test.go: ... add regression tests for behavior changes."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/compaction_preserve_test.go` around lines 63 - 113, Add a
regression test alongside TestCompactDropsCarriedTaskContextAfterParityMismatch
that supplies a prior preservedTaskState in the input, leaves
CompactionOptions.taskStateChecked false with no taskState, and verifies the
resulting preserved state retains the prior Task unchanged. Mirror the existing
compaction setup and assertions while covering the no-task-wired carry-forward
branch.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/agent/loop.go`:
- Around line 578-628: Defer the taskStateEvent completion observation in the
RequireCompletionSignal and gate-off branches until after
postEditDiagnostics.drainFinal returns no nudge. Preserve the existing
incomplete and uncertain handling, and emit CompletionComplete only immediately
before the actual final result return so task state does not report completion
when diagnostics cause another loop iteration.

---

Nitpick comments:
In `@internal/agent/compaction_preserve_test.go`:
- Around line 63-113: Add a regression test alongside
TestCompactDropsCarriedTaskContextAfterParityMismatch that supplies a prior
preservedTaskState in the input, leaves CompactionOptions.taskStateChecked false
with no taskState, and verifies the resulting preserved state retains the prior
Task unchanged. Mirror the existing compaction setup and assertions while
covering the no-task-wired carry-forward branch.

In `@internal/agent/task_state.go`:
- Line 1: Replace the variadic optional parameters in newTaskState and
newCompactionState with single pointer parameters, *trace.Recorder and
*taskState respectively. Update all call sites and tests to pass nil explicitly
when no recorder or task is available, while preserving existing non-nil
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6661b4f5-1f35-449e-84f8-132542c3c7de

📥 Commits

Reviewing files that changed from the base of the PR and between 2e267bd and b3052df.

📒 Files selected for processing (14)
  • internal/agent/compaction.go
  • internal/agent/compaction_preserve.go
  • internal/agent/compaction_preserve_test.go
  • internal/agent/completion_policy.go
  • internal/agent/completion_policy_test.go
  • internal/agent/guardrails.go
  • internal/agent/loop.go
  • internal/agent/task_state.go
  • internal/agent/task_state_test.go
  • internal/trace/emit.go
  • internal/trace/parse.go
  • internal/trace/recorder.go
  • internal/trace/task_state_test.go
  • internal/trace/trace.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 19, 2026
Keep the immutable run objective when plan calls have been elided, while admitting mutable task fields only when plan parity still matches the transcript.

Make plan parsing match update_plan rejection semantics, coalesce and cap task-state trace events, rename parity to its plan-only scope, use explicit nilable dependencies, and remove confirmable objective fingerprints from traces.

Tested: make fmt-check

Tested: go vet ./...

Tested: go test ./...

Tested: go test -race ./internal/agent ./internal/trace

Tested: go run ./cmd/zero-release build

Tested: go run ./cmd/zero-release smoke

Tested: govulncheck ./... (no vulnerabilities found)

Advisory: pinned golangci-lint reports only unrelated pre-existing findings

Tested: git diff HEAD --check

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE. Re-reviewed at d9ed9c0. The major is fixed correctly — I re-ran my repro against the new head rather than reading the diff — and all six minors plus the nit are closed.

The major — verified fixed

The fix is the right one: Objective is now set unconditionally whenever a snapshot exists, and only the mutable fields are gated on parity.

task = &preservedTaskState{Objective: capTaskObjective(taskSnapshot.Objective)}
if taskSnapshot.PlanParity == taskPlanParityMatch {
    task.Status = ...; task.Pending = ...; task.VerificationPassed = ...
}

snapshotForCompaction no longer returns nil on mismatch (it records parity and returns the snapshot), and taskStateChecked is gone entirely — which removes the "delete the carried copy" path that was doing the damage. Re-ran the exact repro from last round:

compaction #1: parity=match     objective preserved=true  counts carried
compaction #2: parity=mismatch  objective preserved=true
OK: objective preserved, mutable fields correctly withheld on mismatch

I also added the inverse assertion — that a matching parity still carries the plan counts — so the fix isn't just "preserve everything always." It passes. The distinction now lands exactly where it should: the immutable objective always survives, the corroboration-dependent counts don't.

Minors — all closed

  • Parity scope naming. Renamed throughout: taskStateParitytaskPlanParity, compareTranscriptobservePlanParity, StateMatchesTranscriptPlanMatchesTranscript, TranscriptParityPlanParity. The new doc comment states plainly that "objective, tool, and verification fields are not part of this comparison." That was the point — a future field can no longer be silently assumed corroborated.
  • parseTaskPlan leniency. Now rejects empty content outright and drops the step alias, so it matches update_plan's own strictness instead of tracking plans the tool would have refused. TestParseTaskPlanRejectsArgumentsTheToolWouldReject and TestFormatPlanArgumentsRejectsUnsupportedStepAlias pin both directions. Worth noting the len(plan) == 0 → (nil, true) return is retained, so both sides of the reflect.DeepEqual parity check stay nil-typed and an empty plan can't produce a spurious mismatch — I checked that specifically, since the rewrite of this function was the place it could have crept in.
  • Trace growth. Tool results no longer emit (event.kind != taskStateEventToolResult), with a final task.emit() in the defer so aggregate counts still land on an early return. Plus a hard maxTaskStateEvents = 128 cap that replaces the tail rather than the head, so the final aggregate is never the one dropped — the right end to sacrifice.
  • Variadic optional deps. newTaskState(objective, recorder) and newCompactionState(options, task) take plain parameters now.
  • Mutating query. observePlanParity names the write and documents that it mutates and emits.
  • Objective hash. Removed from the trace event entirely, and the type doc now says "confirmable content fingerprints are deliberately excluded." That's a cleaner resolution than the HMAC I suggested — if nothing consumes it, not emitting it is strictly better.

Verification

gofmt clean, go vet clean. internal/trace green including -race -count=2. internal/agent green under -race -count=2 for the task-state, compaction-preserve, completion-policy, and plan suites. Eight new tests landed, mapping 1:1 onto the findings.

Four internal/agent failures locally (TestRunPromptsAndAllowsOutsideWorkspaceWrite, TestRunSessionAllowsLaterOutsideWorkspaceWrite, TestRunAppliesSandboxEvenInUnsafeMode, TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp) are sandbox artifacts of my environment — I ran all four against this PR's exact base (2e267bd) and they fail identically there.

Good turnaround. The objective/mutable split is a cleaner invariant than what was there before the review, not just a patch over the symptom. Merge is kevin's call per the program gate.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against the program gates, with the two things I most wanted to be sure of on a task-state projection: that the trace stays content-free, and that grounding completion in the objective does not quietly change exit-code behavior. Both hold up.

Trace privacy is the strong part. TaskStateEvent carries only counts and enum strings, no objective text, no plan content, no tool output, no file paths, and deliberately not even an objective_hash, so a traced run cannot confirm a guessed objective. It is content-free by construction rather than by scrubbing, which is the right way to build this, and the round-trip test plus the bounded-growth cap (128 with tail replacement) back it up. Changed files are kept in memory for the count only and never emitted or persisted, and the objective that does land in the compaction block is the user's own prompt that already lives in the transcript, capped at 512 bytes with UTF-8-safe truncation, so nothing new leaks.

Completion behavior is preserved, not just claimed. The structured PlanPending is only trusted when observePlanParity says the projection still matches the transcript, and on a match Plan.Pending+Plan.InProgress is the same quantity the transcript path already computes via pendingPlanItems, so the decision is unchanged; on a mismatch it falls back to the transcript count. The only new prompt content is the objective appended to the acceptance-verification nudge, which is on the opt-in semantic-check path. So headless exit semantics are the same and interactive runs stay byte-identical.

The plan-parsing consolidation is the one change that could read as a regression but is actually a correctness fix. I checked parseTaskPlan against the real update_plan tool: the tool requires content (no step field) and its enforceSingleInProgress keeps only the last in_progress item, downgrading earlier ones to completed. The new shared parser matches both, where the old formatPlanArguments was looser than the tool and accepted the step alias. So dropping step and coercing multiple in_progress lines up compaction and guardrails with the tool's real semantics, and planStatusRemaining keeps its prior result.

Gates: content-free trace (4), no default-path behavior change with a transcript fallback (2), nothing touching permissions/sandbox (3) or one-result-per-call (5), and the event is opt-in and listed in OptionalEventKeys (10). One non-blocking micro-note: observePlanParity re-walks the message history for the latest plan on each completion check and each compaction; it early-exits from the tail so it is cheap in practice, just worth remembering if the completion path ever gets hot.

Deterministic projection, snapshots are copied so callers cannot mutate the source, tool results are coalesced so the trace does not balloon. Nice work, and it closes out the task-state stream. Approving.

@anandh8x

Copy link
Copy Markdown
Collaborator Author

Addressed in d9ed9c0.

  • Added an exact two-compaction regression: after the first compaction elides the plan call, the second still preserves the immutable objective and omits uncorroborated mutable fields.
  • Renamed transcript parity to plan parity and made the mutating observation explicit.
  • Tightened plan parsing to reject the same malformed shapes as update_plan.
  • Coalesced tool-result snapshots, capped retained task-state events at 128, and kept the latest aggregate at the cap.
  • Replaced variadic optional dependencies with explicit nilable parameters.
  • Removed the objective hash entirely so traces contain neither objective text nor a confirmable objective fingerprint.

Validation is green: formatting, vet, unrestricted full tests, focused race tests, build, smoke, and govulncheck. The advisory lint output remains limited to unrelated pre-existing findings.

@anandh8x
anandh8x merged commit 9242b9e into main Jul 19, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants