From 68cb60b4fbe1f6fda19f5d371a4920bf2403f95e Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Thu, 24 Sep 2026 14:45:55 +0300 Subject: [PATCH 1/2] fix(files): keep the pod's own S3 uploads out of the sync guard (CLEAN-115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime's fs.watch pusher uploads every local change (usage.json on each LLM call, memory, sessions), so the CLEAN-50 guard listed those objects as "edited in S3" on every Sync. Ranch now tags each write it makes with object metadata origin=ranch; the guard keeps only tagged objects among the newer-than-baseline candidates (one HeadObject each). Admin: the header pill no longer claims "Agent copy is newer" — it says the agent is running, the tooltip explains the watcher, and the 409 dialog says the files were edited from Ranch. Spec 008 gets an R8 amendment on the watcher and the residual runtime-side risk. Co-Authored-By: Claude Fable 5.1 --- .../file/components/agentFile/Provider.vue | 21 ++-- .../api/data/repositories/api/schemas.gen.ts | 2 +- .../api/data/repositories/api/types.gen.ts | 4 +- .../file/data/file.gateway.origin.spec.ts | 103 ++++++++++++++++++ .../slices/agent/file/data/file.gateway.ts | 34 ++++++ .../slices/agent/file/domain/file.gateway.ts | 8 ++ .../file/domain/syncGuard.service.spec.ts | 58 +++++++++- .../agent/file/domain/syncGuard.service.ts | 14 ++- .../slices/agent/file/dtos/syncFiles.dto.ts | 6 +- api/src/slices/agent/file/file.controller.ts | 5 +- api/src/slices/agent/file/file.tool.ts | 6 +- .../api/data/repositories/api/schemas.gen.ts | 2 +- .../api/data/repositories/api/types.gen.ts | 4 +- specs/008-agent-files-sync-safety/research.md | 8 ++ .../quickstart.md | 2 +- 15 files changed, 252 insertions(+), 25 deletions(-) create mode 100644 api/src/slices/agent/file/data/file.gateway.origin.spec.ts diff --git a/admin/slices/agent/file/components/agentFile/Provider.vue b/admin/slices/agent/file/components/agentFile/Provider.vue index 133ce830..678ca779 100644 --- a/admin/slices/agent/file/components/agentFile/Provider.vue +++ b/admin/slices/agent/file/components/agentFile/Provider.vue @@ -187,9 +187,12 @@ const confirmStore = useConfirmStore(); const route = useRoute(); const router = useRouter(); -// ── Agent copy hint (CLEAN-50) ───────────────────────────────────── -// While the agent is Running, this tab shows the S3 copy but the pod works on -// its own. Read from the agent store's record (docs/state.md). +// ── Agent copy hint (CLEAN-50, reworded in CLEAN-115) ────────────── +// While the agent is Running, this tab shows the S3 copy. The pod works on +// its own copy and its watcher pushes whatever it changes to S3 within about +// 30 s, so the two rarely differ for long — the pill must not claim the pod +// holds something newer. Sync forces a full push and catches what the +// watcher missed. Read from the agent store's record (docs/state.md). const agent = computed(() => agentStore.byId(props.id)); const agentRunning = computed(() => agent.value?.status === 'running'); @@ -204,14 +207,14 @@ function formatClock(iso: string | null): string | null { const copyPill = computed(() => { if (!agentRunning.value) return null; const pulled = formatClock(agent.value?.lastPullAt ?? null); - return pulled ? `Agent copy is newer (${pulled})` : 'Agent works on its own copy'; + return pulled ? `Agent running since ${pulled}` : 'Agent is running'; }); const copyPillTitle = computed(() => { const pulled = agent.value?.lastPullAt ? new Date(agent.value.lastPullAt).toLocaleString() : null; const synced = agent.value?.lastSyncAt ? new Date(agent.value.lastSyncAt).toLocaleString() : null; const parts = [ - 'This tab shows the stored (S3) copy. The running agent works on its own copy and may hold newer content — Sync brings it in.', + 'This tab shows the stored (S3) copy. The running agent works on its own copy and pushes the files it changes to S3 within about 30 seconds. Sync forces a full push and picks up anything the watcher missed.', ]; if (pulled) parts.push(`Agent took its copy ${pulled}.`); if (synced) parts.push(`Last sync ${synced}.`); @@ -406,8 +409,8 @@ async function onSync() { title: 'Overwrite newer files in S3?', description: `${atRisk.length} file${atRisk.length === 1 ? ' was' : 's were'} ` + - 'edited in S3 after the running agent last took its copy: ' + - `${describeAtRisk(atRisk)}. ` + + 'edited from Ranch (console, chat tools or import) after the ' + + `running agent last took its copy: ${describeAtRisk(atRisk)}. ` + 'If the agent also changed them, Sync will overwrite the S3 ' + 'version with the agent’s copy. Files changed only in S3 are safe.', confirmLabel: 'Sync anyway', @@ -621,12 +624,12 @@ watch( diff --git a/admin/slices/setup/api/data/repositories/api/schemas.gen.ts b/admin/slices/setup/api/data/repositories/api/schemas.gen.ts index ce7b05bd..7f9b603f 100644 --- a/admin/slices/setup/api/data/repositories/api/schemas.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/schemas.gen.ts @@ -1233,7 +1233,7 @@ export const SyncConflictDtoSchema = { }, atRisk: { description: - "S3 files modified after the pod last pulled/pushed. A sync MAY overwrite or delete them if the pod also changed them locally.", + "S3 files written from Ranch (console, tools, import) after the pod last pulled/pushed. A sync MAY overwrite or delete them if the pod also changed them locally. Objects the pod uploaded itself are excluded (no Ranch origin tag).", type: "array", items: { $ref: "#/components/schemas/AtRiskFileDto", diff --git a/admin/slices/setup/api/data/repositories/api/types.gen.ts b/admin/slices/setup/api/data/repositories/api/types.gen.ts index 330409e8..7ad2ea3f 100644 --- a/admin/slices/setup/api/data/repositories/api/types.gen.ts +++ b/admin/slices/setup/api/data/repositories/api/types.gen.ts @@ -553,7 +553,7 @@ export type SyncConflictDto = { */ requiresConfirmation: boolean; /** - * S3 files modified after the pod last pulled/pushed. A sync MAY overwrite or delete them if the pod also changed them locally. + * S3 files written from Ranch (console, tools, import) after the pod last pulled/pushed. A sync MAY overwrite or delete them if the pod also changed them locally. Objects the pod uploaded itself are excluded (no Ranch origin tag). */ atRisk: Array; /** @@ -3103,7 +3103,7 @@ export type FileControllerSyncData = { export type FileControllerSyncErrors = { /** - * S3 files newer than the pod’s working copy were found and confirm was not set. No sync was performed. + * S3 files edited from Ranch after the pod’s last pull/push were found and confirm was not set. No sync was performed. Files the pod uploaded itself are not counted. */ 409: SyncConflictDto; }; diff --git a/api/src/slices/agent/file/data/file.gateway.origin.spec.ts b/api/src/slices/agent/file/data/file.gateway.origin.spec.ts new file mode 100644 index 00000000..fcfd3dc2 --- /dev/null +++ b/api/src/slices/agent/file/data/file.gateway.origin.spec.ts @@ -0,0 +1,103 @@ +import { + HeadObjectCommand, + PutObjectCommand, + S3ServiceException, +} from '@aws-sdk/client-s3'; +import { ISettingGateway } from '../../../setting/domain/setting.gateway'; +import { RANCH_ORIGIN_METADATA, S3FileGateway } from './file.gateway'; + +// Every S3 write made through Ranch carries an origin tag so the sync guard +// (CLEAN-115) can tell a console edit from the pod's own watcher upload. The +// SDK client is replaced by a recording `send`; command inputs stay real. +const send = jest.fn(); + +jest.mock('@aws-sdk/client-s3', () => { + const actual = jest.requireActual('@aws-sdk/client-s3'); + return { + ...actual, + S3Client: jest.fn().mockImplementation(() => ({ send })), + }; +}); + +const settings = { + findByKey: async (_group: string, name: string) => ({ + value: name === 's3_bucket' ? 'test-bucket' : '', + }), +} as unknown as ISettingGateway; + +function notFound(): S3ServiceException { + return new S3ServiceException({ + name: 'NotFound', + $fault: 'client', + $metadata: { httpStatusCode: 404 }, + }); +} + +describe('S3FileGateway origin tag', () => { + let gateway: S3FileGateway; + + beforeEach(() => { + send.mockReset(); + gateway = new S3FileGateway(settings); + }); + + it('tags text saves with the Ranch origin', async () => { + send.mockResolvedValue({}); + await gateway.saveRaw('agent-1', 'notes.md', 'hello'); + const put = send.mock.calls[0][0] as PutObjectCommand; + expect(put).toBeInstanceOf(PutObjectCommand); + expect(put.input.Key).toBe('agents/agent-1/notes.md'); + expect(put.input.Metadata).toEqual(RANCH_ORIGIN_METADATA); + }); + + it('tags raw byte writes (import) with the Ranch origin', async () => { + send.mockResolvedValue({}); + await gateway.putObjectRaw('agent-1', 'img/logo.png', Buffer.from('x')); + const put = send.mock.calls[0][0] as PutObjectCommand; + expect(put.input.Metadata).toEqual(RANCH_ORIGIN_METADATA); + }); + + it('tags skill files written from the template bundle', async () => { + send.mockResolvedValue({ Contents: [] }); + await gateway.syncSkills('agent-1', [ + { name: 'greet', body: '# greet', files: [{ path: 'x.md', content: 'x' }] }, + ]); + const puts = send.mock.calls + .map((c) => c[0]) + .filter( + (c): c is PutObjectCommand => + c instanceof PutObjectCommand && + !String(c.input.Key).endsWith(S3FileGateway.MANAGED_MARKER), + ); + expect(puts.length).toBe(2); + for (const put of puts) { + expect(put.input.Metadata).toEqual(RANCH_ORIGIN_METADATA); + } + }); + + describe('wasWrittenByRanch', () => { + it('is true for an object carrying the tag', async () => { + send.mockResolvedValue({ Metadata: { ...RANCH_ORIGIN_METADATA } }); + await expect( + gateway.wasWrittenByRanch('agent-1', 'SOUL.md'), + ).resolves.toBe(true); + const head = send.mock.calls[0][0] as HeadObjectCommand; + expect(head).toBeInstanceOf(HeadObjectCommand); + expect(head.input.Key).toBe('agents/agent-1/SOUL.md'); + }); + + it('is false for an untagged object (pushed by the pod)', async () => { + send.mockResolvedValue({ Metadata: {} }); + await expect( + gateway.wasWrittenByRanch('agent-1', 'data/usage.json'), + ).resolves.toBe(false); + }); + + it('is false when the object is gone', async () => { + send.mockRejectedValue(notFound()); + await expect( + gateway.wasWrittenByRanch('agent-1', 'gone.md'), + ).resolves.toBe(false); + }); + }); +}); diff --git a/api/src/slices/agent/file/data/file.gateway.ts b/api/src/slices/agent/file/data/file.gateway.ts index ae3111cd..017ca55b 100644 --- a/api/src/slices/agent/file/data/file.gateway.ts +++ b/api/src/slices/agent/file/data/file.gateway.ts @@ -139,6 +139,18 @@ const AGENT_OWNED_ROOT_FILES = new Set([ 'MEMORY.md', ]); +// Object metadata stamped on every write Ranch makes into an agent prefix +// (CLEAN-115). The pod's S3 watcher uploads its own changes with no metadata +// (usage.json on every LLM call, memory, sessions), so the sync guard reads +// this tag to keep only console/tool/import edits in the at-risk list. +// Template copies (seed/resync) stay untagged on purpose: they land before +// the pod's boot pull, so they are never newer than the guard's baseline, +// and CopyObject would need MetadataDirective=REPLACE plus a re-declared +// content type to carry the tag. +export const RANCH_ORIGIN_METADATA: Readonly> = { + origin: 'ranch', +}; + @Injectable() export class S3FileGateway extends IFileGateway { // Sentinel file written into every template-managed skill dir. syncSkills @@ -461,6 +473,7 @@ export class S3FileGateway extends IFileGateway { Key: key, Body: content, ContentType: this.contentType(path), + Metadata: RANCH_ORIGIN_METADATA, }), ); } @@ -739,6 +752,7 @@ export class S3FileGateway extends IFileGateway { Key: base + 'SKILL.md', Body: skill.body, ContentType: this.contentType('SKILL.md'), + Metadata: RANCH_ORIGIN_METADATA, }), ); written++; @@ -750,6 +764,7 @@ export class S3FileGateway extends IFileGateway { Key: base + file.path, Body: file.content, ContentType: this.contentType(file.path), + Metadata: RANCH_ORIGIN_METADATA, }), ); written++; @@ -916,6 +931,7 @@ export class S3FileGateway extends IFileGateway { Key: this.prefix(agentId) + path, Body: bytes, ContentType: contentType ?? this.contentType(path), + Metadata: RANCH_ORIGIN_METADATA, }), ); } @@ -1053,6 +1069,24 @@ export class S3FileGateway extends IFileGateway { } } + async wasWrittenByRanch(agentId: string, path: string): Promise { + this.assertSafePath(path); + const { client, bucket } = await this.connect(); + try { + const head = await client.send( + new HeadObjectCommand({ + Bucket: bucket, + Key: this.prefix(agentId) + path, + }), + ); + // S3 lower-cases user metadata keys on the way back. + return head.Metadata?.origin === RANCH_ORIGIN_METADATA.origin; + } catch (err) { + if (this.isNotFound(err)) return false; + throw err; + } + } + private proposalKey(proposalId: string): string { return `${PROPOSAL_PREFIX}${proposalId}/content`; } diff --git a/api/src/slices/agent/file/domain/file.gateway.ts b/api/src/slices/agent/file/domain/file.gateway.ts index 1b3e9773..e8e24f40 100644 --- a/api/src/slices/agent/file/domain/file.gateway.ts +++ b/api/src/slices/agent/file/domain/file.gateway.ts @@ -73,6 +73,14 @@ export abstract class IFileGateway { // ── Change proposals (CLEAN-112) ────────────────────────────── /** ETag of the stored object (quotes stripped), or null when absent. */ abstract headEtag(agentId: string, path: string): Promise; + /** + * Whether the stored object was last written through Ranch (console save, + * agent tool, import, skill sync) rather than uploaded by the pod's own + * S3 watcher (CLEAN-115). Ranch tags every write it makes; an object + * without the tag is the pod's copy and can never be at risk from a Sync. + * False when the object is absent. + */ + abstract wasWrittenByRanch(agentId: string, path: string): Promise; /** Proposed content lives outside every agent prefix until applied. */ abstract putProposalContent( proposalId: string, diff --git a/api/src/slices/agent/file/domain/syncGuard.service.spec.ts b/api/src/slices/agent/file/domain/syncGuard.service.spec.ts index 3ed97c58..5b612f0a 100644 --- a/api/src/slices/agent/file/domain/syncGuard.service.spec.ts +++ b/api/src/slices/agent/file/domain/syncGuard.service.spec.ts @@ -5,10 +5,22 @@ import { IFileNode } from './file.types'; const T0 = new Date('2026-08-31T10:00:00Z').getTime(); const at = (offsetSec: number) => new Date(T0 + offsetSec * 1000); -function fileStub(nodes: IFileNode[]): IFileGateway { +/** + * `ranchPaths` — objects carrying the Ranch origin tag. Defaults to every + * node, i.e. "all of these were written from the console"; pass a subset to + * model files the pod's own watcher uploaded (CLEAN-115). + */ +function fileStub( + nodes: IFileNode[], + ranchPaths: string[] = nodes.map((n) => n.path), +): IFileGateway & { wasWrittenByRanch: jest.Mock } { + const tagged = new Set(ranchPaths); return { list: async (): Promise => nodes, - } as unknown as IFileGateway; + wasWrittenByRanch: jest.fn( + async (_agentId: string, path: string) => tagged.has(path), + ), + } as unknown as IFileGateway & { wasWrittenByRanch: jest.Mock }; } const node = (path: string, updatedAt: Date): IFileNode => ({ @@ -86,5 +98,47 @@ describe('SyncGuardService', () => { const result = await guard.assess('agent-1', at(0), null); expect(result.atRisk).toEqual([]); }); + + // CLEAN-115: the pod's fs.watch pusher uploads its own changes (usage.json + // on every LLM call, memory, sessions). Those objects are newer than the + // baseline but were never edited from Ranch — the pod already holds them. + it('ignores newer files the pod pushed itself (no Ranch origin tag)', async () => { + const files = fileStub( + [ + node('data/usage.json', at(600)), // watcher upload → pod's own copy + node('SOUL.md', at(500)), // console save → at risk + ], + ['SOUL.md'], + ); + const guard = new SyncGuardService(files); + const result = await guard.assess('agent-1', at(-1000), at(100)); + expect(result.atRisk.map((n) => n.path)).toEqual(['SOUL.md']); + }); + + it('looks up the origin only for files newer than the baseline', async () => { + const files = fileStub([ + node('data/usage.json', at(600)), + node('notes.md', at(-500)), + ]); + const guard = new SyncGuardService(files); + await guard.assess('agent-1', at(-1000), at(100)); + expect(files.wasWrittenByRanch).toHaveBeenCalledTimes(1); + expect(files.wasWrittenByRanch).toHaveBeenCalledWith( + 'agent-1', + 'data/usage.json', + ); + }); + + it('reports nothing when every newer file came from the pod', async () => { + const guard = new SyncGuardService( + fileStub( + [node('data/usage.json', at(600)), node('memory/today.md', at(700))], + [], + ), + ); + const result = await guard.assess('agent-1', at(-1000), at(100)); + expect(result.baseline).toEqual(at(100)); + expect(result.atRisk).toEqual([]); + }); }); }); diff --git a/api/src/slices/agent/file/domain/syncGuard.service.ts b/api/src/slices/agent/file/domain/syncGuard.service.ts index ea63ca37..3db26924 100644 --- a/api/src/slices/agent/file/domain/syncGuard.service.ts +++ b/api/src/slices/agent/file/domain/syncGuard.service.ts @@ -22,6 +22,12 @@ export interface ISyncRiskAssessment { * "at risk" — a Sync could overwrite or delete them if the pod's local copy * also changed. The platform cannot see the pod's local state, so this is an * upper bound by design (false positives possible, false negatives not). + * + * CLEAN-115: the pod also has an fs.watch pusher that uploads its own + * changes as they happen (usage.json on every LLM call, memory, sessions). + * Those objects are newer than the baseline too, but they ARE the pod's copy + * and cannot be at risk. Ranch tags every write it makes; a newer object + * without the tag is a watcher upload and is dropped from the list. */ @Injectable() export class SyncGuardService { @@ -48,9 +54,15 @@ export class SyncGuardService { // behave exactly as before the guard existed. if (!baseline) return { baseline: null, atRisk: [] }; const nodes = await this.files.list(agentId); - const atRisk = nodes.filter( + const newer = nodes.filter( (n) => n.updatedAt.getTime() > baseline.getTime(), ); + // One HeadObject per newer file — usually a handful, never the whole + // workspace. Files at or below the baseline are not looked up. + const origins = await Promise.all( + newer.map((n) => this.files.wasWrittenByRanch(agentId, n.path)), + ); + const atRisk = newer.filter((_, i) => origins[i]); return { baseline, atRisk }; } } diff --git a/api/src/slices/agent/file/dtos/syncFiles.dto.ts b/api/src/slices/agent/file/dtos/syncFiles.dto.ts index 3131529b..45953d5e 100644 --- a/api/src/slices/agent/file/dtos/syncFiles.dto.ts +++ b/api/src/slices/agent/file/dtos/syncFiles.dto.ts @@ -34,8 +34,10 @@ export class SyncConflictDto { @ApiProperty({ type: [AtRiskFileDto], description: - 'S3 files modified after the pod last pulled/pushed. A sync MAY ' + - 'overwrite or delete them if the pod also changed them locally.', + 'S3 files written from Ranch (console, tools, import) after the pod ' + + 'last pulled/pushed. A sync MAY overwrite or delete them if the pod ' + + 'also changed them locally. Objects the pod uploaded itself are ' + + 'excluded (no Ranch origin tag).', }) atRisk!: AtRiskFileDto[]; diff --git a/api/src/slices/agent/file/file.controller.ts b/api/src/slices/agent/file/file.controller.ts index 3f5e4911..38abada9 100644 --- a/api/src/slices/agent/file/file.controller.ts +++ b/api/src/slices/agent/file/file.controller.ts @@ -210,8 +210,9 @@ export class FileController { status: 409, type: SyncConflictDto, description: - 'S3 files newer than the pod’s working copy were found and confirm was ' + - 'not set. No sync was performed.', + 'S3 files edited from Ranch after the pod’s last pull/push were found ' + + 'and confirm was not set. No sync was performed. Files the pod ' + + 'uploaded itself are not counted.', }) async sync( @Param('agentId') agentId: string, diff --git a/api/src/slices/agent/file/file.tool.ts b/api/src/slices/agent/file/file.tool.ts index e2a83b65..db2043f0 100644 --- a/api/src/slices/agent/file/file.tool.ts +++ b/api/src/slices/agent/file/file.tool.ts @@ -638,8 +638,10 @@ export class FileTool implements IConditionallyListedTool { description: 'Ask the running agent to push its local workspace files to S3, so the ' + 'console and read_agent_file see what the pod sees. Refuses with an ' + - '`atRisk` list (and does NOT sync) when S3 holds files edited after ' + - "the pod's last pull or push — those could be overwritten. Show the " + + '`atRisk` list (and does NOT sync) when S3 holds files edited from ' + + "Ranch (console, tools, import) after the pod's last pull or push — " + + 'those could be overwritten; files the pod uploaded itself are never ' + + 'listed. Show the ' + 'list to the person; call again with `confirm: true` only after they ' + 'accepted the risk. Returns `agentOnline` and the number of files ' + 'pushed; an offline agent pushes nothing.', diff --git a/app/slices/setup/api/data/repositories/api/schemas.gen.ts b/app/slices/setup/api/data/repositories/api/schemas.gen.ts index fbb8f8dd..c6721511 100644 --- a/app/slices/setup/api/data/repositories/api/schemas.gen.ts +++ b/app/slices/setup/api/data/repositories/api/schemas.gen.ts @@ -1233,7 +1233,7 @@ export const SyncConflictDtoSchema = { }, atRisk: { description: - "S3 files modified after the pod last pulled/pushed. A sync MAY overwrite or delete them if the pod also changed them locally.", + "S3 files written from Ranch (console, tools, import) after the pod last pulled/pushed. A sync MAY overwrite or delete them if the pod also changed them locally. Objects the pod uploaded itself are excluded (no Ranch origin tag).", type: "array", items: { $ref: "#/components/schemas/AtRiskFileDto", diff --git a/app/slices/setup/api/data/repositories/api/types.gen.ts b/app/slices/setup/api/data/repositories/api/types.gen.ts index 49a9241c..7a6c87e4 100644 --- a/app/slices/setup/api/data/repositories/api/types.gen.ts +++ b/app/slices/setup/api/data/repositories/api/types.gen.ts @@ -553,7 +553,7 @@ export type SyncConflictDto = { */ requiresConfirmation: boolean; /** - * S3 files modified after the pod last pulled/pushed. A sync MAY overwrite or delete them if the pod also changed them locally. + * S3 files written from Ranch (console, tools, import) after the pod last pulled/pushed. A sync MAY overwrite or delete them if the pod also changed them locally. Objects the pod uploaded itself are excluded (no Ranch origin tag). */ atRisk: Array; /** @@ -3099,7 +3099,7 @@ export type FileControllerSyncData = { export type FileControllerSyncErrors = { /** - * S3 files newer than the pod’s working copy were found and confirm was not set. No sync was performed. + * S3 files edited from Ranch after the pod’s last pull/push were found and confirm was not set. No sync was performed. Files the pod uploaded itself are not counted. */ 409: SyncConflictDto; }; diff --git a/specs/008-agent-files-sync-safety/research.md b/specs/008-agent-files-sync-safety/research.md index 22309842..3f2e0536 100644 --- a/specs/008-agent-files-sync-safety/research.md +++ b/specs/008-agent-files-sync-safety/research.md @@ -55,3 +55,11 @@ - Status updates: `agent.gateway.ts:91-111` (`updateStatus`), driven by `agentStatus.service.ts` subscribing to bridle `agentEvents$` (`bridle.gateway.ts:75` emits `connected`) — `lastPullAt` hooks into the same path. - Tests: api uses jest (`test: jest --passWithNoTests`); admin has no tests (manual validation via quickstart). - OpenAPI: regenerate `api` swagger (`generate:swagger`) then `admin bun run build:api` (openapi-ts) after DTO changes. + +## R8. Amendment (CLEAN-115, 2026-09-24): the runtime's watcher was missed in R1 + +**Finding**: R1 described the bridle `sync` event as the pod's only push path. The runtime also starts an fs.watch pusher whenever `S3_BUCKET` is set (`runtime.module.ts:347` → `s3-sync.gateway.ts:startWatcher`, 30 s debounce, always on). Every local change the pod makes reaches S3 on its own: `data/usage.json` after each LLM call, memory, sessions. Those objects are newer than the R2 baseline yet are the pod's own copy, so the guard listed them on every Sync after the first LLM call (`data/usage.json` in the 409 dialog), and the "Agent copy is newer" pill claimed a lag that in practice is under a minute. + +**Decision**: every S3 write Ranch makes into an agent prefix (console save, agent tool, import, skill sync) carries object metadata `origin=ranch` (`file.gateway.ts:RANCH_ORIGIN_METADATA`). `SyncGuardService.assess` keeps only tagged objects among the newer-than-baseline candidates, one `HeadObject` per candidate. Untagged = uploaded by the pod = never at risk. Template copies (seed/resync via `CopyObject`) stay untagged: they land before the boot pull and are never newer than the baseline. The pill now says the agent is running and that the pod pushes its changes within about 30 s; Sync remains the full-push safety net. + +**Residual risk (out of scope here, runtime repo)**: the watcher itself never checks S3 freshness. A Ranch edit to a file the pod rewrites on its own (usage, memory, sessions) is overwritten by the next watcher flush, well before any Sync click, and the guard cannot intervene. Files the pod only reads (SOUL.md, skills, config) are not affected: the watcher pushes only paths whose local mtime/size changed. Hardening belongs in the runtime: tag its own uploads (`origin=pod`) and/or `HeadObject` before overwriting an object newer than its manifest entry. diff --git a/specs/017-advanced-file-management/quickstart.md b/specs/017-advanced-file-management/quickstart.md index 827d6bd4..3ead223f 100644 --- a/specs/017-advanced-file-management/quickstart.md +++ b/specs/017-advanced-file-management/quickstart.md @@ -62,7 +62,7 @@ Expected: file slice specs green (archive validation, plan classification, diff 3. Select everything → Delete → refused with the "would empty the workspace" acknowledgement. 4. Open three files, edit one → dot on its tab; switch tabs → edit kept; close it → keep/discard prompt; reload page with a dirty draft → browser warns. 5. **New file** `notes/todo.md` → appears in the tree, editable; `notes/todo.md` again → 409 "already exists"; `../x.md` → refused. -6. Running agent with an older copy → header shows the "Agent copy is newer (HH:MM) — Sync now" pill instead of the banner. +6. Running agent → header shows the neutral "Agent running since HH:MM — Sync now" pill instead of the banner (CLEAN-115: the pod pushes its own changes within ~30 s, so the pill no longer claims the pod holds newer files; Sync is the full-push safety net). After the agent made an LLM call, Sync must NOT open the "Overwrite newer files" dialog for `data/usage.json`; a file saved from the console while the agent runs still does. ## Done when From a3f84415439031ac866a38fe8fa07d06eac81b8e Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Thu, 24 Sep 2026 14:55:39 +0300 Subject: [PATCH 2/2] chore: bump version to 0.3.63 (CLEAN-115) Co-Authored-By: Claude Fable 5.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bcee9ab3..f862fe3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ranch", - "version": "0.3.62", + "version": "0.3.63", "private": true, "packageManager": "bun@1.2.12", "workspaces": [