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

@@ -58,13 +58,37 @@ function stringifyResult(result: unknown): string {
// Pure derivation, parameterized so it can back both the global "current
// session" activityLog below and a per-session activityLogFor(sessionId) for
// a floating task window.
function computeActivityLog(
//
// Timestamps are REAL where the data has them and FROZEN where it doesn't:
// - persisted tool calls use their message's created_at (a true time);
// - steps use their started_at when present;
// - only genuinely-live entries (a tool call on the in-flight message that
// has no created_at yet) fall back to wall-clock, and that value is frozen
// into `frozen` on FIRST sight so a re-derive (the 3s poller re-sets
// `messages` every tick) reads the same value instead of marching every
// entry forward. Before this, every entry's time was
// `now - (len - index) * 1000` — fabricated at render time and churning
// every poll (P0.2). `frozen` is owned by the caller and lives across
// re-derivations; pass a fresh Map for a purity test.
export function computeActivityLog(
$msgs: ChatMessage[],
$steps: PlanStep[],
$task: Session | null
$task: Session | null,
frozen: Map<string, number>
): ActivityEntry[] {
const entries: ActivityEntry[] = []
const now = Date.now()
// freeze: prefer a real persisted time; else reuse a value already pinned
// for this id; else pin wall-clock now and remember it.
const freeze = (id: string, real?: number | null): number => {
if (real && real > 0) return real
const hit = frozen.get(id)
if (hit !== undefined) return hit
frozen.set(id, now)
return now
}
const tsOf = (iso?: string): number | undefined =>
iso ? new Date(iso).getTime() || undefined : undefined
// Goal
if ($task?.goal) {
@@ -87,7 +111,7 @@ function computeActivityLog(
s.status === 'running' ? 'step_running' : s.status === 'done' ? 'step_done' : 'step_failed',
description: `Step ${s.seq}: ${stepLabel}`,
detail: s.detail || undefined,
timestamp: s.started_at ? new Date(s.started_at).getTime() : now,
timestamp: freeze(s.id, tsOf(s.started_at)),
status: s.status === 'running' ? 'running' : s.status === 'done' ? 'done' : 'failed'
})
}
@@ -97,6 +121,7 @@ function computeActivityLog(
let currentStepSeq = 0
let entryIdx = 0
for (let mi = 0; mi < $msgs.length; mi++) {
const msgTs = tsOf($msgs[mi].created_at)
for (const t of $msgs[mi].tools) {
// Track current step from update_plan_step calls
if (t.type === 'tool_use' && t.name === 'update_plan_step') {
@@ -109,13 +134,14 @@ function computeActivityLog(
const label = toolActivityLabel(t)
const stepTag = currentStepSeq > 0 ? currentStepSeq : undefined
const id = t.id ?? `tool_${mi}_${entryIdx++}`
if (t.type === 'tool_use') {
entries.push({
id: t.id ?? `tool_${mi}_${entryIdx++}`,
id,
type: 'tool_running',
description: label,
args: summarizeArgs(t.args),
timestamp: now - ($msgs.length - mi) * 1000,
timestamp: freeze(id, msgTs),
toolName: t.name,
stepSeq: stepTag,
indent: stepTag != null,
@@ -142,12 +168,12 @@ function computeActivityLog(
// full entry itself. It used to fall back to the raw tool name
// (e.g. "get_entity") instead of the humanized label here.
entries.push({
id: t.id ?? `tool_${mi}_${entryIdx++}`,
id,
type: t.error ? 'tool_error' : 'tool_done',
description: t.error ? `${label}: ${t.error.slice(0, 80)}` : label,
detail: t.error ? t.error : stringifyResult(t.result),
args: summarizeArgs(t.args),
timestamp: now - ($msgs.length - mi) * 1000,
timestamp: freeze(id, msgTs),
toolName: t.name,
stepSeq: stepTag,
indent: stepTag != null,
@@ -160,14 +186,16 @@ function computeActivityLog(
// Knowledge recorded — detect from upsert_knowledge tool results
for (let mi = 0; mi < $msgs.length; mi++) {
const msgTs = tsOf($msgs[mi].created_at)
for (const t of $msgs[mi].tools) {
if (t.type === 'tool_result' && t.name === 'upsert_knowledge' && !t.error) {
const title = typeof t.args?.title === 'string' ? t.args.title : ''
const kid = `knowledge_${mi}_${t.id ?? ''}`
entries.push({
id: `knowledge_${mi}`,
id: kid,
type: 'knowledge',
description: title ? `Recorded: ${title.slice(0, 60)}` : 'Recorded knowledge',
timestamp: now - ($msgs.length - mi) * 1000,
timestamp: freeze(kid, msgTs),
status: 'done'
})
}
@@ -180,7 +208,7 @@ function computeActivityLog(
id: 'complete',
type: 'complete',
description: $task.summary || `Task ${$task.outcome}`,
timestamp: now,
timestamp: freeze('complete'),
status: $task.outcome === 'failure' ? 'failed' : 'done'
})
}
@@ -200,8 +228,13 @@ function computeActivityLog(
return entries
}
// A per-store freeze map: the first time a live entry (no real timestamp
// yet) is seen, its wall-clock time is pinned here so the 3s poller's
// re-derivation can't march it forward. Owned here, outside the derivation,
// so it survives re-runs. The per-session path has its own Map keyed by id.
const frozenTimestamps = new Map<string, number>()
export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) =>
computeActivityLog($msgs, $steps, $task)
computeActivityLog($msgs, $steps, $task, frozenTimestamps)
)
// Attach streaming output to the `run` entry that is currently executing.
@@ -222,13 +255,21 @@ function withLiveOutput(
return entries
}
// One freeze map per session window (entry ids are UUIDs, but the synthetic
// 'goal'/'complete' ids collide across sessions, so each window keeps its own).
const sessionFrozenTimestamps = new Map<string, Map<string, number>>()
export function activityLogFor(sessionId: string): Readable<ActivityEntry[]> {
const chat = chatFor(sessionId)
const ws = workspaceFor(sessionId)
const task = taskFor(sessionId)
const live = liveExecutionOutputFor(sessionId)
let frozen = sessionFrozenTimestamps.get(sessionId)
if (!frozen) {
frozen = new Map<string, number>()
sessionFrozenTimestamps.set(sessionId, frozen)
}
return derived([chat.messages, ws.planSteps, task, live], ([$msgs, $steps, $task, $live]) =>
withLiveOutput(computeActivityLog($msgs, $steps, $task), $live)
withLiveOutput(computeActivityLog($msgs, $steps, $task, frozen), $live)
)
}