Skip to content

A run's acts are read instance-wide: two overlapping runs on one Coder instance each report the other repo's pushes, on the card, the resume note and check_delegation #809

Closed

Description

@serge-ivo

What is wrong

actsInWindow returns every consequential act on the instance during a run's time window. It is meant to return only the acts of the session that run was driving. A Coder instance holds several repos, each with its own session, and those sessions can run concurrently. When two runs on one instance overlap in time, each one reports the other repo's pushes, merges and deletes as its own. That happens on the three surfaces a human or a supervisor reads:

  • the run's outcome note and board card
  • the resume note handed to the next run
  • check_delegation

This is a code-path finding, not confirmed in production. I have not measured whether two runs on different repos of one instance have actually overlapped on this account. Everything below is read from the code at b47cf05e.

Mechanism (verified)

1. The query does not filter by session. workers/api/src/lib/instance-work.ts:400-404:

`SELECT ${ACT_COLUMNS}
   FROM agent_events
  WHERE instance_id = ?1 AND user_id = ?2 AND event = ?5 AND ts >= ?3 AND ts <= ?4
  ORDER BY ts ASC LIMIT ?6`,

Its own doc comment (:381-390) states the intent it does not enforce: "The window is precise enough — it is the interval during which this run was the one driving the session." It keys on time instead of trace_id only because a console terminal poll can drain a run's acts and stamp the session id first. That argues against keying on the trace id, not for including other sessions.

2. Every act already records its session. workers/api/src/lib/engine-acts.ts:609-616:

context: {
	act: act.kind,
	command: act.command,
	target: act.target,
	irreversible: act.irreversible,
	ok: act.ok,
	sessionId: ctx.sessionId,
},

It has been there since acts were first recorded (9958d7a9, #294). act.consequential is written nowhere else, so no act row lacks it. context is TEXT -- JSON extras (migrations/0038_agent_events.sql:17), written with JSON.stringify(...).slice(0, 4000) (lib/events.ts:72). The runner caps command at 400 characters and target at 120 (engine-acts.ts:81,504,517), so the slice cannot cut off sessionId.

3. The run's own card. workers/api/src/workflows/coding-session.ts:206-207:

const acts = event.payload.loopRunId || event.payload.boardTaskId
	? await actsInWindow(env, userId, instanceId, runStartedAt, Date.now()).catch(() => [])

sessionId is in scope: it is destructured from event.payload at :73 and never reassigned.

4. The resume note. workers/api/src/lib/coding-resume-note.ts:164-167:

const prev = await lastUnfinishedRunForRepo(env, params.userId, params.instanceId, params.sessionId, now);
if (!prev) return null;
// The window is the interval during which that run was the one driving its session.
const acts = await actsInWindow(env, params.userId, params.instanceId, prev.startedAt, prev.finishedAt ?? now, 100);

prev carries sessionId (agent-loop-store.ts:179). Since f8ded1bb the predecessor is found by repo, so the note is exactly the reader that must not pick up another repo's acts.

5. check_delegation. workers/api/src/lib/connectors/supervision.ts:736-742:

const acts = await actsInWindow(
	ctx.env,
	userId,
	run.instanceId,
	run.startedAt,
	run.finishedAt ?? Date.now(),
).catch(() => [] as ActItem[]);

run comes from getLoopRun, whose view carries sessionId: string | null (agent-loop-store.ts:155,179).

Fix

  • Add a sessionId: string | null parameter to actsInWindow. When it is set, add AND json_valid(context) AND json_extract(context, '$.sessionId') = ?. That is the same guarded form routes/admin-trace.ts:89 already uses on this table.
  • When it is null, return [] without querying. Only the chat driver creates a run row with no session (loop-drivers.ts:113-125), and a chat run drives no engine. Any coding act inside its window belongs to some other session, so "none" is the correct reading.
  • Pass the session at all three call sites: sessionId, prev.sessionId and run.sessionId.
  • Leave recentActsForInstances instance-wide on purpose. It is a supervisor's feed across instances, not one run's record.

Test plan

  • A real-schema test (d1-sqlite, the coding-resume-note-repo.test.ts pattern): one instance, two repos, two sessions, overlapping windows, acts written for each session. Assert each call returns only its own session's acts, and assert a null session returns [] without querying.
  • End to end through pendingCodingResumeNote: a successor on repo R must not be told about a push that repo O's run made in the same window.
  • Mutation check: remove the session predicate and watch the tests fail.

Refs #806 (where this was noticed and left unfiled), #294 (acts on delegated runs), #766 (the ts = 0 misattribution fix, same family).

Activity

  1. added
    bugSomething isn't working
    backendBackend / Worker / API work
    P2: correctnessReal defect, no live harm today — inert fields, miscounts, missing guards
    coderThe Coder wedge agent (#68) — Engine, Pilot, Co-pilot, Loop, Overseer
    observabilityA displayed value the code cannot produce, or that means something other than its label
    on Sep 13, 2026
  2. serge-ivo commented on Sep 13, 2026

    @serge-ivo
    ContributorAuthor

    Fixed in cda8ef91. CI ✅ (run) and Deploy API Worker ✅ (run).

    What changed

    • actsInWindow (lib/instance-work.ts) now takes a sessionId: string | null. When it is set, the query adds AND json_valid(context) AND json_extract(context, '$.sessionId') = ?7. When it is null, it returns [] without querying.
    • All three call sites pass a session:
      • the run's card, workflows/coding-session.ts: sessionId
      • the resume note, lib/coding-resume-note.ts: prev.sessionId, the predecessor's session rather than the caller's, which since f8ded1bb is usually a later session
      • check_delegation, lib/connectors/supervision.ts: run.sessionId
    • Unchanged on purpose: recentActsForInstances stays instance-wide. It is a supervisor's feed across instances, not one run's record.

    Tests

    • instance-work-session.test.ts (new, real migrated schema). Two repos on one instance, with overlapping runs:
      • each run reads only its own session's acts
      • the time bound still holds within a session
      • a null session gets nothing
      • a row with malformed context is skipped instead of failing the read
    • coding-resume-note-repo.test.ts: a repo-O act inside run A's window no longer reaches repo R's successor. The fixture's acts now carry sessionId, as real rows do.
    • instance-work.test.ts: the bind shape, and a null session issues no query.
    • supervision.test.ts: the A delegated run records its outcome but not its consequential acts — merging to main is invisible #294 fixture gains session_id, as every coding driver writes it.

    Mutation-checked:

    • Dropping the session predicate fails 6 tests.
    • Dropping json_valid fails the malformed-context test, because json_extract raises on bad JSON.
    • Letting a null session fall through to the query fails the no-query test.

    Suites: full suite 11,514/11,514. API typecheck (source and tests), pnpm -r typecheck, biome, every check-*.mjs guard and docs:drift are clean. The change is line-neutral in both pinned files.

    Not verified

    • Production occurrence. I have not observed two repos' runs overlapping on one instance, so there is no before/after card to show. The evidence is the code path plus the real-schema tests.
    • Chat-driven runs. A run with no session now reports no acts in check_delegation, where it previously showed whatever coding acts fell inside its window. That is the intended reading, since a chat run drives no engine, and it is the one visible behaviour change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2: correctnessReal defect, no live harm today — inert fields, miscounts, missing guardsbackendBackend / Worker / API workbugSomething isn't workingcoderThe Coder wedge agent (#68) — Engine, Pilot, Co-pilot, Loop, OverseerobservabilityA displayed value the code cannot produce, or that means something other than its label

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions