Skip to content

feat(tasks/report): accept worker startedAt so AgentRun durations are real (#1120) - #1168

Merged
joryirving merged 8 commits into
mainfrom
courier/misospace/dispatch/issue-1120
Oct 5, 2026
Merged

joryirving merged 8 commits into
mainfrom
courier/misospace/dispatch/issue-1120

Conversation

@itsmiso-ai

@itsmiso-ai itsmiso-ai commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1120

What

POST /api/agents/{agentName}/tasks/report previously stamped AgentRun.startedAt with the report time, so every single-report worker run had zero duration. The report body now accepts an optional startedAt (extended ISO 8601 timestamp with timezone — Z or ±HH:MM — e.g. 2026-10-03T04:20:58Z or Python isoformat ...+00:00; basic-form offsets such as +0000 are outside the accepted grammar and fall back silently by design). When it parses, is not in the future, and is no more than 24h before the report, it is stored as the run's start time so the run records a real duration (finishedAt - startedAt). Missing, malformed, non-string, future, or too-old values silently fall back to the report time — never a 400. Courier will send it (misospace/courier#167).

Changes

  • src/app/api/agents/[agentName]/tasks/report/route.ts — resolveReportedStartedAt helper with a strict ISO-8601-with-timezone gate (ISO_8601_TIMESTAMP_PATTERN, extended format only: Z or ±HH:MM; basic-form +0000 falls back silently by design), not-future check, and 24h age bound (MAX_STARTED_AT_AGE_MS, inclusive: exactly-24h is accepted); now computed once so validation and finishedAt share an instant and startedAt <= finishedAt holds by construction; idempotency payload hash uses the raw body value (reportPayloadHash takes startedAt?: unknown, no cast).
  • src/app/api/agents/[agentName]/tasks/report/route.test.ts — 17 new route tests: valid stored (duration > 0), missing, malformed string, number, object, empty string, future, >24h, ~23h boundary accepted, exactly-24h accepted (fake timers, pins the inclusive bound), non-zero offset (+05:30) stored at the correct UTC instant, bare-year/date-only/locale-ambiguous rejected, +00:00 offset accepted, response echo (normalized / absent), hash divergence for differing startedAt, and identical-body retry across the 24h acceptance boundary replays as duplicate: true (not 409).
  • AGENTS.md — documented the optional startedAt field in the Report Outcomes contract as an extended ISO 8601 timestamp with timezone (Z or ±HH:MM) matching the regex grammar, per the human review nit (f43bcd7); UTC (Z) normalization of the stored/echoed value; and that the echo reflects re-validation of the current request (so an idempotent duplicate replay may omit startedAt after the 24h acceptance window even though the original accepted run stored it).

Audit-fix supersession (merge 08982fb). This branch previously cherry-picked c506e1e (#1169, scripts.audit --include=optional) so this PR could show green CI. main then landed the canonical fix for the same root cause (#1166: explicit --include=prod, guarded by package-audit.test.ts, non-blocking dev-inclusive visibility pass in the security-audit workflow). Merging origin/main into this branch conflicted on package.json and SECURITY-ACCEPTED-RISKS.md; the resolution adopts main's side verbatim in every hunk, so this PR's diff vs main for package.json, SECURITY-ACCEPTED-RISKS.md, .github/workflows/security-audit.yaml, and package-lock.json is now empty — the cherry-picked audit change is fully superseded and this PR again touches no dependency/audit-gate files. The only audit-adjacent remnant is the one-line CAUTION comment this branch added to .npmrc (main didn't touch the file), reworded at the merge to reference the canonical --include=prod / #1166 so it can't point at a flag that no longer exists in the script.

Invariants this change keeps (and every other path enforcing the same rule)

  1. AgentRun.startedAt <= finishedAt; no negative/absurd durations from worker input. Kept here by the not-future + 24h checks against a single now used for both fields. Other AgentRun writers: src/app/api/agents/[agentName]/heartbeat/route.ts and src/lib/groomer/run.ts stamp server-side pairs and are untouched; the legacy POST /api/agent-runs (src/app/api/agent-runs/route.ts:83) still ingests caller-supplied startedAt unvalidated — unchanged by this PR, and it is not the canonical worker path (AGENTS.md designates tasks/report).
  2. Invalid startedAt never 400s — lenient fallback only. raw.startedAt is deliberately excluded from the stringFields validation loop; every rejection path returns undefined → now. Kept by the fallback tests (number/object/empty/future/too-old/date-only/basic-form-offset all expect 200).
  3. Idempotent reporting (feat(queue): expose PR-fix work generations #1044): same key + same payload replays as duplicate; different payload → 409. Because startedAt acceptance is time-relative, hashing the validated value would flip an identical-body retry's hash across the 24h boundary and cause a spurious 409; the hash therefore uses the raw body value (reportPayloadHash({ ...report, startedAt: raw.startedAt })). Pinned by the fake-timer retry-stability test. The duplicate branch's report.startedAt echo re-validates the retry request (documented in AGENTS.md); PR-fix settlement (fix(queue): settle only the issued PR-fix attempt and start head #1074/pr-fix: accept an explicit already_addressed settlement without a push #1121) and the no-re-run-of-side-effects guarantee are untouched.
  4. Report response shape stays additive. report.startedAt appears only when valid (normalized to UTC Z); all pre-existing route tests pass unmodified.
  5. No consumer-side duration math breaks. Grep found no duration computation from startedAt/finishedAt today (src/app/agents/page.tsx only displays startedAt), so the change is display-safe.
  6. npm run audit gates production (+ optional/native) dependencies only and npm ci still installs devDependencies despite a global omit=dev. Now enforced entirely on main by the CI: Security Audit failing on the default branch (npm audit) #1166 fix that this merge adopted verbatim: scripts.audit carries the empirically validated adjacent pair --omit=dev --include=prod, and main's package-audit.test.ts pins the script flags, the "//".audit rationale, and the non-blocking dev-inclusive workflow step. This PR adds no audit-scoping code of its own; it keeps the rule only by not re-introducing the superseded --include=optional variant. Other paths enforcing the same rule: .github/actions/setup-node/action.yml (npm ci --include=dev, install-time only, untouched), Dockerfile prod-deps (npm ci --omit=dev; does not copy .npmrc, immune, untouched), .github/workflows/security-audit.yaml (consumes the script; main's version, untouched by this diff).

Verification

  • vitest full suite at merge head 08982fb: 3806 passed / 16 skipped (includes main's package-audit.test.ts alongside the 17 new route tests); route suite 97 passed.
  • npm run lint and npm run typecheck clean (one pre-existing warning in src/app/login/page.tsx, unrelated).
  • CI at 08982fb: all checks green including npm audit (via main's CI: Security Audit failing on the default branch (npm audit) #1166 gate); independent review pass over the merged tree confirmed zero diff vs main on all audit/dependency files, no merge artifacts, and no blockers/majors in the startedAt change (two info findings: the extended-format-only offset grammar is documented deliberate behavior; the duplicate-replay echo nuance is now documented in AGENTS.md at 23f976f).
  • Human review nit (joryirving, CHANGES_REQUESTED on 23f976f) resolved at f43bcd7 (docs-only): AGENTS.md now reads "an extended ISO 8601 timestamp with timezone (Z or ±HH:MM)", matching ISO_8601_TIMESTAMP_PATTERN; no code change. Branch also synced with main 0b90a70 at merge 0c22e51 (conflict: .npmrc CAUTION comment only — resolved by adopting main's canonical --include=prod / "//".audit wording verbatim, consistent with scripts.audit). Full suite at f43bcd7: 3808 passed / 16 skipped; lint + typecheck clean (pre-existing login/page.tsx warning only).

… real (#1120)

Accept an optional ISO 8601 startedAt in the report body. Store it as the
run's start time when it carries a timezone, parses, is not in the future,
and is within 24h of the report; missing/malformed/future/too-old values
silently fall back to the report time (never a 400). The raw body value
participates in the idempotency payload hash so an identical-body retry
keeps duplicate-replay semantics across the 24h acceptance boundary.
@itsmiso-ai
itsmiso-ai requested a review from joryirving as a code owner October 3, 2026 05:03
its-saffron[bot]

This comment was marked as outdated.

@itsmiso-ai

Copy link
Copy Markdown
Contributor Author

Responses to the AI review findings:

  1. raw.startedAt as string | undefined type assertion (route.ts:290) — intentional and load-bearing. The hash input is deliberately the raw body value, so for non-string inputs (123, {}) the assertion keeps the original value in the hashed object rather than coercing it to undefined. That preserves "different body → different payload identity" (a 123 report and a {} report must not replay each other), which is exactly what the hash-divergence test pins. The assertion is needed because raw is Record<string, unknown> while TaskReportBody.startedAt is string | undefined; canonicalJson handles any JSON-safe value at runtime. Behavior verified by the tests for number/object startedAt (fallback to now, 200) plus the divergence test.

  2. Fake-timer retry-stability test mock injection (route.test.ts:948) — agreed as-is. The inline mockDedupe.findUnique.mockResolvedValueOnce mirrors the existing P2002/idempotency test pattern in this file; restructuring the mock setup would be churn unrelated to what the test proves (identical-body retry across the 24h acceptance boundary replays as duplicate: true, not 409).

On the npm audit failure noted under "Unknowns": confirmed pre-existing on the default branch — the current main tip b0863de shows the identical failure (braces GHSA-vfj7-8cjw-p6xm via @next/eslint-plugin-next → fast-glob → micromatch), and this diff touches no dependency files. Already tracked by open issues #1162/#1166, so no new issue filed.

…op hash cast (#1120)

Independent review of #1168 found three minors: no non-zero-offset test
(TZ conversion exercised only via +00:00), the 24h bound's inclusive >
semantics unpinned at exactly 24h, and a lying `as string | undefined`
cast on the raw hash input. Add the two tests (the boundary one under
fake timers so it is not race-prone), widen reportPayloadHash to accept
startedAt?: unknown so no cast is needed (canonicalJson already takes
unknown; hash output byte-identical), and note UTC normalization of the
stored/echoed startedAt in AGENTS.md.
@itsmiso-ai

Copy link
Copy Markdown
Contributor Author

Follow-up push 068ec8b — all findings from an independent review pass over the diff are now resolved:

Fixed

  1. No non-zero-offset test — added accepts a non-zero UTC offset (+05:30) and stores the normalized UTC instant: renders a ~2h-ago instant in +05:30 and asserts the persisted startedAt equals the UTC reading, pinning TZ conversion (previously only +00:00 exercised it).
  2. Exactly-24h boundary unpinned — added accepts a startedAt exactly 24h old (24h bound is inclusive) under fake timers (mirrors the retry test's setup/cleanup), so the inclusive > semantics are pinned deterministically rather than only at 23h/25h.
  3. Lying cast (raw.startedAt as string | undefined feeding the hash) — reportPayloadHash now takes startedAt?: unknown, no cast; canonicalJson already handles any JSON-safe value, hash output is byte-identical, and the comment block keeping the raw-value rule is unchanged.
  4. Normalization undocumented — AGENTS.md now states an accepted startedAt is normalized to UTC (Z) form when stored and echoed.

Not changed, rationale

  • +0000 (strftime %z) / lowercase z rejected: deliberate — the documented contract is RFC-8601 extended format with timezone (the forms Courier/Python isoformat emit), and every rejection is the silent fallback, never a 400. Widening the grammar would weaken invariant 2's "malformed → fallback" story for no real consumer.
  • now computed before resolveIssueId: required for the shared-instant invariant (startedAt <= finishedAt by construction); the DB-lookup delta it excludes from finishedAt is intentional.
  • #1120/#1044 comment tag: issue-number references in comments are this repo's convention (both are live tracking issues for the behavior the line pins).

Verification at 068ec8b: route suite 97/97, full suite 3800 passed / 16 skipped, lint + typecheck clean.

its-saffron[bot]

This comment was marked as outdated.

@itsmiso-ai

Copy link
Copy Markdown
Contributor Author

Coordinator takeover — state verification (no new code changes needed).

Branch state: courier/misospace/dispatch/issue-1120 @ 068ec8b is current with main (main tip is still the PR base b0863de; git diff origin/main..head = the 3 intended files, no dependency files touched).

Checks at current head: all green — Build, CI (Lint/Typecheck/Tests/Coverage/Database integration/Workflow lint/Database migrations), Docker Build (+MCP), smoke — except npm audit, which I reproduced locally at this head: the identical dev-only chain eslint-config-next → @next/eslint-plugin-next → fast-glob → micromatch → braces (GHSA-vfj7-8cjw-p6xm, advisory range *, no patched release). Because package.json / package-lock.json / .npmrc are byte-identical to main tip here, this failure is identical on main and is not introduced by this diff. It is tracked by #1162 and fixed by open PR #1169 (audit-script --include=optional fix); not duplicating that fix here to keep this PR's diff scoped.

Reviews: both its-saffron reviews (advisory, approval withheld for check-coverage only) have no outstanding findings at this head. Round 1's two info findings were resolved in 068ec8b / answered above; round 2's five claims all "Held" with zero formal findings. No inline review threads. Human review (joryirving) is pending, which is expected for a review-ready PR.

No further changes from this run; PR remains open and review-ready.

@itsmiso-ai

Copy link
Copy Markdown
Contributor Author

Courier: already addressed (run courier-80398826ef09fa000dacfa6032019f9c)

PR #1168 is already review-ready at head 068ec8b: branch current with main tip b0863de (diff = only the 3 intended files, no dependency files), all checks green except npm audit, which I reproduced locally and is byte-identically pre-existing on main (dev-only braces GHSA-vfj7-8cjw-p6xm chain; mergeable_state unstable = failing non-required check) and fixed by separate open PR #1169 per #1162. Both its-saffron advisory reviews addressed: round-1 info findings fixed in 068ec8b and answered in comments, round-2 zero formal findings with all 5 claims Held, no inline threads. Verification comment posted at #1168 (comment)

…es .npmrc include=dev (#1162)

npm gives include=dev precedence over --omit=dev even on the command
line, so npm run audit evaluated the dev tree and failed on the
dev-only, unfixable braces * advisory GHSA-vfj7-8cjw-p6xm via
eslint-config-next. The CLI --include=optional replaces the
project-level include list, restoring a prod+optional-only audit.
its-saffron[bot]

This comment was marked as outdated.

@itsmiso-ai

Copy link
Copy Markdown
Contributor Author

Coordinator takeover — npm audit red resolved by cherry-picking the #1169 fix (head 068ec8b → c506e1e).

Previous state: all checks green at 068ec8b except npm audit, which is the only check that was not already green — a PR that can never show green CI is not review-ready, so I pulled in the approved fix rather than wait on #1169's merge.

What changed: single cherry-pick of 7a6ad35 (#1169 / #1162): scripts.audit gains --include=optional so --omit=dev survives .npmrc's include=dev precedence; docs in package.json "//".audit, .npmrc CAUTION, SECURITY-ACCEPTED-RISKS.md. No package-lock.json or dependencies/overrides edits.

Verified locally at c506e1e: npm run audit → found 0 vulnerabilities (pre-fix on the same tree: the identical 5-high dev-only braces GHSA-vfj7-8cjw-p6xm chain I reproduced before the pick); full vitest suite 3800 passed / 16 skipped; lint + typecheck clean (only the pre-existing login/page.tsx warning). Independent review pass over the cherry-pick: all 7 claims verified against the repo (security-audit workflow, Dockerfile prod-deps, setup-node action, package-overrides.test.ts, all npm audit/.npmrc consumers), zero blockers/minors.

Body updated: the prior "no dependency files are touched" verification claim is superseded; invariant 6 now states the audit-scoping rule and how the other paths (setup-node action, Dockerfile prod-deps, security-audit workflow) are kept.

#1169 carries the identical commit; whichever merges second has an empty delta. PR remains open for human review (joryirving).

its-saffron[bot]

This comment was marked as outdated.

@itsmiso-ai

Copy link
Copy Markdown
Contributor Author

Coordinator takeover — synced with main mid-merge; audit cherry-pick superseded by main's canonical fix.

Merge (08982fb). The branch was mid-merge with origin/main, conflicting on package.json and SECURITY-ACCEPTED-RISKS.md. Main landed #1166 (explicit --include=prod, guarded by package-audit.test.ts, plus a non-blocking dev-inclusive audit step) for the same root cause this branch's cherry-pick c506e1e (#1162/#1169, --include=optional) addressed. Resolution adopts main's side in every hunk — main's package-audit.test.ts pins the literal --omit=dev --include=prod pair, so keeping the cherry-picked variant would have failed main's own guard. Verified post-merge: git diff origin/main...HEAD for package.json, SECURITY-ACCEPTED-RISKS.md, .github/workflows/security-audit.yaml, and package-lock.json is empty; the PR's diff is again only the startedAt work (route + tests + AGENTS.md) plus the branch's .npmrc CAUTION line, reworded to reference --include=prod/#1166 since the --include=optional wording would have pointed at a flag no longer in the script.

Independent review pass over the merged tree: zero blockers/majors; single now shared by validation and finishedAt, inclusive 24h bound, raw-value hash stability, and AGENTS.md/code consistency all confirmed; deterministic fake-timer boundary tests re-run clean 3x. Two info findings, both dispositioned:

  1. Basic-form offsets (+0000/+00) fall back silently — deliberate: the documented contract is RFC-8601 extended format (the forms Courier/Python isoformat emit), and every rejection is the silent fallback, never a 400. No change.
  2. Duplicate-replay echo reflects the retry request's re-validation, so an identical-body retry past the 24h window omits report.startedAt even though the stored run kept it — behavior is intended and pinned by the retry-stability test; reading the stored value in the duplicate branch would add a DB round-trip to a no-op path for no worker benefit. Fixed the under-specification instead: AGENTS.md now states the echo reflects re-validation of the current request (23f976f).

Verification at merged tree: full vitest suite 3806 passed / 16 skipped; lint + typecheck clean (pre-existing login/page.tsx warning only). CI at 08982fb green end-to-end including npm audit. No inline review threads outstanding; all three its-saffron rounds were advisory with no unresolved findings. PR body updated to drop the stale cherry-pick claims. PR remains open for human review (joryirving).

its-saffron[bot]

This comment was marked as outdated.

@joryirving joryirving left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed current head 23f976f. Approve.

I independently traced the route and idempotency behavior against #1120:

  • valid worker startedAt values are normalized and persisted, while missing/malformed/non-string/future/>24h values fall back to the single report-time now without introducing a new 400 path;
  • startedAt <= finishedAt is guaranteed because validation and persistence share the same captured instant;
  • the 24h boundary is inclusive and pinned deterministically;
  • the idempotency hash correctly uses the raw request value, so an identical-body retry does not turn into a 409 merely because re-validation crosses the 24h acceptance boundary;
  • different raw startedAt payloads still produce different hashes;
  • offset timestamps are converted to the correct UTC instant;
  • the duplicate-response echo semantics match the documentation and do not mutate the already-stored AgentRun.

All current-head CI is green: tests, coverage, lint, typecheck, build, DB integration/migrations, both Docker builds, npm audit, smoke, and AI review.

Only non-gating wording nit: AGENTS.md says “full ISO 8601 timestamp with timezone,” while the accepted grammar is deliberately the extended-offset form (Z / ±HH:MM). The PR body already documents that distinction precisely, so I would not hold the implementation for it.

I found no merge blocker.

@joryirving joryirving left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One nit before merge, since this PR already touches the contract documentation:

AGENTS.md: change “a full ISO 8601 timestamp with timezone” to wording that matches the actual accepted grammar, e.g. “an extended ISO 8601 timestamp with timezone (Z or ±HH:MM)”.

The implementation intentionally rejects basic-form offsets such as +0000, so the current wording is broader than the contract. No code change needed; everything else on 23f976f is approved.

its-saffron[bot]

This comment was marked as outdated.

…ormat grammar (#1120)

Human review nit (joryirving) on PR #1168: 'a full ISO 8601 timestamp
with timezone' is broader than the accepted grammar. ISO_8601_TIMESTAMP_PATTERN
accepts extended format only with Z or ±HH:MM offsets; basic-form offsets
(+0000) fall back silently. Reword to 'an extended ISO 8601 timestamp with
timezone (Z or ±HH:MM)'. Docs-only; no behavior change.
@itsmiso-ai

Copy link
Copy Markdown
Contributor Author

Nit addressed in f43bcd7 (docs-only, as requested). AGENTS.md now reads:

The report body also accepts an optional startedAt — an extended ISO 8601 timestamp with timezone (Z or ±HH:MM), for example 2026-10-03T04:20:58Z or 2026-10-03T04:20:58.123456+00:00, marking when the worker began the task.

The wording now matches ISO_8601_TIMESTAMP_PATTERN (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/) — extended format, Z or ±HH:MM, basic-form offsets like +0000 outside the accepted grammar (silent fallback). PR body's "What" section reworded to match. No code change.

Also synced the branch with main in the same push: merge 0c22e51 (main tip 0b90a70), sole conflict the .npmrc CAUTION comment, resolved by adopting main's canonical --include=prod / "//".audit wording verbatim. Verified at f43bcd7: full vitest suite 3808 passed / 16 skipped, lint + typecheck clean (pre-existing login/page.tsx warning only).

its-saffron[bot]

This comment was marked as outdated.

@itsmiso-ai

Copy link
Copy Markdown
Contributor Author

Coordinator takeover — PR is review-ready; one residual terminology nit closed in a0ce647 (comment-only).

State at takeover: head f43bcd7, all 13 checks green (Build, Lint, Typecheck, Tests, Coverage, Database integration, Database migrations, Workflow lint, Docker Build + MCP, smoke, npm audit, AI review). its-saffron approved at f43bcd7; your AGENTS.md nit was already fixed there (f43bcd7) and the grammar matches ISO_8601_TIMESTAMP_PATTERN (extended format only: Z or ±HH:MM).

New in this push (a0ce647): the code comment above ISO_8601_TIMESTAMP_PATTERN still read "a full ISO 8601 timestamp" — the exact phrase your nit objected to in AGENTS.md. Reworded to "an extended ISO 8601 timestamp" so the contract wording is consistent across AGENTS.md and the code. Comment-only diff; no behavior, test, or API-surface change (grep confirms no other "full ISO 8601" occurrences in src/).

Verification: comment-only change — lint/typecheck/tests unchanged; CI is re-running at a0ce647. No inline review threads are open. Requesting your re-review to clear the stale CHANGES_REQUESTED state (your review is on 23f976f; the requested wording landed at f43bcd7 and the last residual use of it at a0ce647).

@its-saffron its-saffron Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Automated Review

Full PR review.

Analysis engine: MiniMax-M3 (anthropic) — primary route · pr-reviewer-action v3.3.0

Findings (1 info)

Severity Location Finding
Info AGENTS.md:320 AGENTS.md now correctly scopes the accepted grammar to extended ISO 8601 with timezone (Z or ±HH:MM); worth a CHANGELOG entry under release/ but not blocking.

Recommendation

Approve. The PR delivers exactly what issue PR 1120 asks for: the optional startedAt is accepted, validated, stored as the run's start when valid, and silently falls back to now otherwise — no 400. The now shared between validation and finishedAt is the right construction (impossible for startedAt > finishedAt), the idempotency hash is over the raw body so an identical retry never 409s across the 24h boundary, and AGENTS.md matches the actual regex grammar (extended format, Z or ±HH:MM). 13 CI checks are green at head a0ce647, including npm audit, full vitest, lint, typecheck, build, DB integration, and migrations. The only .npmrc change is the deleted trailing newline; the audit supersession (merging origin/main over the cherry-pick) is correctly adopted verbatim and is invisible in the diff vs main for package.json, SECURITY-ACCEPTED-RISKS.md, and the audit workflow. The single outstanding human review nit (joryirving on 23f976f) — "extended ISO 8601 with timezone (Z or ±HH:MM)" — is satisfied.

Change-by-change findings

src/app/api/agents/[agentName]/tasks/report/route.ts

  • New TaskReportBody.startedAt?: string field — additive, optional, no breaking change. ✓
  • New MAX_STARTED_AT_AGE_MS = 24h and ISO_8601_TIMESTAMP_PATTERN (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/) — extended format only, basic-form +0000 falls back silently as designed and documented. ✓
  • resolveReportedStartedAt(raw, now) gates in order: non-string → undefined, regex fail → undefined, NaN time → undefined, future (time > now) → undefined, older than 24h (now - time > MAX_STARTED_AT_AGE_MS) → undefined. The 24h comparison is strict-greater, so exactly-24h is accepted (inclusive bound) — pinned by the dedicated test using fake timers at T0 minus 24h. ✓
  • now is computed once before report is assembled, and the same now is used for finishedAt, so startedAt <= finishedAt is guaranteed by construction when startedAt is accepted (worker value <= now = finishedAt) and trivially when fallback fires (both equal now). ✓
  • report.startedAt is the normalized UTC ISO string (parsed and re-serialized via toISOString()); echo reflects the canonical Z form. ✓
  • runData.startedAt is new Date(report.startedAt) when valid, else now — Date object for Prisma. ✓
  • reportPayloadHash signature widened to accept startedAt?: unknown (no cast); the call site explicitly passes raw.startedAt so the idempotency identity is the raw body, not the time-validated normalized value. This is what keeps an identical-body retry deduping instead of 409ing when the same string flips across the 24h boundary between attempts. ✓
  • The duplicate branch (existing.payloadHash !== payloadHash → 409) is unchanged and operates on the raw-hash, so behavior is consistent. ✓

src/app/api/agents/[agentName]/tasks/report/route.test.ts

17 new tests, all consistent with the corpus. Highlights:

  • valid stored (duration > 0): asserts startedAt.getTime() === startInstant and finishedAt > startedAt. ✓
  • missing → startedAt === finishedAt and within before/after. ✓
  • malformed string, number, object, empty string, >24h, future → all collapse to now and return 200. ✓
  • ~23h accepted; exactly 24h accepted (fake timers at T0); non-zero offset +05:30 stored at the UTC instant (TZ-conversion proof, not just accept); Python +00:00 accepted; response echo normalized vs absent; bare-year/date-only/locale rejected. ✓
  • Differing only by startedAt → distinct payloadHash (verifies raw-value participation). ✓
  • Identical-body retry 25h later → duplicate: true, agentRunId: 'run-1', status 200, not 409. ✓

The pre-existing route tests are intact; the helper setup (mockAgentRun.create, mockDedupe, Prisma.PrismaClientKnownRequestError) supports the new cases.

AGENTS.md

New paragraph at line 320 in the Report Outcomes section: optional startedAt, extended ISO 8601 with timezone (Z or ±HH:MM), 24h window, silent fallback (never a 400), UTC (Z) normalization on store/echo, raw value participates in idempotency payload identity, and the echo reflects re-validation of the current request so an idempotent replay may omit startedAt even when the original stored it. The wording matches the regex grammar and addresses the human review nit at 23f976f.

.npmrc

Only the trailing newline on include=dev is removed. Content is otherwise unchanged, including the CAUTION comment that now references #1166 (canonical --include=prod) instead of the obsolete --include=optional. Confirmed by impact scan: #1166 is the live audit-fix identifier across package.json, SECURITY-ACCEPTED-RISKS.md, package-audit.test.ts, the security-audit workflow, Dockerfile, and .npmrc itself. The PR's diff vs origin/main for the audit-fix files is empty (per the PR body's supersession note and the impact scan showing main-side wording in every hunk), so this PR does not re-litigate the audit gate.

Claim verification

  • Claim 1 (AgentRun.startedAt previously stamped with report time → zero duration for single-report runs). Held: the diff removes startedAt: now from runData and replaces it with report.startedAt ? new Date(report.startedAt) : now. No remaining counterexample.
  • Claim 2 (missing/malformed/non-string/future/too-old silently fall back, never 400). Held: resolveReportedStartedAt returns undefined on every rejection branch, and the fallback path runs inside the POST handler after all 400ing validators (taskType / outcome / numbers / string fields / evidence / idempotencyKey / prFixItem) have already passed. No new 400 was introduced on any listed or unlisted sibling code path.
  • Claim 3 (resolveReportedStartedAt strict grammar, not-future, inclusive 24h, single now, raw hash, etc.). Held: regex matches the documented grammar; time > now rejects future; now - time > MAX_STARTED_AT_AGE_MS strictly rejects >24h (so exactly-24h passes, verified by the dedicated fake-timer test); single now is computed once at line 241 and reused; reportPayloadHash is called with { ...report, startedAt: raw.startedAt } so the raw value (not the normalized one) participates in the hash.
  • Claim 4 (17 new tests; cases enumerated). Held: count matches; each enumerated case is present in the diff (valid, missing, malformed string, number, object, empty string, future, >24h, ~23h accepted, exactly-24h accepted under fake timers, +05:30 stored at correct UTC instant, bare-year/date-only/locale rejected, +00:00 accepted, echo normalized, echo absent on invalid, hash divergence for differing startedAt, identical-body retry across boundary → duplicate not 409).
  • Claim 5 (AGENTS.md documents extended ISO 8601 with timezone; UTC normalization; echo reflects current request). Held: AGENTS.md L320 reads "an extended ISO 8601 timestamp with timezone (Z or ±HH:MM), for example 2026-10-03T04:20:58Z or 2026-10-03T04:20:58.123456+00:00"; mentions "normalized to UTC (Z) form when stored and echoed back"; and explicitly notes the echo reflects re-validation of the current request. The human review nit at 23f976f is satisfied.

Sources

  • src/app/api/agents/[agentName]/tasks/report/route.ts L35-72 (interface, constants, helper), L241 (now once), L252-262 (report.startedAt), L268-274 (runData.startedAt), L126-131 (reportPayloadHash), L291-294 (raw-body hash).
  • src/app/api/agents/[agentName]/tasks/report/route.test.ts L733-1040 (17 new tests).
  • AGENTS.md L320 (new contract paragraph).
  • .npmrc (no semantic change; CAUTION references #1166).
  • package.json "//".audit comment (impact scan) and package-audit.test.ts (impact scan) confirm the audit gate is canonical and guarded.
  • CI: 13/13 checks green at head a0ce647.
  • Human review nit (5407799789 against 23f976f): addressed at f43bcd7 (AGENTS.md wording) and a0ce647 (code comment).

Standards Compliance

  • AGENTS.md — agent workflow contract updated to match the implementation; route documentation added; DISPATCH_AGENT_TOKEN bearer-auth path unchanged.
  • .github/ai-review-rules.md — direct, practical, flag real defects, prefer approve when reasonable: this PR meets that bar.
  • Code Standards (AGENTS.md) — input validated before DB operations (startedAt validated before being passed to Prisma); appropriate HTTP status (no spurious 400); no secrets logged or persisted.
  • Repository conventions — the startedAt field is additive (no breaking change to existing callers); the hash widening uses an unknown boundary (no cast) so an unknown body still hashes correctly without type laundering.

Linked Issue Fit

Issue PR 1120's "Done when":

  1. "A report with a valid startedAt stores it, and the duration is finishedAt - startedAt." — Held: runData.startedAt = report.startedAt ? new Date(report.startedAt) : now; test stores a valid worker-reported startedAt… pins finishedAt - startedAt > 0 and the exact stored instant.
  2. "A missing, malformed, future or too-old startedAt falls back to now." — Held: six dedicated tests cover missing, malformed string, future, and >24h; number/object/empty/bare-year/date-only/locale round it out.
  3. "Route tests cover each case." — Held: 17 tests in the new startedAt (#1120) describe block.

The issue's proposal text also asked for "not older than a sane bound (e.g. 24h)"; the implementation picks exactly 24h with an inclusive bound — consistent with the proposal.

Tool Harness Findings

8 tool calls executed (read_file on route.ts at three offsets, route.test.ts at two offsets, AGENTS.md, and .github/ai-review-rules.md; one access-denied on .npmrc which is fine because the diff already shows the change is only the trailing newline on include=dev and the content is otherwise identical). The harness confirmed:

  • now = new Date() is computed exactly once (L241) and the prior const now = new Date() further down has been removed.
  • reportPayloadHash is called with { ...report, startedAt: raw.startedAt } — raw value, not the normalized one.
  • AGENTS.md L320 wording matches the regex grammar (extended format, Z or ±HH:MM) and explicitly addresses the human review nit.
  • The 17 new tests are co-located with the existing persistence/idempotency blocks; the existing postRequest helper and mock setup support the new cases without modification.

Unknowns or Needs Verification

None that block the verdict. The PR is internally consistent, the linked issue is satisfied, the human nit is addressed, and CI is green.

Human review dispositions

[
  {
    "review_id": "5407799789",
    "disposition": "addressed",
    "evidence": "AGENTS.md L320 now says 'an extended ISO 8601 timestamp with timezone (`Z` or `±HH:MM`)', matching the accepted regex grammar `ISO_8601_TIMESTAMP_PATTERN` (`Z` or `±HH:MM`, extended format only; basic-form `+0000` outside the grammar). The residual 'full ISO 8601' wording in the code comment was also reworded to 'extended ISO 8601' at head `a0ce647`."
  }
]

Requirement coverage

  • req-e589bfe1ad7c (Lint/typecheck blocks CI; must pass) — satisfied: Lint and Typecheck both show success in CI at head a0ce647; PR body also states npm run audit (per package.json //.audit comment) plus vitest 3808 passed / 16 skipped at the prior verified head f43bcd7.
  • req-ad902b2a08da (Tokens are secrets. DISPATCH_AGENT_TOKEN and GITHUB_TOKEN must never be logged, echoed, or persisted to disk.) — satisfied: the route never logs the bearer token, never echoes it in startedAt (which is only the report-body field), and never persists it via runData (which contains only agentName, runType, status, startedAt, finishedAt, summary, errorMessage, touchedIssueUrls, issueId). No token is written to the AgentRun row.

Outstanding Human Change Requests

  • @​joryirving's change request (23f976f, head moved since) is not shown addressed at this head; it needs the reviewer's own re-review.

@itsmiso-ai itsmiso-ai added the needs-human Human input or decision is required. label Oct 5, 2026
@itsmiso-ai

Copy link
Copy Markdown
Contributor Author

⚠️ This PR fix item has been marked as BLOCKED and needs human attention.

Reason: PR review: CHANGES_REQUESTED

Total attempts: 192

Attempts by lane:

  • NEEDS_HUMAN: 1 attempt
  • NORMAL: 191 attempts

Failing run(s):

Last attempt: One nit before merge, since this PR already touches the contract documentation:

AGENTS.md: change “a full ISO 8601 timestamp with timezone” to wording that matches the actual accepted grammar, e.g. “an extended ISO 8601 timestamp with timezone (Z or ±HH:MM)”.

The implementation intentionally rejects basic-form offsets such as +0000, so the current wording is broader than the contract. No code change needed; everything else on 23f976f is approved.

Posted automatically by Dispatch on 2026-10-05T09:10:47.139Z

@joryirving joryirving left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed current head a0ce647. Approve.

The only item from my prior change request is fully addressed:

  • AGENTS.md now says “an extended ISO 8601 timestamp with timezone (Z or ±HH:MM)”, matching the actual regex grammar.
  • The same stale “full ISO 8601” wording in the route comment was also cleaned up to “extended ISO 8601,” so the implementation and contract documentation now use the same terminology.

I rechecked the core behavior as well: raw-body idempotency hashing, inclusive 24h validation, UTC normalization, and silent fallback for invalid/non-string/future/too-old values remain unchanged.

Current-head CI, Security Audit, image build, PR Smoke, and AI review are all green. I found no remaining blocker or nit.

@joryirving
joryirving merged commit 4d9af7a into main Oct 5, 2026
14 checks passed
@joryirving
joryirving deleted the courier/misospace/dispatch/issue-1120 branch October 5, 2026 12:25
@its-miso its-miso Bot mentioned this pull request Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-human Human input or decision is required.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tasks/report: accept the worker's startedAt so AgentRun durations are real

2 participants