fix(nomos): generation-relative plan seq + real activity timestamps
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

The plan recorded false history after a re-plan and the activity panel
showed fabricated, churning timestamps. Two bugs compounding on one event
stream.

Plan drift (P0.1):
- proposePlan seq is now 1..N per generation; (session,generation,seq) is
  the addressing key. The model's 1-based update_plan_step calls always map
  to the CURRENT plan after a re-plan, instead of resurrecting a superseded
  `replaced` row as done while the live work went unrecorded.
- updatePlanStep resolves against MAX(generation); a stale/out-of-range seq
  returns errPlanStepNotFound (never touches a superseded generation).
- getPlanSteps returns only the current generation by default; ?all=true
  keeps the audit/eval view (plan_generations assertion).
- completeTask auto-close scopes to the current gen, stamps started_at, and
  emits one plan.step.finished per closed step so the panel converges
  instead of freezing on "running" after completion (P1.1).
- propose_plan result enumerates step seqs; writeback detector matches
  "write back"/"writeback"/"upsert_knowledge" so a natural-language final
  step isn't doubled (P1.2).
- migration 029 renumbers existing seq per generation + unique index.

Activity panel (P0.2 / P1.1, web):
- computeActivityLog uses the real message created_at for tool calls; live
  entries fall back to wall-clock frozen on first sight, killing the 3s
  poll churn. Steps use real started_at.
- dropped plan-step events warn + count instead of a silent no-op.

Tests: TestProposePlan updated; + generation-relative-seq and auto-close
event-emission regression tests; + web activity purity/timestamp tests.

VERSION: 0.14.0 -> 0.14.1
This commit is contained in:
2026-07-30 22:40:56 +02:00
parent e25e979757
commit 467589d78a
10 changed files with 499 additions and 67 deletions

View File

@@ -0,0 +1,103 @@
import { describe, it, expect, vi } from 'vitest'
import { writable } from 'svelte/store'
// computeActivityLog is a pure derivation; it only needs TYPES from the store
// modules, so mock their runtime exports to keep the test isolated from the
// real store graph (workspace.ts, e.g., starts a top-level setInterval).
vi.mock('./chat', () => ({
messages: writable([]),
chatFor: vi.fn(() => ({
messages: writable([]),
streaming: writable(false),
connectionState: writable('connected'),
error: writable(null),
notFound: writable(false)
}))
}))
vi.mock('./workspace', () => ({
planSteps: writable([]),
currentTask: writable(null),
workspaceFor: vi.fn(() => ({})),
taskFor: vi.fn(() => writable(null))
}))
vi.mock('./execstream', () => ({
liveExecutionOutputFor: vi.fn(() => writable(null))
}))
import { computeActivityLog } from './activity'
import type { ChatMessage } from './chat'
import type { PlanStep, Session } from '$lib/api'
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
function msg(partial: Partial<ChatMessage> & { id: string }): ChatMessage {
return {
id: partial.id,
role: 'assistant',
text: '',
tools: partial.tools ?? [],
pendingApprovals: [],
created_at: partial.created_at
}
}
function toolResult(name: string, id: string): NonNullable<ChatMessage['tools']>[number] {
return { type: 'tool_result', name, id, result: 'ok' }
}
describe('computeActivityLog timestamps (P0.2)', () => {
it('uses the message created_at for persisted tool calls, not a fabricated spread', () => {
const created = '2026-07-29T20:08:10Z'
const m = msg({
id: 'm1',
created_at: created,
tools: [toolResult('get_entity', 't1'), toolResult('run', 't2')]
})
const entries = computeActivityLog([m], [], null, new Map())
const want = new Date(created).getTime()
for (const id of ['t1', 't2']) {
const e = entries.find((x) => x.id === id)
expect(e, `entry ${id} should exist`).toBeDefined()
expect(e!.timestamp).toBe(want) // real time, shared per message — no now-(len-i)*1000
}
})
it('freezes live entries (no created_at) so re-derivation never churns them', async () => {
const m = msg({ id: 'm1', tools: [toolResult('run', 't1')] }) // no created_at
const frozen = new Map<string, number>()
const a = computeActivityLog([m], [], null, frozen)
const ts1 = a.find((e) => e.id === 't1')!.timestamp
await sleep(60) // the 3s poller re-derives on a later tick
const b = computeActivityLog([m], [], null, frozen)
const ts2 = b.find((e) => e.id === 't1')!.timestamp
expect(ts2).toBe(ts1) // frozen — the previous bug marched every entry forward
})
it('is pure w.r.t. wall-clock: two calls with identical inputs give identical output', async () => {
const created = '2026-07-29T20:08:10Z'
const msgs = [
msg({ id: 'm1', created_at: created, tools: [toolResult('get_entity', 't1')] }),
msg({ id: 'm2', created_at: created, tools: [toolResult('run', 't2')] })
]
const steps: PlanStep[] = [
{ id: 's1', seq: 1, title: 'A', detail: '', status: 'done', started_at: created }
]
const task = { id: 'sess', outcome: 'success', summary: 'done' } as unknown as Session
const frozen = new Map<string, number>()
const a = computeActivityLog(msgs, steps, task, frozen)
await sleep(50)
const b = computeActivityLog(msgs, steps, task, frozen)
expect(b.map((e) => [e.id, e.timestamp, e.type])).toEqual(
a.map((e) => [e.id, e.timestamp, e.type])
)
})
it('uses a step started_at instead of falling back to now', () => {
const started = '2026-07-29T20:07:40Z'
const steps: PlanStep[] = [
{ id: 's1', seq: 1, title: 'A', detail: '', status: 'done', started_at: started }
]
const entries = computeActivityLog([], steps, null, new Map())
expect(entries.find((e) => e.id === 's1')!.timestamp).toBe(new Date(started).getTime())
})
})