Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,16 @@ SIGINT or SIGTERM drains in-flight work and exits cleanly. With
`--watch-idle-timeout` set, the process exits only once **every** root's
daemon has been idle that long.

Only one watch process syncs a session root at a time. It holds an operating
system lock on `<root>/.agy-reader-watch.lock` until it exits. An overlapping
watcher logs once that it is waiting, continues syncing its other roots, and
takes over on a later tick after the owner exits. This prevents different
reader versions from repeatedly invalidating each other's verification
records. The lock works across different cache directories and root symlinks,
and is released even if the owner crashes. Leave the lock file in place.
Watchers from before this locking support must be restarted with the updated
binary to participate.

On Linux, watch mode remembers successful SQLite and legacy protobuf verification checks in memory
and in `$XDG_CACHE_HOME/agy-reader/verification-<root-hash>.json` (normally
`~/.cache/agy-reader/`). A later tick or watcher process can reuse a check while
Expand Down
3 changes: 3 additions & 0 deletions docs/compatibility/agy-1.5.5.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ After those blockers were removed, the coordinator exposed an empty task list
and two timestamp-prefixed skill reads. The empty list adds only `action: list`
metadata. Skill name and description are derived from saved numbered plain
frontmatter after two valid timestamp lines; the original output stays intact.
Timestamp-prefixed image reads preserve their timestamps while validating
media type and PNG-to-JPEG downscaling. The separate `images155` profile suffix
invalidates verification caches from builds predating that image-read rule.
No current skill file or incoming RPC value supplies that metadata. Historical
presentation alternatives remain available. No descriptor or source-only
exception changed; old attestations must be reverified under the new profile.
Expand Down
5 changes: 4 additions & 1 deletion docs/completeness.md
Original file line number Diff line number Diff line change
Expand Up @@ -1316,7 +1316,10 @@ absent, and kill arguments must match every returned ID. The empty native task
list adds only its action metadata. Plain skill frontmatter may follow the two
valid RFC3339 timestamp lines captured in 1.5.5; extraction still uses saved
numbered content and keeps the original output intact. Other timestamp-prefixed
frontmatter forms remain unqualified.
frontmatter forms remain unqualified. Image reads may also follow those two
valid RFC3339 timestamp lines, matching either the stored media MIME type or
the qualified downscaled PNG-to-JPEG pair while preserving the complete
timestamped result text in the native `VIEW_FILE` step.

These mappings need no embedded descriptor changes and no source-only
exemptions. Existing attestations require reverification. All four captured
Expand Down
19 changes: 19 additions & 0 deletions internal/completeness/projection_replay147.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"errors"
"strconv"
"strings"
"time"

"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
Expand Down Expand Up @@ -152,7 +154,24 @@ func projectImageRead147(step, generic, result, payload protoreflect.Message) (p
// JPEG. The captured read of a 3200x2400 PNG kept the file's own type in the
// text ("image/png"), reported the file's full size, and stored a 2000x1500
// JPEG. No other conversion was observed, so no other pair is qualified.
// agy 1.5.5 prepends two valid timestamp lines to tool results.
func imageReadNamed1410(text, mime string) bool {
if strings.HasPrefix(text, "Created At: ") {
lines := strings.SplitN(text, "\n", 3)
if len(lines) != 3 {
return false
}
for i, prefix := range []string{"Created At: ", "Completed At: "} {
value, ok := strings.CutPrefix(lines[i], prefix)
if !ok {
return false
}
if _, err := time.Parse(time.RFC3339, value); err != nil {
return false
}
}
text = lines[2]
}
const prefix = "Showing binary file of type "
return text == prefix+mime || (mime == "image/jpeg" && text == prefix+"image/png")
}
43 changes: 43 additions & 0 deletions internal/completeness/projection_tools155_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,46 @@ func TestTwoChildren155UnobservedStates(t *testing.T) {
}
}
}

