Skip to content

instance_runtime_tasks.updated_at is written in two text formats, so ORDER BY updated_at DESC ranks a 00:30 runner task above the 22:00 escalation and the LIMIT drops the wrong rows #634

Description

@serge-ivo

instance_runtime_tasks.updated_at is written in two mutually-unsortable text formats, so every ORDER BY updated_at DESC … LIMIT over the board mirror ranks a 00:30 runner task above an escalation raised at 22:00 the same day.

Found in the D1 sweep prompted by #438 (no test in this repo executes SQL). This is the #587 shape one level down: not two queries disagreeing, but two writers disagreeing about the byte format of the column a reader sorts on.

Verified — the column has two writers with two formats

datetime('now') → SQLite's YYYY-MM-DD HH:MM:SS (space at index 10):

  • workers/api/src/lib/work-card.ts:22-23upsertWorkCard, the ONE upsert every board domain goes through:
    INSERT INTO instance_runtime_tasks (id, instance_id, user_id, type, status, payload, created_at, updated_at)
     VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'), datetime('now'))
    Callers: lib/coding-board.ts:91 (coding-session cards), lib/pipeline-board.ts:54 (pipeline runs), lib/coding-authority.ts:355, lib/repo-policy-act.ts:241 and :275, workflows/coding-session.ts:255.
  • workers/api/src/lib/work-card.ts:63closeWorkCards: updated_at = datetime('now')
  • workers/api/src/lib/work-card.ts:93setWorkCardProgress: updated_at = datetime('now')
  • workers/api/src/workflows/agent-loop.ts:312-313 — the escalation card, i.e. "a supervised agent needs a decision":
    VALUES (?1, ?2, ?3, 'escalation', 'needs_human', ?4, datetime('now'), datetime('now'))
  • workers/api/src/lib/loop-drivers.ts:268-269 — the delegation card, same shape.
  • workers/api/src/workflows/job-apply.ts:141, routes/instances-apply.ts:246, routes/instances-browse.ts:183updated_at = datetime('now') on failure.

ISO-8601 with T/Z (index 10 is T):

  • workers/api/src/routes/instances-runtime.ts:250-262mirrorRuntimeTask binds ?8 from
    taskTimestamp(task.updatedAt ?? task.updated_at ?? createdAt), and taskTimestamp is
    workers/api/src/routes/instances-runtime.ts:155-159:
    export function taskTimestamp(value: unknown): string {
    	return typeof value === "string" && value.trim()
    		? value
    		: new Date().toISOString();
    }
    The runner-supplied value is ISO too — packages/browser-runner/src/runner.ts:180,195,235,243,253,260,474,1079,1140,1153,1172 all assign task.updatedAt = new Date().toISOString().
  • workers/api/src/routes/instances-tasks.ts:242-245SET status = 'running', updated_at = ?1 bound with new Date().toISOString().
  • workers/api/src/routes/instances-apply.ts:104 and routes/instances-browse.ts:108 — the single-flight claim rows bind nowIso.

Verified — SQLite orders these by bytes, so the two formats never interleave

D1/SQLite compares TEXT with BINARY collation. ' ' is 0x20, 'T' is 0x54, so for any two rows with the same date every ISO row sorts above every datetime('now') row regardless of the time of day:

$ sqlite3 :memory: "create table t(u text);
  insert into t values('2026-08-16T01:00:00.000Z'),('2026-08-16 23:00:00'),('2026-08-16T02:00:00.000Z'),('2026-08-16 09:00:00');
  select u from t order by u desc;"
2026-08-16T02:00:00.000Z
2026-08-16T01:00:00.000Z
2026-08-16 23:00:00
2026-08-16 09:00:00

Verified — three readers sort on it, and each pairs the sort with a LIMIT

The sort is not cosmetic: it decides which rows are returned at all.

  1. workers/api/src/routes/instances-runtime.ts:281-286mirroredRuntimeTasks:
    SELECT payload FROM instance_runtime_tasks
     WHERE instance_id = ?1 AND user_id = ?2 AND hidden = 0
     ORDER BY updated_at DESC
     LIMIT ?3
    Default limit = 200 (:279).
  2. workers/api/src/lib/instance-work.ts:220-224recentWorkForInstances, one branch per subordinate:
    SELECT instance_id, id, type, status, payload, updated_at
      FROM instance_runtime_tasks
     WHERE instance_id = ?${p} AND user_id = ?1 AND hidden = 0
     ORDER BY updated_at DESC LIMIT ?2
    clampPer(perInstance, 8) at :2168 rows per instance.
  3. workers/api/src/lib/board.ts:295 calls mirroredRuntimeTasks(env, instanceId, userId, BOARD_TASK_LIMIT) with BOARD_TASK_LIMIT = 1000 (lib/board.ts:127).

User-visible impact

  • The supervision surface is the sharp one. lib/connectors/supervision.ts:358 calls
    recentWorkForInstances(ctx.env, userId, ids, limit) — this is what a supervisor agent reads to
    answer "what have my subordinates been doing". Only 8 cards come back per subordinate, and any
    subordinate with more than 8 non-hidden cards gets its 8 chosen by this ordering. A coding-session
    card, a pipeline card, a delegation card and an escalation card are all datetime('now')
    writers, so they lose to every runner-mirrored task stamped anywhere earlier the same day. The
    card that says "a supervised agent needs a decision" (workflows/agent-loop.ts:312) is exactly
    the one that drops off.
  • GET /v1/instances/:id/tasks (routes/instances-tasks.ts:81 and :121) returns
    mirroredRuntimeTasks(...) at the 200 default with no pagination and no truncation flag, so
    the console has no way to know rows were dropped or that the drop was arbitrary. (The board path
    does report truncatedlib/board.ts:415 — but only at 1000.)

Not claimed / inferred

  • lib/board.ts:415 re-sorts the assembled items in JS with Date.parse(item.updatedAt) off the
    payload, and the payload's updatedAt is mixed the same way (work-card.ts:62 and :92 write
    json_set(payload, '$.updatedAt', datetime('now')) while runner payloads are ISO). V8 parses
    "YYYY-MM-DD HH:MM:SS" as local time and the Workers runtime is UTC, so this JS sort is probably
    correct today. Not verified, and not part of the claim above — the SQL ordering is broken
    independently of it, and the payload mixing is worth fixing for the same reason.
  • I have not measured a production row count, so I cannot say how often the 200/1000 caps bind. The
    8-per-instance supervision cap binds at nine cards.

The repo already knows this hazard exists

workers/api/src/lib/error-log.ts:93 carries a private one-liner for exactly this conversion:

const sqlTime = (ms: number) => new Date(ms).toISOString().slice(0, 19).replace("T", " ");

grep -rn "sqlTime" workers/api/src → 2 hits, both in error-log.ts. lib/admin.ts:116-118 (sinceTs), routes/admin-instance-detail.ts:166-168 (thirtyDaysAgo) and lib/external-usage.ts:170-173 each re-derive the same three lines locally. Four independent copies of the fix, none of them shared, and instance_runtime_tasks got none of them.

Suggested shape of a fix (not prescriptive)

Pick one format for the column, convert the other writers, and pin it with a test that inserts through both writer paths and asserts the SQL ordering — which is the class of test #438 says does not exist yet. A shared sqlTime/nowSql helper in lib/ would give the four existing copies somewhere to collapse into.

Refs #438.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions