chore(release): pending release v0.107.1 - #1515
Merged
Merged
Conversation
* fix(gateway): narrow int64 repository ids at the API boundary GitHub models repository ids as int64, and @octokit/openapi-types v28 now types them honestly as number | bigint. databaseId is a redaction denylist deny key matched by Set<number> membership, where a bigint never matches and a lossy Number() above 2^53 could collide with a different repo's id — so neither propagating the union nor converting blindly is safe. The conversion now happens once, at the only boundary where a repository id enters, and only when the value round-trips exactly. An id that cannot be represented yields a null databaseId rather than a wrong one, and keeps its nodeId, which is independently usable as a deny key. Consumers omit a null rather than persisting it, leaving the stored binding shape unchanged, and the runtime narrowing in bindingToRepoKey is untouched, so a binding with neither key is still denied. Unreachable today at ids near 1e9, and no behavior changes at any id in use. It compiles against both the current and the bumped octokit types, so it stands on its own rather than riding a dependency update. Refs #1467 * fix(gateway): centralize the repo-id narrowing and prove denial without a numeric key Review follow-ups on the int64 narrowing. The narrowing is now a named helper rather than an expression, and rejects any id that is not a positive exactly-representable integer. Its parameter spans both octokit response shapes, so the file still compiles against the current and bumped types. Both binding writers shared one omit-when-null rule expressed two different ways; that policy now lives in a single helper, which also strips a stored id that is not a usable number rather than carrying it forward. The backfill's skip check asked whether a stored id was merely defined, while its sibling in the redaction gate asked whether it was a number — the two now agree, so a malformed stored id is repaired instead of being mistaken for a complete one. Adds the test the design actually rests on: a binding with no numeric key but a denied nodeId is still denied, an allowed one still allowed, and a binding with neither key denied. Also covers the non-bigint rejections. Refs #1467 * fix(gateway): apply one repository-id rule on both sides of the deny-key match The previous commit made the binding side strict about repository ids and left the denylist source side accepting any finite number. Both sides used to be lossy in the same way, so an oversized id matched itself by accident; with the binding side now yielding null, a redacted repos.yaml entry carrying only a database_id and no node_id would have stopped matching — allowing a repo that must be denied, and falsifying this change's own claim that a binding keyed by nodeId still denies. One predicate now defines a usable numeric repository id, and the read boundary, the write boundary, and the denylist source all consult it. A redacted entry whose only key is unrepresentable fails the load closed, which is what the surrounding hardening already intended. The backfill also treats a nodeId as sufficient completion, so a binding whose id is not representable is no longer re-resolved and rewritten on every pass; such a binding can only come from that same path, so re-fetching could never have produced a numeric key anyway. Refs #1467 * fix(gateway): make a deny-all schema failure locatable and say what actually failed Tightening the denylist source means one malformed database_id now takes the whole operator surface to deny-all, and the errors named neither the entry nor the real defect. One message still blamed a missing database_id field for entries where the field was present but unusable, and neither carried anything an operator could use to find the row in a file the message must not identify by name. Both now report the entry's index. That is a position, not an identity: the message reaches only doRefresh's server-side logger, never a response or the audit stream, and anyone who can read the file can already count rows. Also records the surviving non-positive bigint check as a fast path rather than distinct validation, and corrects the AGENTS.md paragraph that still claimed the denylist source rule was unchanged after the commit that changed it. Refs #1467 * fix(gateway): name the offending index on the malformed-entry failure too The two schema failures that fail the denylist load closed gained an entry index; the third, one line above them in the same loop, did not — and it is the most reachable of them, since a stray dash or a commented-out body produces a null entry while the others need a value past 2^53. The index was already in scope. No no-oracle concern: the entry is still unclassified at that point, so the code has not determined whether it is redacted, and the index carries less than the two messages already reporting it. Refs #1467
Co-authored-by: fro-bot[bot] <109017866+fro-bot[bot]@users.noreply.github.com>
Co-authored-by: fro-bot[bot] <109017866+fro-bot[bot]@users.noreply.github.com>
* docs(plans): plan the session-cache bootstrap trap fix Refs #1407 * docs(plans): deepen the session-cache trap plan The first draft's central claim was wrong. Declining to persist bad state prevents new poisoning but cannot escape existing poisoning: save keys are unique per run and restore keys are prefixes returning the newest match, so a run that declines to save leaves the poisoned entry as the newest one and the next run restores it again. The loop stays absorbing. The repair belongs on restore. Checkpointing a hot database between restore and bootstrap recovers the session instead of discarding it, heals a stuck repository in one run, and lands outside the budget that was failing, since no timer spans those two calls. Also corrects three things the draft asserted without grounds: that the harness could await the child's exit, which it cannot observe; that the checkpoint belonged in the session runtime, when both save paths and the object-store sync share one function at the cache boundary; and that whether the runtime could checkpoint at all was unknowable before implementation. Refs #1407 * fix(cache): normalize the transported database set and make the bootstrap budget visible Units 1, 4, 5 and 6 of the session-cache trap fix. The wal-index is machine-local and SQLite never syncs it, so a copy from another runner is stale by construction. It is no longer captured on save, and a copy carried in by an older cache is deleted on restore rather than left to age out. Restore still tolerates its presence so entries written before this change still work. Declaring storage corrupt now actually clears the database. It lives beside the storage directory rather than inside it, so 'proceed with clean state' had been leaving the very files it meant to discard. The object-store restore also gains the corruption and version checks the cache path already had: it wins on restore and returns before either one ran, so the authoritative path was the unchecked one. Which files constitute the database is now defined once, in the runtime package, because the object-store sync there cannot import from the Action. The name had been written out in three places. The server bootstrap budget is configurable and reported. It was the SDK's 5000ms default, never passed and never logged, against runs observed landing 16ms inside it. Time spent in the spawn is measured apart from the total so the budget comparison is exact and a slow port bind is distinguishable from slow server init. session-retention now reaches pruning. It was parsed, validated, logged, and then never read, so a consumer bounding their cache with it got a validated no-op. Its default matches the hardcoded value it replaces, so an unset input changes nothing. Refs #1407 * fix(cache): checkpoint the session database before anything captures it No run had ever checkpointed it. server.close() sends a kill and returns without waiting, so a valid session could sit entirely in the write-ahead log with the database file at zero bytes, and the cache carried that hot log to the next run, which paid recovery cost opening it — measured at 2416.8ms for a 185MB log against 60.2ms once merged. The checkpoint runs inside saveCache, which both the cleanup phase and the post hook already call and which also owns the object-store sync, so every capture path inherits it without a flag they must agree to read. It runs before anything inspects file sizes, since merging changes which files are non-empty. Success is judged by the log's size on disk rather than the pragma's own count, which reported zero on a verified-successful truncation. The outcome has three states: collapsing 'already clean' into 'failed' would decline saves that were always safe. The harness cannot observe whether the writer exited, so the attempt is the liveness probe — a live writer surfaces as busy and is retried within a bound. A log that still cannot be merged declines the save and says why in the job summary, because a save that silently does not happen is the failure this repository already paid for once. Refs #1407 * fix(cache): repair a restored database before the server opens it This is what gets a repository out of the loop rather than merely stopping it from entering one. Save keys are unique per run and restore keys are prefixes returning the newest match, so a run that declines to save writes no entry and the next restore hits the same bad one. Declining forever is a stable state. Checkpointing the restored database heals it in place. It runs between restore and bootstrap, where no timer applies — the budget covers createOpencode alone — so the recovery cost moves out of the window that was timing out instead of being paid inside it. Repairing rather than wiping is the point. Wiping would also break the loop, but it discards session history on every transient bootstrap failure, which is the second reporter's case exactly. A repair is logged because it is the evidence a stuck repository healed, and its absence later is the evidence it stayed healed. The healthy case is silent, and a failed repair still lets bootstrap try. Closes #1407 Refs #1407 * fix(cache): correct a false claim about the database's on-disk shape A comment asserted a valid session could leave opencode.db at zero bytes with its data in the write-ahead log. Three independent reviewers read it and each filed the same bug against the checkpoint helper's zero-byte guard, claiming it skipped the case it exists for. The guard is right and the comment was wrong. A database in write-ahead mode with no checkpoint leaves the main file at its header page — 4096 bytes on Node 24.20.0, never zero — and forcing it to actually zero makes the log unreadable, so that shape holds no session to save. The comment now states the measured value, and a test pins the real shape so the misreading cannot recur. Also from review: the errno checks in the two delete paths assumed every thrown value carries a code, so a non-errno failure was silently treated as a missing file; both now narrow before reading it. The bootstrap timeout is bounded, since an unbounded value would hang a run to the job timeout rather than failing fast, which is the opposite of what that input is for. And the restore repair gains a test that drives a real database rather than a mock, covering the wiring the ordering tests cannot see. Refs #1407 * fix(cache): treat an unusable restored database as corrupt rather than passing it on Review follow-ups on the trap fix. A malformed database still trapped a repository in the loop this branch exists to break, reached through corruption instead of a hot log. The checkpoint already distinguished a live writer from a file SQLite says is not a database; only the first is worth retrying, and the second now routes into the clean-slate path restore already used for corrupt storage. That covered a malformed database carrying a log, since that is the only shape anything opened. One with no log was never opened at all, so it reached bootstrap, failed, and was saved again. Restore now checks that a database it is about to hand over can actually be read — one schema-page read, a few milliseconds and independent of size, outside the budget. Construction alone does not surface this: opening a file of plain text succeeds and only the read reports it is not a database. The repair's retries bounded their sleeps but not their work, so a large log could spend far longer than intended ahead of bootstrap. A deadline between attempts caps that without ever interrupting one in flight. Also records why declining to save rarely costs a run its work — the post hook retries once the child has exited — because a refactor that marked the cache saved on decline would remove that quietly. And corrects a comment claiming save paths are filtered by existence when only the log is. Refs #1407 * test(cache): size the truncation target through its own open handle CodeQL flagged the new integrity test: it measured the database with stat and then reopened the same path to truncate it, which resolves the path twice and is a check-then-use window. Statting the already-open handle closes it and also makes the truncation apply to the file that was actually measured. Refs #1407 * fix(cache): only delete a database SQLite says is structurally corrupt The clean-slate path was reached by negating retryability, and retryability was an allowlist of busy and locked. So every error outside that pair — a full disk, an I/O fault, exhausted descriptors, a permissions problem — answered a transient condition by deleting the repository's session history. That is the trade this work rejected when it declined to wipe on any bootstrap failure, reached through a narrower door. Corruption is now matched on its own terms rather than inferred from the absence of a retry, so an unrecognized error leaves the database alone. Both comments already described the allowlist; the code now agrees with them. Verified in both directions: a file of plain text still reports it is not a database and is still cleared, while a database that merely cannot be opened is left where it is. The corruption downgrade also returned the original restore result, which still described a hit with a path that had just been deleted, and reported a source for a cache that no longer existed. Both now match the state on disk. Save paths are built after the checkpoint rather than before, so the guard reflects the log the checkpoint actually left behind. Refs #1407 * fix(cache): keep the post-hook save symmetric with cleanup's Review follow-ups. The post hook built its save without the project-id path, so an archive that landed only there omitted .git/opencode. That was harmless while the post hook was an incidental fallback, but this work made it the documented reason a declined save is acceptable, so the two call sites should agree. The path derives from a runner-level variable available in the post process exactly as the storage and auth paths already are. Closing the acquired server on the bootstrap-failure path was the last statement before returning an error, so a throw there would have rejected from a function whose whole try/catch exists to return a result instead. It is now best-effort, matching how this change already closes databases, and a test drives a throwing close to prove the contract holds. Also pins the usability probe against the object store rather than only the cache. That source wins on restore and its own integrity checks cannot see past the directory, so the probe is its only real defense there. Refs #1407 * fix(cache): say what is true at the point each message is written Review follow-ups. The cacheable-content note justified inspecting the write-ahead log with a case its own control flow forecloses: a declined checkpoint returns before that check is reached, so the log there is always already merged or empty. The header-page-only session it protects is real but arrives the other way, and a test cited the old wording as its guard. The decline summary claimed the next run may restore an older session, but it is appended, not replaced, so it survived the post-hook retry that usually saves the cache moments later and left the run asserting something untrue. It now states only what holds when it is written. Six action inputs declare a default in YAML that a constant repeats in TypeScript, agreeing by hand. A guard now compares them, so a drift fails loudly instead of quietly changing every run. Refs #1407 * fix(cache): wait for the writer to exit before trusting a checkpoint A successful checkpoint did not mean what the code assumed. shutdown() signalled the OpenCode child and returned without awaiting its exit, so a still-live connection could append to the write-ahead log immediately after the checkpoint reported success: wal before checkpoint 2068272 checkpoint returns {busy:0, log:0, checkpointed:0}, wal -> 0 one late INSERT from an idle live writer wal -> 4152 That is the same root cause as the bug this branch fixes: nothing ever checkpointed *because* shutdown never waited. The SDK exposes no way to await the child. createOpencode returns only {client, server: {url, close}}, and close() calls proc.kill() on a handle it never surfaces -- no pid, no exit event. So shutdown() now polls the server's own port until connections are refused, which happens once the OS reclaims the socket. The pinned server registers no SIGTERM handler, so termination is immediate rather than drained. This is a boundary, not a proof, and both the code and its comment say so: a timeout means the child's fate is unknown, and cleanup warns that the checkpoint which follows may race a writer. With the writer actually gone, the log is empty on every path that reaches transport, so neither transport carries it. Removing it from DB_TRANSPORTABLE_BASENAMES only covered the object store; the Actions cache added it independently in buildSaveCachePaths, and both had to change. That also fixes a regression this branch introduced. Uploads skip missing files and never delete, so once healthy saves stopped producing a local log, opencode.db-wal stopped being refreshed while opencode.db kept being overwritten at the same prefix. A later restore then paired a fresh database with a log from an older generation -- reproduced twice, once surfacing as "database disk image is malformed" (which this branch answers by deleting session history, then re-saving without overwriting the orphan, so it repeats every run) and once far worse: the checkpoint succeeded and silently replayed the stale log, turning 3000 rows back into 2000 with no error at all. The two sources need opposite treatment. An Actions cache entry is one atomic archive, so a legacy pair is consistent and its log may hold committed transactions -- it is checkpointed, never discarded. The object store has no generation marker, and the silent-replay case proves SQLite cannot reliably tell a stale pair from a valid one, so a downloaded log is untrusted and deleted before anything opens the database. That runs unconditionally after the sync, because an object store holding only a sidecar can fall through to a cache miss and leave the file on disk. Discarding an untrusted log can lose that generation's last transactions. That is a real cost, recorded in the code, and still better than silently rolling a session back. Refs #1407 * fix(cache): make the untrusted-log deletion actually unconditional Three follow-ups from review, all of which were true of the code and not of what its comments claimed. The object-store write-ahead log deletion sat inside the try, while its comment said it ran unconditionally after the download. syncSessionsFromStore calls fs.mkdir per key, a real throw site under ENOSPC/EACCES, and object keys list lexicographically so opencode.db-wal arrives before any sessions sidecar. A throw partway through left the log on disk, skipped the deletion, returned a miss, and let the Actions-cache restore extract a database beside it -- rebuilding the exact fresh-db/stale-log pairing the previous commit removed, silent-replay variant included. It now runs in a finally. Hoisting the call up into restoreCache was the other option and would have been wrong: the store-disabled guard returns before the try, so a locally-present log on a persistent self-hosted runner has nothing to do with the object store and must not be deleted. The finally covers every exit from the download attempt and none of the paths where no download happened. isPortOpen settled only on connect or error, so the bound on the quiescence wait lived in an assumption about loopback rather than in the code -- if neither event fired, cleanup awaited it forever. It now has a socket timeout of one poll interval and a settled guard so teardown resolves exactly once. A port that parses to 0 or out of range now reports unconfirmed instead of reporting success without having checked anything. The duplicate OpenCodeServerHandle in features/agent still declared shutdown as returning void after the runtime's began returning a promise. No type error, because a promise-returning function is assignable to a void-returning one, which is exactly why it was worth removing -- the same drift the DB filename lists have guards against. One definition now, imported through the existing sibling re-export, so no new cross-layer import. The throw-path test was checked against the old shape and fails there: the log survived the download failure. Also pins the quiesced:false warning branch, and a legacy Actions-cache pair surviving a round trip with its transactions intact -- the half of the source-specific split that preserves data rather than discarding it. Refs #1407
* docs(solutions): record why repair must precede capture Writes up #1407 and #1519 for docs/solutions/. The durable lesson is an ordering rule -- repair on restore has to happen before capture on save, or a transient failure gets encoded into durable state that the next run then trusts. The most useful section is what didn't work. Five plausible fixes were tried and refuted by evidence: "only persist cleanly-checkpointed state" (nothing had ever produced one), "declining to save breaks the loop" (save keys are unique per run and restore keys are prefixes, so the poisoned entry stays newest), "wipe and save empty state" (destroys history on every transient failure), "the log is provably empty at transport" (a late write from a live writer regrew it after the checkpoint reported success), and "route non-retryable failures into the clean-slate path" (an allowlist of retryable, negated, hands a full disk to the delete branch). Also records the regression the fix itself introduced, since it was caused by the fix and caught only in review: once healthy saves stopped producing a local write-ahead log, an upload that only ever sends what exists stopped refreshing that object while the database beside it kept being overwritten. One reproduction reported malformed; the other silently replayed stale content and turned 3000 rows into 2000. The safety claim about archiving the database alone is stated conditionally rather than absolutely -- SQLite's default wal_autocheckpoint is 1000 pages, and a live writer crossing it mutates the main file with no explicit checkpoint, measured going from 4096 to 860160 bytes. Overlap against the four adjacent docs scored Moderate at highest, so this is a new doc cross-linked in both directions rather than folded into an existing one. Refs #1407 * docs(solutions): add the backlinks the description claimed The PR body said the new doc was cross-linked in both directions. It wasn't -- only forward links existed, and no existing doc referenced it. This repo does follow a backlink convention, so the claim was checkable and wrong. Adds entries from the two highest-overlap neighbours, which are where a reader is most likely to be standing when this becomes relevant: the cache-continuity doc (a save that failed and reported success, against one that succeeded and persisted the wrong thing) and the S3 restore-scope doc (the same restore-is-not-save asymmetry one layer down -- capability there, atomicity here). Also names the remedy in the regression section. These docs are retrieved by section rather than read end to end, so a reader landing there from a `malformed` search could have concluded the hazard was still live; the fix was described in two other sections but never pointed at from the one that describes the problem. Drops related_components. Both values were novel to the corpus and the field is unguarded, so they would have seeded near-synonyms for no retrieval benefit -- tags already carry sqlite and session-cache. * docs(solutions): fix inverted deixis in both backlinks Both backlinks were written from the new doc's vantage point and pasted into the neighbour docs without flipping "there" and "here", so each one described the two documents backwards. The convention is established three lines above the first one, in the same bulleted list: there = the linked doc, here = the doc you are reading. So that file simultaneously claimed "here = a write that failed and reported success" and "here = the save genuinely succeeded", about itself. The cache-continuity doc is the failed-save-reported-success incident; the new doc is the succeeded-save-persisted-poison one. The S3 backlink had the same inversion: that doc is the capability half of the restore-is-not-save asymmetry, and the new one is the atomicity half. Both links resolved the whole time, which is all check-md-links can observe -- the text they resolved to was the part that was wrong. Fitting for a doc about claims that are checkable and unchecked. * docs(solutions): correct the count and heading case "Five plausible fixes" undercounted the seven that follow -- the two it dropped are the zero-byte-log recreation and the restoreCache hoist, both of which were refused with reasons worth keeping. Carried unresolved through two reviews, in the section the doc itself calls the useful one. Also aligns the regression heading with the title case the rest of the file uses.
…it (#1521) * fix(runtime): report an inconclusive port probe as unknown, not as exit Two follow-ups from the #1519 review, both cases where an unknown answer resolved to the optimistic one. The quiescence probe's socket timeout called finish(false), and false means "port closed", which waitForServerQuiescence reads as quiesced and returns immediately. So a connect that neither completed nor was refused -- the one case the timeout exists for -- reported that the OpenCode child had exited. That suppressed the warning telling an operator their checkpoint may have raced a live writer, which is the whole reason the signal exists. Every other unknown in this design resolves the other way: verifyDatabaseUsable returns usable for an unrecognized throw, and isStructuralCorruptionError returns false unless SQLite positively says otherwise. It now returns true, so an inconclusive attempt keeps polling and only the real deadline produces quiesced: false. Testing it needed the socket to be injectable, since a real one either connects or is refused and never reaches the timeout. The probe now takes a connect function, and a fake socket drives the branch directly rather than depending on a black-holed address behaving consistently in CI. Second: a mid-transfer failure in the S3 adapter's download left a partial opencode.db on disk. mainDbRestored stays false, so restore reports a miss and falls through to the Actions cache; if that also misses -- the normal case for a repository leaning on S3 -- the repair block is skipped entirely and a truncated database reaches bootstrap unprobed. The suggested condition for cleaning it up was mainDbRestored === false && failed > 0 at the restore layer. That is unsafe: syncSessionsFromStore increments failed for any key in the batch, so an unrelated sidecar failure satisfies it while opencode.db was never attempted -- and on a self-hosted runner with a pre-existing local database, deleting it destroys live session state the failed download never touched. Fixed at the write site instead. download() unlinks localPath only when the pipeline itself throws, meaning the stream was open and a partial write happened. A rejection before the pipeline is reached never touches the path, so an existing file is provably safe -- pinned by a test. Both fixes were checked against their pre-fix shapes and fail there. Refs #1407 * test(runtime): pin the quiescence invariant at the call site The polarity fix was pinned inside isPortOpen but not where it is read. waitForServerQuiescence called it without an injector, so nothing proved that an inconclusive probe keeps the loop polling -- and inverting the read at the call site to `if (stillOpen) return {quiesced: true}` passed every test in this PR, reintroducing the bug one line from where it was fixed. The optional connect parameter now threads through, defaulted so the production call site is unchanged, and a test drives several poll cycles of nothing-but-timeouts and asserts the only reachable outcome is quiesced: false at the deadline. Verified against the inverted read: it fails there. Also narrows the s3-adapter cleanup comment, which claimed the unlink can only reach a file the pipeline just opened. True for a mid-transfer failure, not for an open failure -- if createWriteStream cannot open the destination at all, pipeline rejects before truncating and the unlink still fires against a file this attempt never wrote to, since unlink needs permission on the directory rather than the file. The comment now separates what is guaranteed from what is merely rare, and the behavior is unchanged. And records what the polarity flip costs: an inconclusive probe now rides out the full 5s budget instead of returning after one poll interval. One server per run and bounded, but whoever next tunes those constants shouldn't have to rediscover it. * test(runtime): decouple the quiescence test from the wall clock Two review follow-ups, both in test code. The call-site test asserted connectCalls > 1 against a 60ms budget with a 10ms poll interval. The do/while checks the deadline only after the first iteration, so a single stalled delay() on a contended runner exits after one probe and fails that assertion. Widened to a 500ms budget with a 5ms interval so a stall cannot end the loop before a second probe. The quiesced: false assertion was never at risk either way -- a stalled loop still cannot produce quiesced: true -- so this only removes a spurious red, and the comment now says so. The s3-adapter test title claimed the pre-existing file was "at a different path", which would be trivially true and worth nothing. It is at the same path the download targets, which is the entire point: a pre-pipeline rejection leaves untouched the exact file the cleanup would otherwise delete. Title now matches what the body actually pins.
Co-authored-by: fro-bot[bot] <109017866+fro-bot[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pending Release: v0.107.1
This PR tracks changes pending release. Released on the next auto-release cycle (Sunday/Wednesday) or via manual dispatch.
Merge this PR to trigger a release. Releases also run automatically on Sunday/Wednesday at 20:00 UTC, or via manual workflow dispatch.
Commits Since Last Release
Auto-generated by the release pipeline. Updated: 2026-09-02 20:30 UTC