func TestTimestampedImageRead155(t *testing.T) {
prefix := "Created At: 2026-11-10T14:56:09-08:00\\nCompleted At: 2026-11-10T14:56:11-08:00\\n"
for _, mime := range []string{"image/jpeg", "image/png", "image/webp"} {
raw, shown := imageReadPair147(mime)
raw = replace1216(t, raw, `"result":"Showing binary file of type `+mime+`"`, `"result":"`+prefix+`Showing binary file of type `+mime+`"`)
shown = replace1216(t, shown, `"content":"Showing binary file of type `+mime+`"`, `"content":"`+prefix+`Showing binary file of type `+mime+`"`)
s := snapshot1216(t, raw)
require1216(t, s, raw, shown, Verified)
offline, err := s.OfflineJSON()
if err != nil {
t.Fatal(err)
}
require1216(t, s, raw, string(offline), Verified)
require1216(t, s, strings.Replace(raw, "2026-11-10T14:56:09-08:00", "2026-11-10T14:56:10-08:00", 1), shown, Incomplete)
require1216(t, s, raw, strings.Replace(shown, "2026-11-10T14:56:09-08:00", "2026-11-10T14:56:10-08:00", 1), Incomplete)
}

// Downscaled PNG stored as JPEG with timestamps.
convRaw, convShown := convertedImage1410(t)
convRaw = replace1216(t, convRaw, `"result":"Showing binary file of type image/png"`, `"result":"`+prefix+`Showing binary file of type image/png"`)
convShown = replace1216(t, convShown, `"content":"Showing binary file of type image/png"`, `"content":"`+prefix+`Showing binary file of type image/png"`)
cs := snapshot1216(t, convRaw)
require1216(t, cs, convRaw, convShown, Verified)
convOffline, err := cs.OfflineJSON()
if err != nil {
t.Fatal(err)
}
require1216(t, cs, convRaw, string(convOffline), Verified)

// Malformed timestamp lines fail-closed as Unknown.
raw, shown := imageReadPair147("image/png")
for _, badPrefix := range []string{
"Created At: not-a-date\\nCompleted At: 2026-11-10T14:56:11-08:00\\n",
"Created At: 2026-11-10T14:56:09-08:00\\nCompleted At: not-a-date\\n",
"Created At: 2026-11-10T14:56:09-08:00\\nOther Line: 2026-11-10T14:56:11-08:00\\n",
"Created At: 2026-11-10T14:56:09-08:00\\n",
} {
badRaw := replace1216(t, raw, `"result":"Showing binary file of type image/png"`, `"result":"`+badPrefix+`Showing binary file of type image/png"`)
badShown := replace1216(t, shown, `"content":"Showing binary file of type image/png"`, `"content":"`+badPrefix+`Showing binary file of type image/png"`)
require1216(t, snapshot1216(t, badRaw), badRaw, badShown, Unknown)
}
}
4 changes: 3 additions & 1 deletion internal/completeness/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ import (
// completed canceled non-background commands with a retained bypass argument.
// tools155 qualifies captured swarm calls, share/branch workspace invocations,
// two-child management, empty task lists and timestamp-prefixed skill reads.
const Profile = "sqlite-v1-format0-agy-1.3.4-5b93565c5f04-planner50-permission2-prompt23-tools143-permission1-tools144-skill1-history2-ide225-options145-compaction146-skill146-finish147-replay147-failedread146-subagents148-media149-tools149-sendmessage149-files1411-media1410-parentmsgs1410-paths1410-denied1412-cwd1412-unrun1412-config150-skill150-frontmatter150-bgcancel150-readlines150-config152-tools153-tools154-tools155"
// images155 separately qualifies timestamp-prefixed image reads so builds
// predating that rule cannot reuse their verification checks.
const Profile = "sqlite-v1-format0-agy-1.3.4-5b93565c5f04-planner50-permission2-prompt23-tools143-permission1-tools144-skill1-history2-ide225-options145-compaction146-skill146-finish147-replay147-failedread146-subagents148-media149-tools149-sendmessage149-files1411-media1410-parentmsgs1410-paths1410-denied1412-cwd1412-unrun1412-config150-skill150-frontmatter150-bgcancel150-readlines150-config152-tools153-tools154-tools155-images155"
const maxBytes = 64 << 20
const maxDepth = 100

Expand Down
7 changes: 6 additions & 1 deletion internal/vcache/vcache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func TestCacheInvalidation(t *testing.T) {
}

func TestCacheDiskFailuresAndCompatibility(t *testing.T) {
for _, action := range []string{"corrupt", "version", "profile", "boot", "root", "unwritable"} {
for _, action := range []string{"corrupt", "version", "profile", "before-timestamped-images", "boot", "root", "unwritable"} {
t.Run(action, func(t *testing.T) {
dir, s, c := dummyFixture(t)
c.IsStale(s, alwaysFresh)
Expand Down Expand Up @@ -160,6 +160,11 @@ func TestCacheDiskFailuresAndCompatibility(t *testing.T) {
disk.Version++
case "profile":
disk.Profile = "other-profile"
case "before-timestamped-images":
// The image projection added after the first agy 1.5.5
// qualification must not share persisted checks with it.
const previous = "sqlite-v1-format0-agy-1.3.4-5b93565c5f04-planner50-permission2-prompt23-tools143-permission1-tools144-skill1-history2-ide225-options145-compaction146-skill146-finish147-replay147-failedread146-subagents148-media149-tools149-sendmessage149-files1411-media1410-parentmsgs1410-paths1410-denied1412-cwd1412-unrun1412-config150-skill150-frontmatter150-bgcancel150-readlines150-config152-tools153-tools154-tools155"
disk.Profile = previous + "\nlegacy-aes256gcm-v1-agy-1.4.4-337dcde66844/" + previous
case "boot":
disk.Boot = "previous-boot"
case "root":
Expand Down
29 changes: 29 additions & 0 deletions internal/watcher/lock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package watcher

import (
"os"
"path/filepath"
)

// Keep this file in the store, rather than a user cache or runtime directory:
// watchers with different HOME/XDG settings must contend on the same inode.
// Never unlink it, even on exit, since a replacement inode would let a third
// watcher acquire a second lock while another process still holds the first.
const watchLockName = ".agy-reader-watch.lock"

// acquireWatchLock returns nil, nil when another process owns the store.
// Ownership lasts until the returned file is closed or the process exits.
// A per-tick lock would still let different verification profiles repeatedly
// replace each other's sidecars between ticks.
func acquireWatchLock(root string) (*os.File, error) {
f, err := os.OpenFile(filepath.Join(root, watchLockName), os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
locked, err := tryWatchLock(f)
if err != nil || !locked {
_ = f.Close()
return nil, err
}
return f, nil
}
12 changes: 12 additions & 0 deletions internal/watcher/lock_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !windows

package watcher

import (
"errors"
"os"
)

func tryWatchLock(_ *os.File) (bool, error) {
return false, errors.New("watcher ownership locks are unsupported on this platform")
}
Loading
Loading