Skip to content

Commit 5bc9617

Browse files
JohnRivmjacobs
andauthored
fix(devin): treat session timestamps as epoch seconds (#1322)
The Devin CLI stores created_at and last_activity_at as Unix epoch seconds, but the parser treated them as milliseconds. This produced dates in 1970 that were discarded as invalid, leaving started_at and ended_at NULL for every Devin session. Replace devinUnixMilli (time.UnixMilli) with devinUnixSec (time.Unix) and correct the FileMtime multiplier from 1e6 to 1e9. The same fix applies to message_nodes.created_at, which is also epoch seconds. Rename variables and test helpers from the misleading MS/Millis suffixes to match the Devin DB column names. Update all test data from 13-digit millisecond values to 10-digit second values. Add timestamp-unit evidence to the Devin provenance entry. I tested this and confirmed it fixes #1199 --------- Co-authored-by: Matthew Jacobs <mjacobs@apache.org>
1 parent 7ddf624 commit 5bc9617

11 files changed

Lines changed: 540 additions & 186 deletions

docs/internal/session-format-sources.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -820,7 +820,14 @@ Grok section and remove the explicit registry exception in the coverage test.
820820
## Devin CLI (`devin`)
821821

822822
- **Format:** `cli/sessions.db` for session metadata plus transcript JSON
823-
artifacts.
823+
artifacts. The `sessions.created_at`, `sessions.last_activity_at`, and
824+
`message_nodes.created_at` columns are Unix epoch seconds (not milliseconds).
825+
Verified against a live Devin CLI database 2026-07-31, and reverified
826+
independently against CLI 3000.3.22 the same day. Because the unit is observed
827+
rather than documented, the parser rejects values outside the
828+
nanosecond-representable epoch-second range instead of converting them, so a
829+
future unit change surfaces as missing timestamps rather than as a silently
830+
overflowed far-future mtime that would wedge resync.
824831
- **Evidence:** `no-public-source`.
825832
- **Upstream:** Cognition's first-party
826833
[Devin documentation](https://docs.devin.ai/) and public repositories were

internal/db/db.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,18 @@ const projectIdentityRemoteScrubCompletedKey = "project_identity_remote_scrub_v1
350350
// project identity snapshots that older mapping behavior could persist with
351351
// the mapped target label before incremental ingestion is allowed to reuse
352352
// them.)
353-
const dataVersion = 77
353+
//
354+
// (78: Devin timestamp reparse. The Devin parser read sessions.created_at,
355+
// sessions.last_activity_at, and message_nodes.created_at as epoch
356+
// milliseconds when Devin writes epoch seconds, so every existing Devin row
357+
// carries 1970-era started_at/ended_at and message timestamps that were
358+
// discarded as invalid. Existing rows need re-parsing to backfill real
359+
// timestamps. A fingerprint change alone cannot cover this: for a message-node
360+
// fallback session whose sessions row has no usable created_at or
361+
// last_activity_at, the Devin fingerprint hashes only raw epoch integers and
362+
// zero-time metadata, so it is byte-identical before and after the fix and
363+
// incremental sync would skip the correction.)
364+
const dataVersion = 78
354365

355366
const tokenCoverageRepairStatsKey = "token_coverage_repair_v1"
356367

internal/db/db_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,9 +1008,10 @@ func TestMigration_ToolResultEventsTable(t *testing.T) {
10081008
"expected tool_result_events table after reopen")
10091009
}
10101010

1011-
func TestCurrentDataVersionVibeCachedTokensAndProjectIdentityReparse(t *testing.T) {
1012-
assert.Equal(t, 77, CurrentDataVersion(),
1013-
"version 77 reparses Vibe usage and project identity snapshots")
1011+
func TestCurrentDataVersionDevinEpochSecondsReparse(t *testing.T) {
1012+
assert.Equal(t, 78, CurrentDataVersion(),
1013+
"version 78 reparses Devin sessions whose timestamps were read as "+
1014+
"milliseconds and stored as 1970 dates")
10141015
}
10151016

10161017
func TestInsertMessages_PreservesToolResultEvents(t *testing.T) {

internal/parser/devin.go

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"errors"
88
"fmt"
9+
"math"
910
"os"
1011
"path/filepath"
1112
"strings"
@@ -86,27 +87,27 @@ func ForEachDevinSessionMeta(
8687

8788
for rows.Next() {
8889
var meta DevinSessionMeta
89-
var createdAtMS int64
90-
var lastActivityMS sql.NullInt64
91-
var updatedAtMS int64
90+
var createdAt int64
91+
var lastActivity sql.NullInt64
92+
var updatedAt int64
9293
if err := rows.Scan(
9394
&meta.RawSessionID,
9495
&meta.Title,
9596
&meta.CWD,
9697
&meta.Model,
97-
&createdAtMS,
98-
&lastActivityMS,
99-
&updatedAtMS,
98+
&createdAt,
99+
&lastActivity,
100+
&updatedAt,
100101
); err != nil {
101102
return fmt.Errorf("scanning devin session meta: %w", err)
102103
}
103104
meta.VirtualPath = VirtualSourcePath(dbPath, meta.RawSessionID)
104-
meta.CreatedAt = devinUnixMilli(createdAtMS)
105-
if lastActivityMS.Valid {
106-
meta.LastActivity = devinUnixMilli(lastActivityMS.Int64)
105+
meta.CreatedAt = devinUnixSec(createdAt)
106+
if lastActivity.Valid {
107+
meta.LastActivity = devinUnixSec(lastActivity.Int64)
107108
}
108-
meta.UpdatedAt = devinUnixMilli(updatedAtMS)
109-
meta.FileMtime = updatedAtMS * 1_000_000
109+
meta.UpdatedAt = devinUnixSec(updatedAt)
110+
meta.FileMtime = devinFileMtimeNS(updatedAt)
110111
observeStreamingDiscoveryBuffer(ctx, 1)
111112
if err := yield(meta); err != nil {
112113
return err
@@ -137,9 +138,9 @@ func getDevinSessionMeta(
137138
defer db.Close()
138139

139140
var meta DevinSessionMeta
140-
var createdAtMS int64
141-
var lastActivityMS sql.NullInt64
142-
var updatedAtMS int64
141+
var createdAt int64
142+
var lastActivity sql.NullInt64
143+
var updatedAt int64
143144
err = db.QueryRow(`
144145
SELECT id,
145146
COALESCE(title, ''),
@@ -156,9 +157,9 @@ func getDevinSessionMeta(
156157
&meta.Title,
157158
&meta.CWD,
158159
&meta.Model,
159-
&createdAtMS,
160-
&lastActivityMS,
161-
&updatedAtMS,
160+
&createdAt,
161+
&lastActivity,
162+
&updatedAt,
162163
)
163164
if err != nil {
164165
if err == sql.ErrNoRows {
@@ -168,20 +169,46 @@ func getDevinSessionMeta(
168169
}
169170

170171
meta.VirtualPath = VirtualSourcePath(dbPath, meta.RawSessionID)
171-
meta.CreatedAt = devinUnixMilli(createdAtMS)
172-
if lastActivityMS.Valid {
173-
meta.LastActivity = devinUnixMilli(lastActivityMS.Int64)
172+
meta.CreatedAt = devinUnixSec(createdAt)
173+
if lastActivity.Valid {
174+
meta.LastActivity = devinUnixSec(lastActivity.Int64)
174175
}
175-
meta.UpdatedAt = devinUnixMilli(updatedAtMS)
176-
meta.FileMtime = updatedAtMS * 1_000_000
176+
meta.UpdatedAt = devinUnixSec(updatedAt)
177+
meta.FileMtime = devinFileMtimeNS(updatedAt)
177178
return &meta, nil
178179
}
179180

180-
func devinUnixMilli(ms int64) time.Time {
181-
if ms <= 0 {
181+
// devinMaxEpochSec is the largest epoch-second value whose nanosecond form
182+
// still fits in int64 (year 2262). Anything larger is not a plausible Devin
183+
// timestamp and signals a unit mismatch -- a 13-digit millisecond value, for
184+
// example. Rejecting it keeps a bad column from silently overflowing into a
185+
// far-future mtime, which would wedge change detection: devinApplyFileInfoTimes
186+
// only ever raises Mtime, so a wrapped value can never be superseded and the
187+
// session would stop resyncing.
188+
const devinMaxEpochSec = math.MaxInt64 / int64(time.Second)
189+
190+
// devinPlausibleEpochSec reports whether sec is a usable Devin epoch-second
191+
// timestamp. Devin stores created_at, last_activity_at, and
192+
// message_nodes.created_at as Unix seconds.
193+
func devinPlausibleEpochSec(sec int64) bool {
194+
return sec > 0 && sec <= devinMaxEpochSec
195+
}
196+
197+
func devinUnixSec(sec int64) time.Time {
198+
if !devinPlausibleEpochSec(sec) {
182199
return time.Time{}
183200
}
184-
return time.UnixMilli(ms).UTC()
201+
return time.Unix(sec, 0).UTC()
202+
}
203+
204+
// devinFileMtimeNS converts a Devin epoch-second timestamp to the nanosecond
205+
// mtime the sync layer compares against. Implausible values yield 0, which
206+
// callers already treat as "no synthetic mtime available".
207+
func devinFileMtimeNS(sec int64) int64 {
208+
if !devinPlausibleEpochSec(sec) {
209+
return 0
210+
}
211+
return sec * int64(time.Second)
185212
}
186213

187214
type devinTranscriptError struct {
@@ -406,7 +433,7 @@ type devinMessageNodeRow struct {
406433
NodeID int64
407434
ParentNodeID sql.NullInt64
408435
ChatMessage string
409-
CreatedAtMS int64
436+
CreatedAt int64
410437
}
411438

412439
func listDevinMessageNodes(dbPath, rawSessionID string) ([]devinMessageNodeRow, error) {
@@ -434,7 +461,7 @@ func listDevinMessageNodes(dbPath, rawSessionID string) ([]devinMessageNodeRow,
434461
var nodes []devinMessageNodeRow
435462
for rows.Next() {
436463
var row devinMessageNodeRow
437-
if err := rows.Scan(&row.RowID, &row.NodeID, &row.ParentNodeID, &row.ChatMessage, &row.CreatedAtMS); err != nil {
464+
if err := rows.Scan(&row.RowID, &row.NodeID, &row.ParentNodeID, &row.ChatMessage, &row.CreatedAt); err != nil {
438465
return nil, err
439466
}
440467
nodes = append(nodes, row)
@@ -489,7 +516,7 @@ func parseDevinDBMessageNode(
489516
Role: role,
490517
Content: content,
491518
ThinkingText: thinking,
492-
Timestamp: devinUnixMilli(row.CreatedAtMS),
519+
Timestamp: devinUnixSec(row.CreatedAt),
493520
HasThinking: hasThinking,
494521
HasToolUse: hasToolUse || len(toolCalls) > 0,
495522
IsSystem: isSystem,

internal/parser/devin_provider.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -473,13 +473,13 @@ func devinAppendMessageNodesFingerprint(h io.Writer, dbPath, rawSessionID string
473473
if err != nil {
474474
return err
475475
}
476-
var maxCreatedAtMS int64
476+
var maxCreatedAt int64
477477
for _, node := range nodes {
478-
if node.CreatedAtMS > maxCreatedAtMS {
479-
maxCreatedAtMS = node.CreatedAtMS
478+
if node.CreatedAt > maxCreatedAt {
479+
maxCreatedAt = node.CreatedAt
480480
}
481481
}
482-
if _, err := fmt.Fprintf(h, "message_nodes\x00count\x00%d\x00max_created\x00%d\x00", len(nodes), maxCreatedAtMS); err != nil {
482+
if _, err := fmt.Fprintf(h, "message_nodes\x00count\x00%d\x00max_created\x00%d\x00", len(nodes), maxCreatedAt); err != nil {
483483
return err
484484
}
485485
for _, node := range nodes {
@@ -490,7 +490,7 @@ func devinAppendMessageNodesFingerprint(h io.Writer, dbPath, rawSessionID string
490490
node.NodeID,
491491
node.ParentNodeID.Valid,
492492
node.ParentNodeID.Int64,
493-
node.CreatedAtMS,
493+
node.CreatedAt,
494494
len(node.ChatMessage),
495495
); err != nil {
496496
return err

0 commit comments

Comments
 (0)