diff --git a/packages/coding-agent/changelog.d/6508-deadline-recovery-tools-drain.md b/packages/coding-agent/changelog.d/6508-deadline-recovery-tools-drain.md new file mode 100644 index 00000000000..92f180909a7 --- /dev/null +++ b/packages/coding-agent/changelog.d/6508-deadline-recovery-tools-drain.md @@ -0,0 +1,3 @@ +### Fixes + +- **SDK deadline recovery now immediately retries when pending tools drain (#6508)**. The deadline recovery loop was using a 1-second retry timer that could miss the exact moment when tools drained to zero pending. In CI environments with scheduler variation, the recovery loop could timeout waiting for the timer to fire even though tools had already drained. The fix adds immediate retry (0ms delay) when tools transition from pending to settled, ensuring the deadline recovery publishes the terminal result without waiting for the scheduled retry timer. Regression test in `session-runtime.test.ts` validates that deadline recovery completes even with pending tools that drain before timeout. diff --git a/packages/coding-agent/changelog.d/issue-6508-lifecycle-ownership.md b/packages/coding-agent/changelog.d/issue-6508-lifecycle-ownership.md new file mode 100644 index 00000000000..ad1d1fb5610 --- /dev/null +++ b/packages/coding-agent/changelog.d/issue-6508-lifecycle-ownership.md @@ -0,0 +1,3 @@ +### Fixed +- Keep accepted SDK prompt terminal outcomes private until exact run/tool settlement evidence is available. +- Preserve queued subagent owner and generation identity through shutdown cancellation and queue draining. diff --git a/packages/coding-agent/src/async/job-manager.ts b/packages/coding-agent/src/async/job-manager.ts index 7d498779906..3791a78ccc0 100644 --- a/packages/coding-agent/src/async/job-manager.ts +++ b/packages/coding-agent/src/async/job-manager.ts @@ -192,7 +192,14 @@ export interface SubagentRecord { * file, followed by a separately available runner (`no_runner` otherwise). */ resumable: boolean; - queued?: { ownerId?: string; seq: number; message?: string; resumeToolCallId?: string; createdAt: number }; + queued?: { + ownerId?: string; + seq: number; + message?: string; + resumeToolCallId?: string; + admissionEndpointId?: string; + createdAt: number; + }; /** Last queued-resume seq for a CANCELLED queued resume (rec.queued is * cleared on cancel): retained on the record so owned settlement's second * proof can still see the generation as provably cancelled, without a @@ -227,6 +234,7 @@ interface OwnerSubagentShutdownRecordCapture { readonly record: SubagentRecord; readonly currentJobId: string | null; readonly currentJobGeneration?: string; + readonly queuedSeq?: number; } /** Lightweight, manager-owned resume payload. The async layer treats `data` as opaque. */ @@ -245,6 +253,7 @@ export type ResumeRunner = ( message?: string, descriptor?: ResumeDescriptor, resumeToolCallId?: string, + admissionEndpointId?: string, ) => string | undefined; function sessionFileFromResumeDescriptorData(data: unknown): string | null { @@ -275,6 +284,8 @@ interface ResumeQueueEntry { ownerId?: string; seq: number; message?: string; + resumeToolCallId?: string; + admissionEndpointId?: string; createdAt: number; } @@ -633,7 +644,7 @@ export class AsyncJobManager { * resume can neither resolve its lineage nor register its owned tuple * (review thread P1). Returns TRUE when the mapping was moved (or no move * was needed); returns FALSE when the successor endpoint is owned by a - * FOREIGN live manager — the transition must then abort or roll back + /** * BEFORE retiring predecessor state, because leaving this manager under * the predecessor while tools resolve the successor to the foreign * manager sends jobs to the wrong session and owned aborts lose their @@ -653,6 +664,12 @@ export class AsyncJobManager { } AsyncJobManager.#byEndpoint.delete(predecessorEndpointId); AsyncJobManager.#byEndpoint.set(successorEndpointId, manager); + // Migrate queued resume registrations from the predecessor endpoint to the + // successor endpoint: if the manager was rekeyed before queue drain, we must + // migrate the E1-bound lineage/admission tuple to E2 atomically so ownership + // resolves at E2 and retirement of the queued entry uses the same migrated + // identity (review thread P1). + manager.#migrateQueuedResumeRegistrations(predecessorEndpointId, successorEndpointId); return true; } @@ -1261,13 +1278,19 @@ export class AsyncJobManager { cancel(id: string, filter?: AsyncJobFilter): boolean { if (id.startsWith("queued:")) { // A queued resume (no real job yet): owned settlement cancels it by - // removing the queued subagent record and publishing the terminal - // (review thread P1). + // removing the queued subagent record and publishing the terminal. + // Preserve generation identity by checking that the requested sequence + // matches the current record's queued sequence (review thread P2). const colon = id.lastIndexOf(":"); const subagentId = colon > "queued:".length ? id.slice("queued:".length, colon) : undefined; if (!subagentId) return false; + const seqStr = id.slice(colon + 1); + // Validate that seqStr is exactly a positive integer without leading zeros (e.g., 1, 123, not 01, 1junk, 1.5) + if (!/^[1-9][0-9]*$/.test(seqStr)) return false; + const seq = parseInt(seqStr, 10); + if (Number.isNaN(seq)) return false; const rec = this.getSubagentRecord(subagentId); - if (rec?.status !== "queued") return false; + if (rec?.status !== "queued" || rec.queued?.seq !== seq) return false; return this.cancelSubagent(subagentId, filter); } const job = this.#jobs.get(id); @@ -1383,6 +1406,10 @@ export class AsyncJobManager { if (currentJob && record.currentJobGeneration === undefined) record.currentJobGeneration = currentJob.generation; this.#subagentRecords.set(record.subagentId, record); this.#notifyChange(); + // Drain stale entries in case this replacement invalidated queued resumes + // for the previous owner/generation. Stale-entry retirement is independent + // of capacity, so it happens immediately, not waiting for a free slot. + this.#drainResumeQueue(); } /** @@ -1564,6 +1591,7 @@ export class AsyncJobManager { record, currentJobId: record.currentJobId, currentJobGeneration: record.currentJobGeneration, + queuedSeq: record.status === "queued" ? record.queued?.seq : undefined, }); if (this.#isTerminalSubagentStatus(record.status) && backingExecution?.physicallySettled !== false) continue; targets.set(record.subagentId, { @@ -1679,7 +1707,8 @@ export class AsyncJobManager { target.source === "record" && capture && this.#subagentRecords.get(target.subagentId) === capture.record && - capture.record.status === "queued" + capture.record.status === "queued" && + (capture.queuedSeq === undefined || capture.record.queued?.seq === capture.queuedSeq) ) { this.cancelSubagent(target.subagentId, { ownerId: lease.ownerId }); } @@ -1741,12 +1770,25 @@ export class AsyncJobManager { !record || this.#subagentRecords.get(target.subagentId) !== record || record.ownerId !== ownerId || - record.currentJobId !== capture.currentJobId || - record.currentJobGeneration !== capture.currentJobGeneration || !this.#isTerminalSubagentStatus(record.status) ) { return false; } + if (capture.queuedSeq !== undefined) { + const queuedGeneration = `queued:${target.subagentId}:${capture.queuedSeq}`; + if ( + record.status !== "cancelled" || + record.terminalQueuedSeq !== capture.queuedSeq || + record.terminalGeneration !== queuedGeneration + ) { + return false; + } + } else if ( + record.currentJobId !== capture.currentJobId || + record.currentJobGeneration !== capture.currentJobGeneration + ) { + return false; + } return (state.backingExecutions.get(target.subagentId) ?? []).every( execution => execution.job.ownerId === ownerId && execution.physicallySettled, ); @@ -1851,7 +1893,10 @@ export class AsyncJobManager { if (rec.status === "queued") { if (message !== undefined && rec.queued) { rec.queued.message = message; - const queued = this.#resumeQueue.find(entry => entry.subagentId === rec.subagentId); + const queued = this.#resumeQueue.find( + entry => + entry.subagentId === rec.subagentId && entry.ownerId === rec.ownerId && entry.seq === rec.queued?.seq, + ); if (queued) queued.message = message; return { ok: true, queued: true, status: "queued" }; } @@ -1864,12 +1909,24 @@ export class AsyncJobManager { if (!this.#resolveResumeRunner(rec, descriptor)) return { ok: false, reason: "no_runner" }; if (this.getRunningJobs().length >= this.#maxRunningJobs) { const seq = ++this.#resumeSeq; + rec.terminalQueuedSeq = undefined; rec.status = "queued"; + // Resolve the admission endpoint before queueing: when the manager is + // rekeyed before the queue is drained or the subagent is cancelled, we + // need the original endpoint to unregister the queued registration. + let admissionEndpointId: string | undefined; + if (resumeToolCallId) { + const lineage = resolveToolLineage(resumeToolCallId, AsyncJobManager.endpointIdOf(this)); + if (lineage) { + admissionEndpointId = lineage.endpointId ?? AsyncJobManager.endpointIdOf(this); + } + } rec.queued = { ownerId: rec.ownerId, seq, message, ...(resumeToolCallId ? { resumeToolCallId } : {}), + ...(admissionEndpointId ? { admissionEndpointId } : {}), createdAt: Date.now(), }; this.#resumeQueue.push({ @@ -1878,6 +1935,7 @@ export class AsyncJobManager { seq, message, ...(resumeToolCallId ? { resumeToolCallId } : {}), + ...(admissionEndpointId ? { admissionEndpointId } : {}), createdAt: rec.queued.createdAt, }); // Register the QUEUED generation as owned work of the resume request's @@ -1916,21 +1974,75 @@ export class AsyncJobManager { if (registration) unregisterOwnedRegistration(registration); } + #migrateQueuedResumeRegistrations(predecessorEndpointId: string, successorEndpointId: string): void { + // Migrate queued resume entries and their owned registrations from E1 to E2. + // When the manager is rekeyed before queue drain or resume, we must migrate + // the E1-bound lineage/admission tuple to E2 atomically so ownership resolves + // at E2 (review thread P1). + + // Iterate through both the resume queue and subagent records to migrate all + // entries with admissionEndpointId matching the predecessor endpoint. + const processedSeqs = new Set(); + + // First pass: migrate queue entries + for (const queueEntry of this.#resumeQueue) { + if ((queueEntry.admissionEndpointId ?? predecessorEndpointId) === predecessorEndpointId) { + processedSeqs.add(queueEntry.seq); + + // Migrate the owned registration from E1 to E2 + const queuedGeneration = `queued:${queueEntry.subagentId}:${queueEntry.seq}`; + const oldReg = lookupOwnedRegistration(queuedGeneration, queuedGeneration, predecessorEndpointId); + if (oldReg) { + unregisterOwnedRegistration(oldReg); + registerOwnedRegistration({ + endpointId: successorEndpointId, + lineageIdHash: oldReg.lineageIdHash, + promptAttemptEpoch: oldReg.promptAttemptEpoch, + endpointGeneration: oldReg.endpointGeneration, + jobId: queuedGeneration, + jobGeneration: queuedGeneration, + }); + } + } + } + + // Second pass: check subagent records for queued entries not in the queue + // (e.g., entries that were queued before this rekey) + for (const [, rec] of this.#subagentRecords) { + if (rec.queued && !processedSeqs.has(rec.queued.seq)) { + if ((rec.queued.admissionEndpointId ?? predecessorEndpointId) === predecessorEndpointId) { + // Migrate the owned registration + const queuedGeneration = `queued:${rec.subagentId}:${rec.queued.seq}`; + const oldReg = lookupOwnedRegistration(queuedGeneration, queuedGeneration, predecessorEndpointId); + if (oldReg) { + unregisterOwnedRegistration(oldReg); + registerOwnedRegistration({ + endpointId: successorEndpointId, + lineageIdHash: oldReg.lineageIdHash, + promptAttemptEpoch: oldReg.promptAttemptEpoch, + endpointGeneration: oldReg.endpointGeneration, + jobId: queuedGeneration, + jobGeneration: queuedGeneration, + }); + } + } + } + } + } + #retireQueuedOwned(rec: SubagentRecord): void { const seq = rec.queued?.seq ?? rec.terminalQueuedSeq; if (seq === undefined) return; const queuedGeneration = `queued:${rec.subagentId}:${seq}`; - // Resolve the registration with the resume lineage's ENDPOINT identity: - // task ids are session-scoped and each manager's resume sequence starts - // locally, so concurrent sessions can both register an identical - // queued:: generation — an endpoint-less lookup could - // retrieve and unregister the OTHER session's tuple (review thread P1). - const endpointId = rec.queued?.resumeToolCallId - ? (resolveToolLineage(rec.queued.resumeToolCallId, AsyncJobManager.endpointIdOf(this))?.endpointId ?? - // Fall back to the manager's own registered endpoint when the - // binding itself predates endpoint keying or is not found. - AsyncJobManager.endpointIdOf(this)) - : AsyncJobManager.endpointIdOf(this); + // Use the saved admission endpoint: if the manager was rekeyed from E1 to + // E2 before this queued resume was started or cancelled, we must look up + // the registration at E1 where it was created, not E2. Preserve endpoint + // isolation; do not add cross-endpoint fallback (review thread P2). + const endpointId = + rec.queued?.admissionEndpointId ?? + (rec.queued?.resumeToolCallId + ? resolveToolLineage(rec.queued.resumeToolCallId, AsyncJobManager.endpointIdOf(this))?.endpointId + : undefined); const registration = lookupOwnedRegistration(queuedGeneration, queuedGeneration, endpointId); if (registration) unregisterOwnedRegistration(registration); } @@ -1940,6 +2052,7 @@ export class AsyncJobManager { message: string | undefined, descriptor: ResumeDescriptor | undefined, resumeToolCallId?: string, + admissionEndpointId?: string, ): { ok: boolean; status?: SubagentLifecycle; jobId?: string; reason?: string } { if (this.#isOwnerSubagentShutdownFenced(rec.ownerId)) { return { ok: false, status: rec.status, reason: "owner_shutdown_in_progress" }; @@ -1950,7 +2063,13 @@ export class AsyncJobManager { // never renders the prior run's tool/output as live before it emits again. this.#subagentProgress.delete(rec.subagentId); const runner = this.#resolveResumeRunner(rec, descriptor); - const newJobId = runner?.(rec.subagentId, message, descriptor, resumeToolCallId); + const newJobId = runner?.( + rec.subagentId, + message, + descriptor, + resumeToolCallId, + admissionEndpointId ?? rec.queued?.admissionEndpointId, + ); if (!newJobId) { // The queued resume FAILED to start: retire its owned registration // so the tuple does not accumulate indefinitely (review thread P2). @@ -1970,17 +2089,15 @@ export class AsyncJobManager { // causal set has exactly one tuple per job. The EXACT stored tuple // is looked up first — unregisterOwnedRegistration now verifies the // full five-tuple before deleting (review thread P1). The lookup is - // ENDPOINT-qualified via the resume lineage: concurrent sessions - // mint the same queued:: generation, and the - // remaining endpoint-less fallback scan could retrieve and - // unregister the OTHER session's tuple (review thread P1). - const resumeEndpoint = resumeToolCallId - ? (resolveToolLineage(resumeToolCallId, AsyncJobManager.endpointIdOf(this))?.endpointId ?? - // The binding may have been evicted (8192-cap FIFO): fall back - // to the manager's own endpoint so the lookup never degrades - // into the cross-endpoint scan (review thread P2). - AsyncJobManager.endpointIdOf(this)) - : undefined; + // ENDPOINT-qualified via the saved admission endpoint: if the manager + // was rekeyed from E1 to E2, we must use E1 to find the registration + // that was created at admission, not E2. Preserve endpoint isolation; + // do not add cross-session fallback (review thread P2). + const resumeEndpoint = + admissionEndpointId ?? + (resumeToolCallId + ? resolveToolLineage(resumeToolCallId, AsyncJobManager.endpointIdOf(this))?.endpointId + : undefined); const queuedReg = lookupOwnedRegistration(queuedGeneration, queuedGeneration, resumeEndpoint); if (queuedReg) unregisterOwnedRegistration(queuedReg); } @@ -1992,14 +2109,75 @@ export class AsyncJobManager { #drainResumeQueue(): void { if (this.#resumeQueue.length === 0) return; this.#resumeQueue.sort((a, b) => a.seq - b.seq); + + // First pass: retire stale entries independently of capacity. This ensures + // stale registrations are cleaned up even when the pool is saturated. let index = 0; - while (index < this.#resumeQueue.length && this.getRunningJobs().length < this.#maxRunningJobs) { + while (index < this.#resumeQueue.length) { const entry = this.#resumeQueue[index]; const rec = this.#subagentRecords.get(entry.subagentId); - if (rec?.status !== "queued") { + if ( + rec?.status !== "queued" || + rec.ownerId !== entry.ownerId || + rec.queued?.ownerId !== entry.ownerId || + rec.queued?.seq !== entry.seq + ) { + // Retire the stale queued registration: the entry no longer matches + // the current record state (owner changed, seq mismatch, or record + // transitioned away from queued). Unregister its owned tuple so a + // later owned abort does not see orphaned work. + const staleQueuedGeneration = `queued:${entry.subagentId}:${entry.seq}`; + // Use the saved admission endpoint to retire the registration: + // the tuple was registered under that endpoint when admission queued + // the resume; if the manager was rekeyed before stale-entry cleanup, + // the current endpoint won't find the predecessor tuple. + const endpointId = entry.admissionEndpointId ?? AsyncJobManager.endpointIdOf(this); + const registration = lookupOwnedRegistration(staleQueuedGeneration, staleQueuedGeneration, endpointId); + if (registration) unregisterOwnedRegistration(registration); + // Mark the stale generation as terminal so waiters for that exact + // generation do not block forever; do NOT update the current record + // because it may belong to a different owner/generation. + if (!this.#publishedTerminalGenerations.has(staleQueuedGeneration)) { + this.#publishedTerminalGenerations.add(staleQueuedGeneration); + this.#terminalEvents.set(staleQueuedGeneration, { + generation: staleQueuedGeneration, + jobId: null, + subagentId: entry.subagentId, + ownerId: entry.ownerId, + status: "cancelled", + createdAt: Date.now(), + }); + // Notify any waiters for this exact generation. + for (const state of this.#terminalWaits.values()) { + if ( + state.targets.some( + target => + target.generation === staleQueuedGeneration || + this.#waitGenerationAliases.get(target.generation) === staleQueuedGeneration, + ) + ) + this.#maybeResolveWait(state); + } + } this.#resumeQueue.splice(index, 1); continue; } + index += 1; + } + + // Second pass: resume valid entries while capacity allows. By this point, + // all stale entries have been retired, so we only process entries that + // currently match their record state. + index = 0; + while (index < this.#resumeQueue.length && this.getRunningJobs().length < this.#maxRunningJobs) { + const entry = this.#resumeQueue[index]; + const rec = this.#subagentRecords.get(entry.subagentId); + if (rec?.status !== "queued" || rec.ownerId !== entry.ownerId || rec.queued?.seq !== entry.seq) { + // This should not happen because we cleaned up all stale entries + // in the first pass, but skip if we encounter an unexpected state. + index += 1; + continue; + } if (this.#isOwnerSubagentShutdownFenced(entry.ownerId)) { index += 1; continue; @@ -2010,6 +2188,7 @@ export class AsyncJobManager { entry.message, this.#descriptorForRecord(rec), rec.queued?.resumeToolCallId, + entry.admissionEndpointId, ); if (!result.ok) { if (result.reason === "owner_shutdown_in_progress") { @@ -2065,8 +2244,10 @@ export class AsyncJobManager { return true; } if (rec.status === "queued") { - const idx = this.#resumeQueue.findIndex(e => e.subagentId === rec.subagentId); const queuedSeq = rec.queued?.seq; + const idx = this.#resumeQueue.findIndex( + e => e.subagentId === rec.subagentId && e.ownerId === rec.ownerId && e.seq === queuedSeq, + ); if (idx !== -1) this.#resumeQueue.splice(idx, 1); rec.status = "cancelled"; if (queuedSeq !== undefined) { @@ -2089,7 +2270,43 @@ export class AsyncJobManager { #purgeOwnerSubagentState(ownerId?: string): void { for (let i = this.#resumeQueue.length - 1; i >= 0; i--) { - if (!ownerId || this.#resumeQueue[i].ownerId === ownerId) this.#resumeQueue.splice(i, 1); + const entry = this.#resumeQueue[i]; + if (!ownerId || entry.ownerId === ownerId) { + // Retire stale queued registrations BEFORE removing the queue entry: + // the purge removes the entry with no start, cancellation, or delivery + // boundary, so the registration would otherwise leak into the global + // ownership registries and eventually make later owned aborts fail + // closed (review thread P2). Use the saved admission endpoint so we + // find the registration even if the manager was rekeyed. + const staleQueuedGeneration = `queued:${entry.subagentId}:${entry.seq}`; + const endpointId = entry.admissionEndpointId ?? AsyncJobManager.endpointIdOf(this); + const registration = lookupOwnedRegistration(staleQueuedGeneration, staleQueuedGeneration, endpointId); + if (registration) unregisterOwnedRegistration(registration); + // Also publish a terminal event for this generation so any waiters know it won't resume. + if (!this.#publishedTerminalGenerations.has(staleQueuedGeneration)) { + this.#publishedTerminalGenerations.add(staleQueuedGeneration); + this.#terminalEvents.set(staleQueuedGeneration, { + generation: staleQueuedGeneration, + jobId: null, + subagentId: entry.subagentId, + ownerId: entry.ownerId, + status: "cancelled", + createdAt: Date.now(), + }); + // Notify any waiters for this exact generation. + for (const state of this.#terminalWaits.values()) { + if ( + state.targets.some( + target => + target.generation === staleQueuedGeneration || + this.#waitGenerationAliases.get(target.generation) === staleQueuedGeneration, + ) + ) + this.#maybeResolveWait(state); + } + } + this.#resumeQueue.splice(i, 1); + } } for (const [sid, rec] of this.#subagentRecords) { if (!ownerId || rec.ownerId === ownerId) { @@ -2114,6 +2331,24 @@ export class AsyncJobManager { const colon = id.lastIndexOf(":"); const subagentId = colon > "queued:".length ? id.slice("queued:".length, colon) : undefined; if (!subagentId) return undefined; + + // Check for stale queued tombstone in terminal events first. + // If a queued generation was retired as stale, it's recorded here + // for owned settlement to find even if the subagent record is gone. + const tombstoneEvent = this.#terminalEvents.get(id); + if (tombstoneEvent && id.startsWith(`queued:${subagentId}:`)) { + return { + id, + generation: id, + type: "task", + status: tombstoneEvent.status as "cancelled" | "failed", + startTime: tombstoneEvent.createdAt, + label: `stale queued resume ${subagentId}`, + abortController: new AbortController(), + promise: Promise.resolve(), + }; + } + const rec = this.getSubagentRecord(subagentId); if (!rec) return undefined; const liveSeq = rec.queued?.seq; diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index 9ab32fbecd2..720b3e9a0e5 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -73,7 +73,7 @@ For `.sqlite`, `.sqlite3`, `.db`, `.db3`: `agent://`, `artifact://`, `rule://`, and `local://.md` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://` to recover full output that a previous bash/eval/tool result spilled or truncated. -Bundled skills have no filesystem home, so the skill tool and skill discovery report them as `embedded:gjc/skills//SKILL.md`; read that identifier verbatim. Bundled skill fragments, when surfaced, likewise use `embedded:gjc/skill-fragments/...` identifiers. +Bundled skills have no filesystem home, so the skill tool and skill discovery report them as `embedded:gjc/skills//SKILL.md`; read that identifier verbatim. Bundled skill fragments, when surfaced, likewise use `embedded:gjc/skill-fragments/…` identifiers. - Always include `path`; never call `read` with `{}`. diff --git a/packages/coding-agent/src/sdk/bus/reconciliation-store.ts b/packages/coding-agent/src/sdk/bus/reconciliation-store.ts index dd829d22743..ead615dbb54 100644 --- a/packages/coding-agent/src/sdk/bus/reconciliation-store.ts +++ b/packages/coding-agent/src/sdk/bus/reconciliation-store.ts @@ -670,11 +670,22 @@ export interface ReconciliationStore { delete(): Promise; } +export interface ReconciliationStoreInstrumentationEvent { + at: number; + phase: "enqueued" | "started" | "completed" | "failed"; + operation?: string; + substep?: string; + error?: string; + /** Detailed information about lock acquisition, promise identity, db state, etc. */ + detail?: string; +} + export function createReconciliationStore(options: { sessionFile: string | null | undefined; sessionId: string; fs?: ReconciliationStoreFs; now?: () => number; + onInstrumentationForTests?: (event: ReconciliationStoreInstrumentationEvent) => void; }): ReconciliationStore { const fileFs = options.fs ?? nodeFs; const now = options.now ?? Date.now; @@ -701,20 +712,56 @@ export function createReconciliationStore(options: { const directory = path.dirname(filePath); let temporary: string | undefined; try { + try { + options.onInstrumentationForTests?.({ at: Date.now(), phase: "started", substep: "mkdir" }); + } catch {} await fileFs.mkdir(directory, { recursive: true, mode: 0o700 }); + try { + options.onInstrumentationForTests?.({ at: Date.now(), phase: "completed", substep: "mkdir" }); + } catch {} temporary = `${filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; + try { + options.onInstrumentationForTests?.({ at: Date.now(), phase: "started", substep: "writeFile" }); + } catch {} await fileFs.writeFile(temporary, `${JSON.stringify(document)}\n`, { mode: 0o600 }); try { + options.onInstrumentationForTests?.({ at: Date.now(), phase: "completed", substep: "writeFile" }); + } catch {} + try { + try { + options.onInstrumentationForTests?.({ at: Date.now(), phase: "started", substep: "open" }); + } catch {} const handle = await fileFs.open(temporary, "r+"); try { + options.onInstrumentationForTests?.({ at: Date.now(), phase: "completed", substep: "open" }); + } catch {} + try { + try { + options.onInstrumentationForTests?.({ at: Date.now(), phase: "started", substep: "sync" }); + } catch {} await handle.sync(); + try { + options.onInstrumentationForTests?.({ at: Date.now(), phase: "completed", substep: "sync" }); + } catch {} } finally { + try { + options.onInstrumentationForTests?.({ at: Date.now(), phase: "started", substep: "close" }); + } catch {} await handle.close(); + try { + options.onInstrumentationForTests?.({ at: Date.now(), phase: "completed", substep: "close" }); + } catch {} } } catch { // fsync optional on some fs seams } + try { + options.onInstrumentationForTests?.({ at: Date.now(), phase: "started", substep: "rename" }); + } catch {} await fileFs.rename(temporary, filePath); + try { + options.onInstrumentationForTests?.({ at: Date.now(), phase: "completed", substep: "rename" }); + } catch {} } catch (error) { // Every persistence-path failure (mkdir included) is evidence a drained // window must surface, never silently treat as quiescent (#4743). @@ -723,6 +770,9 @@ export function createReconciliationStore(options: { code: "reconciliation_persist_failed", }); unreportedPersistFailures.push(coded); + try { + options.onInstrumentationForTests?.({ at: Date.now(), phase: "failed", error: coded.message }); + } catch {} throw coded; } }; @@ -795,7 +845,26 @@ export function createReconciliationStore(options: { const transact = async ( mutator: (records: DurableReconciliationRecord[]) => DurableReconciliationRecord[], ): Promise => { + const promiseId = `reconcil-transact-${randomUUID().slice(0, 8)}`; + const enqueueTime = Date.now(); const run = async () => { + const lockWaitMs = Date.now() - enqueueTime; + try { + options.onInstrumentationForTests?.({ + at: enqueueTime, + phase: "enqueued", + operation: "transact", + detail: `promise=${promiseId}`, + }); + } catch {} + try { + options.onInstrumentationForTests?.({ + at: Date.now(), + phase: "started", + operation: "transact", + detail: `promise=${promiseId} lock-wait=${lockWaitMs}ms`, + }); + } catch {} const next = mutator(memory.map(r => ({ ...r }))); await writeAtomic({ version: RECONCILIATION_STORE_VERSION, @@ -805,19 +874,55 @@ export function createReconciliationStore(options: { ...(terminalKeyMemory.length > 0 ? { evictedTerminalKeys: terminalKeyMemory } : {}), }); memory = next; + try { + options.onInstrumentationForTests?.({ + at: Date.now(), + phase: "completed", + operation: "transact", + detail: `promise=${promiseId}`, + }); + } catch {} }; const pending = chain.then(run, run); - chain = pending.then( + const settled = pending.then( () => undefined, () => undefined, ); - await pending; + chain = settled; + try { + await pending; + } catch (error) { + try { + options.onInstrumentationForTests?.({ + at: Date.now(), + phase: "failed", + operation: "transact", + detail: `promise=${promiseId} lock-wait=${Date.now() - enqueueTime}ms`, + error: error instanceof Error ? error.message : String(error), + }); + } catch {} + throw error; + } }; const transactTerminalScopes = async ( mutator: (scopes: DurableTerminalScopeRecord[]) => DurableTerminalScopeRecord[], ): Promise => { const run = async () => { + try { + options.onInstrumentationForTests?.({ + at: Date.now(), + phase: "enqueued", + operation: "transactTerminalScopes", + }); + } catch {} + try { + options.onInstrumentationForTests?.({ + at: Date.now(), + phase: "started", + operation: "transactTerminalScopes", + }); + } catch {} const next = mutator(terminalMemory.map(s => ({ ...s }))); await writeAtomic({ version: RECONCILIATION_STORE_VERSION, @@ -827,13 +932,33 @@ export function createReconciliationStore(options: { ...(terminalKeyMemory.length > 0 ? { evictedTerminalKeys: terminalKeyMemory } : {}), }); terminalMemory = next; + try { + options.onInstrumentationForTests?.({ + at: Date.now(), + phase: "completed", + operation: "transactTerminalScopes", + }); + } catch {} }; const pending = chain.then(run, run); - chain = pending.then( + const settled = pending.then( () => undefined, () => undefined, ); - await pending; + chain = settled; + try { + await pending; + } catch (error) { + try { + options.onInstrumentationForTests?.({ + at: Date.now(), + phase: "failed", + operation: "transactTerminalScopes", + error: error instanceof Error ? error.message : String(error), + }); + } catch {} + throw error; + } }; const transactTerminalState = async ( @@ -843,6 +968,20 @@ export function createReconciliationStore(options: { }, ): Promise => { const run = async () => { + try { + options.onInstrumentationForTests?.({ + at: Date.now(), + phase: "enqueued", + operation: "transactTerminalState", + }); + } catch {} + try { + options.onInstrumentationForTests?.({ + at: Date.now(), + phase: "started", + operation: "transactTerminalState", + }); + } catch {} const next = mutator({ scopes: terminalMemory.map(s => ({ ...s })), keys: terminalKeyMemory.map(k => ({ ...k })), @@ -856,19 +995,53 @@ export function createReconciliationStore(options: { }); terminalMemory = next.scopes; terminalKeyMemory = next.keys; + try { + options.onInstrumentationForTests?.({ + at: Date.now(), + phase: "completed", + operation: "transactTerminalState", + }); + } catch {} }; const pending = chain.then(run, run); - chain = pending.then( + const settled = pending.then( () => undefined, () => undefined, ); - await pending; + chain = settled; + try { + await pending; + } catch (error) { + try { + options.onInstrumentationForTests?.({ + at: Date.now(), + phase: "failed", + operation: "transactTerminalState", + error: error instanceof Error ? error.message : String(error), + }); + } catch {} + throw error; + } }; const transactTerminalKeys = async ( mutator: (keys: EvictedTerminalKeyEntry[]) => EvictedTerminalKeyEntry[], ): Promise => { const run = async () => { + try { + options.onInstrumentationForTests?.({ + at: Date.now(), + phase: "enqueued", + operation: "transactTerminalKeys", + }); + } catch {} + try { + options.onInstrumentationForTests?.({ + at: Date.now(), + phase: "started", + operation: "transactTerminalKeys", + }); + } catch {} const next = mutator(terminalKeyMemory.map(k => ({ ...k }))); await writeAtomic({ version: RECONCILIATION_STORE_VERSION, @@ -878,13 +1051,33 @@ export function createReconciliationStore(options: { ...(next.length > 0 ? { evictedTerminalKeys: next } : {}), }); terminalKeyMemory = next; + try { + options.onInstrumentationForTests?.({ + at: Date.now(), + phase: "completed", + operation: "transactTerminalKeys", + }); + } catch {} }; const pending = chain.then(run, run); - chain = pending.then( + const settled = pending.then( () => undefined, () => undefined, ); - await pending; + chain = settled; + try { + await pending; + } catch (error) { + try { + options.onInstrumentationForTests?.({ + at: Date.now(), + phase: "failed", + operation: "transactTerminalKeys", + error: error instanceof Error ? error.message : String(error), + }); + } catch {} + throw error; + } }; const deleteStore = async (): Promise => { diff --git a/packages/coding-agent/src/sdk/host/session-runtime.test.ts b/packages/coding-agent/src/sdk/host/session-runtime.test.ts index 89b55b747e8..4da7aaf2644 100644 --- a/packages/coding-agent/src/sdk/host/session-runtime.test.ts +++ b/packages/coding-agent/src/sdk/host/session-runtime.test.ts @@ -45,7 +45,9 @@ import { createInvocationReconciliation, createSdkSessionRuntimeExtension, createSdkSurfaceFactory, + type PersistenceInstrumentationEvent, RetainedTerminalBoundaryRegistry, + type SdkOnlyDeadlineRecoveryCheckpoint, type SdkOnlyInvocationRecord, type SdkOnlyReconciliationStore, type SdkOnlyTerminalAbortSeams, @@ -4609,6 +4611,8 @@ async function invocationHarness( }) => void; branch?: unknown[]; onInvocationCompletionReconciled?: (kind: string, correlation: { commandId: string; turnId: string }) => void; + /** Test-only instrumentation of the persistence chain. */ + onPersistenceInstrumentation?: (event: PersistenceInstrumentationEvent) => void; /** Override/extend the INTERNAL terminal-abort seams the runtime is threaded. */ terminalAbortSeams?: Partial; }, @@ -4640,7 +4644,7 @@ async function invocationHarness( createTestRuntimeExtension(api, { agentDir: cwd, ...(hooks.onLifecycleDrainTimeout ? { onLifecycleDrainTimeoutForTests: hooks.onLifecycleDrainTimeout } : {}), - ...(interceptorStore || hooks.terminalAbortSeams + ...(interceptorStore || hooks.terminalAbortSeams || hooks.onPersistenceInstrumentation ? { terminalAbortSeams: { getTerminalTurnEpoch: () => undefined, @@ -4648,6 +4652,9 @@ async function invocationHarness( cancelPendingPreflightForTerminalAbort: () => {}, abortPromptAndWaitWithTerminal: async () => ({ status: "settled", terminalScope: {} }), ...(interceptorStore ? { getReconciliationStore: () => interceptorStore } : {}), + ...(hooks.onPersistenceInstrumentation + ? { onPersistenceInstrumentationForTests: hooks.onPersistenceInstrumentation } + : {}), ...hooks.terminalAbortSeams, }, } @@ -6176,6 +6183,7 @@ describe("post-acceptance invocation terminalization", () => { let harness: InvocationHarness | undefined; let session: AgentSession | undefined; let authStorage: AuthStorage | undefined; + let restoreEmission: (() => void) | undefined; let providerCalls = 0; try { const real = await createTerminalizationSession( @@ -6186,6 +6194,7 @@ describe("post-acceptance invocation terminalization", () => { return createMockModel({ responses: [{ content: ["started"] }] }).stream(model, context, options); }, { "todo.enabled": true, "todo.reminders": true, "todo.reminders.max": 1, "retry.enabled": false }, + true, ); session = real.session; session.setConfiguredModelChain("default", [selector(real.model)], "terminal-throw-test"); @@ -6197,9 +6206,16 @@ describe("post-acceptance invocation terminalization", () => { isIdle: () => !session?.isStreaming, sendUserMessage: realSendUserMessage(session), }); - session.subscribe(async event => { - await harness?.emit(event.type, event); + const runner = session.extensionRunner; + if (!runner) throw new Error("Expected the real awaited terminal extension bridge."); + const emit = runner.emit.bind(runner); + const emission = spyOn(runner, "emit").mockImplementation(async event => { + const result = await emit(event); + if (event.type === "agent_start" || event.type === "agent_failed" || event.type === "agent_end") + await harness?.emit(event.type, event); + return result; }); + restoreEmission = () => emission.mockRestore(); const accepted = await harness.control("turn.prompt", { text: "finish the outstanding work" }); expect(accepted.ok).toBe(true); const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId }; @@ -6226,6 +6242,7 @@ describe("post-acceptance invocation terminalization", () => { outcome: { kind: "failed" }, }); } finally { + restoreEmission?.(); await session?.dispose(); authStorage?.close(); await harness?.stop(); @@ -8521,8 +8538,22 @@ describe("accepted-control zero-execution bound (#4668)", () => { const sessionId = "deadline-captured-uncertain"; const sessionFile = path.join(cwd, ".gjc", "state", `${sessionId}.jsonl`); await Bun.write(sessionFile, ""); - const store = createReconciliationStore({ sessionFile, sessionId }); + // Chain-level evidence for the CI-only hang: every serialized store op + // (including the terminal variants the runtime persistence log cannot see). + const storeLog: string[] = []; + const store = createReconciliationStore({ + sessionFile, + sessionId, + onInstrumentationForTests: event => { + storeLog.push( + `${event.at}:${event.phase}:${event.operation ?? ""}:${event.substep ?? ""}:${event.detail ?? ""}${event.error ? `:${event.error}` : ""}`, + ); + }, + }); const activeTools = new Set(["unfenced-tool"]); + const toolDrainObserved = Promise.withResolvers(); + const recoveryCheckpoints: SdkOnlyDeadlineRecoveryCheckpoint[] = []; + const persistenceLog: Array = []; let boundaryWaitStarted = false; let abortCalls = 0; let harness: InvocationHarness | undefined; @@ -8531,25 +8562,26 @@ describe("accepted-control zero-execution bound (#4668)", () => { settings: { // This test covers deferred terminal recovery, not deadline-triggered worktree persistence. get: (key: string) => - key === "sdk.promptDeadlineMs" - ? 150 - : key === "sdk.promptMaxRuntimeMs" - ? 60_000 - : key === "sdk.flushWorktreeOnDeadline" - ? false - : undefined, + key === "sdk.promptDeadlineMs" ? 150 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined, } as unknown as Settings, sendUserMessage: async (_content, options) => { await options?.onPreflightAcceptCommit?.(); await neverSettlingPromise(); }, + onPersistenceInstrumentation: event => { + const label = `${event.phase}:${event.operation ?? "main"}:${event.substep ?? ""}`; + persistenceLog.push({ ...event, label }); + }, terminalAbortSeams: { getReconciliationStore: () => store, getTerminalTurnEpoch: () => 109, getActivePromptHandle: () => "deadline-captured-uncertain-run", + onDeadlineRecoveryCheckpointForTests: checkpoint => recoveryCheckpoints.push(checkpoint), pendingToolExecutions: () => { if (activeTools.size > 0) boundaryWaitStarted = true; - return [...activeTools]; + const pending = [...activeTools]; + if (pending.length === 0) toolDrainObserved.resolve(); + return pending; }, abortPromptAndWaitWithTerminal: async () => { abortCalls += 1; @@ -8597,8 +8629,33 @@ describe("accepted-control zero-execution bound (#4668)", () => { }); activeTools.clear(); - // Keep this inside the 60s hard-runtime budget while tolerating a slow CI retry tick. - expect(await settledStatus(harness, "turn.prompt_status", correlation, 30_000)).toMatchObject({ + // Wait until the deadline recovery loop has actually observed the exact + // tool-set drain; clearing the test seam alone does not wake its bounded + // retry timer. + await toolDrainObserved.promise; + let terminalStatus: NonNullable; + try { + terminalStatus = await settledStatus(harness, "turn.prompt_status", correlation); + } catch (error) { + const finalRecord = store.snapshot().find(record => record.commandId === correlation.commandId) as + | (SdkOnlyInvocationRecord & { + pendingOutcome?: unknown; + deadlineRecoveryPending?: boolean; + terminalAt?: number; + }) + | undefined; + throw new Error( + `${error instanceof Error ? error.message : String(error)}; persistence_log=${JSON.stringify(persistenceLog)}; store_log=${JSON.stringify(storeLog)}; deadline checkpoints=${JSON.stringify(recoveryCheckpoints)}; durable=${JSON.stringify( + { + status: finalRecord?.status, + terminalAt: finalRecord?.terminalAt, + deadlineRecoveryPending: finalRecord?.deadlineRecoveryPending, + pendingOutcome: finalRecord?.pendingOutcome, + }, + )}`, + ); + } + expect(terminalStatus).toMatchObject({ status: "terminal_ok", outcome: { kind: "stopped", reason: "cancelled" }, }); @@ -8618,6 +8675,7 @@ describe("accepted-control zero-execution bound (#4668)", () => { const sessionFile = path.join(cwd, "session.json"); await Bun.write(sessionFile, ""); const store = createReconciliationStore({ sessionFile, sessionId }); + const deadlineStarted = Promise.withResolvers(); let abortCalls = 0; let harness: InvocationHarness | undefined; try { @@ -8633,7 +8691,10 @@ describe("accepted-control zero-execution bound (#4668)", () => { terminalAbortSeams: { getReconciliationStore: () => store, getTerminalTurnEpoch: () => 17, - getActivePromptHandle: () => "unobservable-tool-run", + getActivePromptHandle: () => { + deadlineStarted.resolve(); + return "unobservable-tool-run"; + }, abortPromptAndWaitWithTerminal: async () => { abortCalls += 1; return { status: "settled" }; @@ -8647,7 +8708,10 @@ describe("accepted-control zero-execution bound (#4668)", () => { await harness.emit("agent_failed", { error: Object.assign(new Error("provider unavailable"), { code: "provider_unavailable" }), }); - await Bun.sleep(150); + // Expiry must install its recoverable observation before the real end + // arrives; otherwise an end racing the durable uncertainty write can + // terminalize from the diagnostic alone. + await deadlineStarted.promise; expect(abortCalls).toBe(0); expect(await harness.query("turn.prompt_status", correlation)).toMatchObject({ result: { status: "in_flight" }, @@ -8655,7 +8719,6 @@ describe("accepted-control zero-execution bound (#4668)", () => { expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_failed")).toHaveLength(1); expect(correlatedFrames(harness, correlation).filter(frame => frame.kind === "agent_end")).toEqual([]); await harness.emit("agent_end", { stopReason: "cancelled" }); - await Bun.sleep(100); expect(await harness.query("turn.prompt_status", correlation)).toMatchObject({ result: { status: "in_flight" }, }); diff --git a/packages/coding-agent/src/sdk/host/session-runtime.ts b/packages/coding-agent/src/sdk/host/session-runtime.ts index 51b0a5362e0..dcc754b7787 100644 --- a/packages/coding-agent/src/sdk/host/session-runtime.ts +++ b/packages/coding-agent/src/sdk/host/session-runtime.ts @@ -103,6 +103,7 @@ import { createKindAwareReconciliation, createReconciliationStore, type KindAwareReconciliation, + type ReconciliationStoreInstrumentationEvent, resolveReconciliationSessionFile, } from "../reconciliation-extensions"; import { @@ -445,6 +446,8 @@ export interface SdkOnlyTerminalAbortSeams { getReconciliationStore?: () => SdkOnlyReconciliationStore | undefined; getTerminalTurnEpoch: () => number | undefined; getActivePromptHandle: () => string | undefined; + onDeadlineRecoveryCheckpointForTests?: (checkpoint: SdkOnlyDeadlineRecoveryCheckpoint) => void; + onPersistenceInstrumentationForTests?: (event: PersistenceInstrumentationEvent) => void; /** Re-read the active prompt's owning SDK connection for the owner-mismatch * recheck; falls back to the runtime-tracked owner when absent (review * thread P1). */ @@ -481,6 +484,34 @@ export interface SdkOnlyTerminalAbortSeams { maxDurableTerminalReservationsForTests?: number; } +export interface SdkOnlyDeadlineRecoveryCheckpoint { + at: number; + stage: + | "terminalization-start" + | "terminalization-result" + | "publication-start" + | "publication-result" + | "note-transition-start" + | "note-transition-awaiting-finalization" + | "note-transition-finalization-ready" + | "note-transition-before-persist" + | "note-transition-persisted" + | "note-transition-persist-failed"; + correlation: InvocationCorrelation; + result?: "settled" | "uncertain" | "published" | "not-published" | "rejected"; + reason?: string; + recordStatus?: string; + recordRevision?: number; + recordTerminalAt?: number; + pendingFinalization?: boolean; + eventCaptured?: boolean; + eventPrepared?: boolean; + terminalCommitted?: boolean; + leaseCurrent?: boolean; + pendingToolCount?: number; + errorCode?: string; +} + /** * The transport-neutral SDK session runtime. * @@ -1024,8 +1055,26 @@ function canonicalizeHydratedDiagnostics(record: InvocationRecord): InvocationRe return canonical; } +export interface PersistenceInstrumentationEvent { + at: number; + phase: "enqueued" | "started" | "completed" | "failed"; + operation?: string; + substep?: string; + error?: string; + /** Detailed information about lock acquisition, promise identity, db state, etc. */ + detail?: string; + /** Time spent waiting (ms) before the operation started. */ + waitMs?: number; +} + export function createInvocationReconciliation( - options: { stateRoot?: string; sessionId?: string; store?: SdkOnlyReconciliationStore } = {}, + options: { + stateRoot?: string; + sessionId?: string; + store?: SdkOnlyReconciliationStore; + onDeadlineRecoveryCheckpointForTests?: (checkpoint: SdkOnlyDeadlineRecoveryCheckpoint) => void; + onPersistenceInstrumentationForTests?: (event: PersistenceInstrumentationEvent) => void; + } = {}, ): InvocationReconciliation { const ACTIVE_CAPACITY = 256; const TERMINAL_CAPACITY = 512; @@ -1063,6 +1112,17 @@ export function createInvocationReconciliation( } >(); const pendingTerminalVisibility = new Map(); + const recordDeadlineRecoveryCheckpoint = ( + stage: SdkOnlyDeadlineRecoveryCheckpoint["stage"], + correlation: InvocationCorrelation, + details: Omit = {}, + ): void => { + try { + options.onDeadlineRecoveryCheckpointForTests?.({ at: Date.now(), stage, correlation, ...details }); + } catch { + // A test observer must never participate in durable reconciliation. + } + }; const retainPendingTerminalVisibility = (recordKey: string, visibleRecord: InvocationRecord): (() => void) => { const existing = pendingTerminalVisibility.get(recordKey); if (existing) existing.writes += 1; @@ -1079,36 +1139,120 @@ export function createInvocationReconciliation( }; const persist = async (): Promise => { const run = async (): Promise => { + try { + options.onPersistenceInstrumentationForTests?.({ at: Date.now(), phase: "enqueued" }); + } catch { + // A test observer must never participate in durable reconciliation. + } + try { + options.onPersistenceInstrumentationForTests?.({ at: Date.now(), phase: "started" }); + } catch { + // Ignore observer errors. + } // Construct the candidate only when this serialized write starts. A // pre-await full snapshot lets a later agent_start/agent_end transition // be overwritten on disk by an older queued write even though the live // map has already converged. const snapshot = [...records.values()].map(record => ({ ...record })); if (store) { - await store.transact(current => [ - ...current.filter(record => record.kind !== "prompt" && record.kind !== "skill"), - ...snapshot.map(record => ({ ...record })), - ]); + const promiseId = `transact-${crypto.randomUUID().slice(0, 8)}`; + const lockWaitStart = Date.now(); + try { + options.onPersistenceInstrumentationForTests?.({ + at: lockWaitStart, + phase: "started", + operation: "store.transact", + detail: `promise=${promiseId} awaiting-lock`, + }); + } catch {} + const lockAcquiredTime = Date.now(); + try { + await store.transact(current => [ + ...current.filter(record => record.kind !== "prompt" && record.kind !== "skill"), + ...snapshot.map(record => ({ ...record })), + ]); + } catch (error) { + try { + options.onPersistenceInstrumentationForTests?.({ + at: Date.now(), + phase: "failed", + operation: "store.transact", + detail: `promise=${promiseId} lock-wait=${Date.now() - lockWaitStart}ms`, + error: error instanceof Error ? error.message : String(error), + }); + } catch {} + throw error; + } + try { + options.onPersistenceInstrumentationForTests?.({ + at: Date.now(), + phase: "completed", + operation: "store.transact", + detail: `promise=${promiseId} lock-acquire=${lockAcquiredTime - lockWaitStart}ms`, + }); + } catch {} return; } if (!reconciliationFile) return; const directory = path.dirname(reconciliationFile); const temporary = `${reconciliationFile}.${process.pid}.${crypto.randomUUID()}.tmp`; + try { + options.onPersistenceInstrumentationForTests?.({ at: Date.now(), phase: "started", substep: "mkdir" }); + } catch {} await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + try { + options.onPersistenceInstrumentationForTests?.({ at: Date.now(), phase: "completed", substep: "mkdir" }); + } catch {} + try { + options.onPersistenceInstrumentationForTests?.({ at: Date.now(), phase: "started", substep: "writeFile" }); + } catch {} await fs.writeFile( temporary, JSON.stringify({ version: 1, sessionId: options.sessionId, records: [...snapshot] }), { encoding: "utf8", mode: 0o600 }, ); + try { + options.onPersistenceInstrumentationForTests?.({ + at: Date.now(), + phase: "completed", + substep: "writeFile", + }); + } catch {} + try { + options.onPersistenceInstrumentationForTests?.({ at: Date.now(), phase: "started", substep: "chmod" }); + } catch {} await fs.chmod(temporary, 0o600); + try { + options.onPersistenceInstrumentationForTests?.({ at: Date.now(), phase: "completed", substep: "chmod" }); + } catch {} + try { + options.onPersistenceInstrumentationForTests?.({ at: Date.now(), phase: "started", substep: "rename" }); + } catch {} await fs.rename(temporary, reconciliationFile); + try { + options.onPersistenceInstrumentationForTests?.({ at: Date.now(), phase: "completed", substep: "rename" }); + } catch {} }; const pending = persistenceChain.then(run, run); persistenceChain = pending.then( () => undefined, () => undefined, ); - await pending; + try { + await pending; + try { + options.onPersistenceInstrumentationForTests?.({ at: Date.now(), phase: "completed" }); + } catch {} + } catch (error) { + try { + options.onPersistenceInstrumentationForTests?.({ + at: Date.now(), + phase: "failed", + error: error instanceof Error ? error.message : String(error), + }); + } catch {} + throw error; + } }; // Retention contract (#4547): terminal records are never age-evicted; only // the per-kind oldest-terminal-first capacity trim removes them, so a @@ -1257,7 +1401,22 @@ export function createInvocationReconciliation( let record = records.get(recordKey); if (!record) return; const pending = pendingFinalizations.get(recordKey); + const isPromptEnd = kind === "prompt" && frame.type === "agent_end"; + if (isPromptEnd) + recordDeadlineRecoveryCheckpoint("note-transition-start", correlation, { + recordStatus: record.status, + recordRevision: record.revision, + recordTerminalAt: record.terminalAt, + pendingFinalization: pending?.finalizedRecord === record, + }); if (pending?.finalizedRecord === record) { + if (isPromptEnd) + recordDeadlineRecoveryCheckpoint("note-transition-awaiting-finalization", correlation, { + recordStatus: record.status, + recordRevision: record.revision, + recordTerminalAt: record.terminalAt, + pendingFinalization: true, + }); const upgrade = Promise.withResolvers(); pending.upgrades.add(upgrade); let upgradeError: unknown; @@ -1269,6 +1428,13 @@ export function createInvocationReconciliation( // so a real lifecycle event is never swallowed. } const current = records.get(recordKey); + if (isPromptEnd) + recordDeadlineRecoveryCheckpoint("note-transition-finalization-ready", correlation, { + recordStatus: current?.status, + recordRevision: current?.revision, + recordTerminalAt: current?.terminalAt, + pendingFinalization: pendingFinalizations.get(recordKey) === pending, + }); const incomingOutcome = frame.type === "agent_end" ? canonicalTerminalOutcome(frame.outcome) : undefined; const providerFailure = current?.error !== undefined && current.error.code !== "prompt_deadline_exceeded"; @@ -1650,10 +1816,31 @@ export function createInvocationReconciliation( record.terminalAt === undefined && next.terminalAt !== undefined ? retainPendingTerminalVisibility(recordKey, record) : undefined; + if (isPromptEnd) + recordDeadlineRecoveryCheckpoint("note-transition-before-persist", correlation, { + recordStatus: next.status, + recordRevision: next.revision, + recordTerminalAt: next.terminalAt, + pendingFinalization: pendingFinalizations.has(recordKey), + }); records.set(recordKey, next); try { await persist(); + if (isPromptEnd) + recordDeadlineRecoveryCheckpoint("note-transition-persisted", correlation, { + recordStatus: next.status, + recordRevision: next.revision, + recordTerminalAt: next.terminalAt, + pendingFinalization: pendingFinalizations.has(recordKey), + }); } catch (error) { + if (isPromptEnd) + recordDeadlineRecoveryCheckpoint("note-transition-persist-failed", correlation, { + recordStatus: next.status, + recordRevision: next.revision, + recordTerminalAt: next.terminalAt, + pendingFinalization: pendingFinalizations.has(recordKey), + }); if (records.get(recordKey) === next) records.set(recordKey, record); throw error; } finally { @@ -6419,16 +6606,38 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre (typeof ctx.sessionManager.getSessionFile === "function" ? ctx.sessionManager.getSessionFile() : undefined) ?? resolveReconciliationSessionFile(undefined, stateRoot, sessionId); const sessionIdentity = `${sessionId}\u0000${sessionFile}`; + const reconciliationInstrumentation = options.terminalAbortSeams?.onPersistenceInstrumentationForTests + ? (event: ReconciliationStoreInstrumentationEvent) => { + try { + options.terminalAbortSeams?.onPersistenceInstrumentationForTests?.(event as never); + } catch {} + } + : undefined; const reconciliationStore = options.terminalAbortSeams?.getReconciliationStore?.() ?? - createReconciliationStore({ sessionFile, sessionId }); - const reconciliation = createInvocationReconciliation({ store: reconciliationStore }); + createReconciliationStore({ + sessionFile, + sessionId, + onInstrumentationForTests: reconciliationInstrumentation, + }); + const reconciliation = createInvocationReconciliation({ + store: reconciliationStore, + onDeadlineRecoveryCheckpointForTests: options.terminalAbortSeams?.onDeadlineRecoveryCheckpointForTests, + onPersistenceInstrumentationForTests: options.terminalAbortSeams?.onPersistenceInstrumentationForTests, + }); await reconciliation.hydrate(); const steerReconciliation = createKindAwareReconciliation({ store: reconciliationStore as never, ownedKinds: ["steer"], }); await steerReconciliation.hydrateFromStore(); + const recordDeadlineRecoveryCheckpoint = (checkpoint: Omit): void => { + try { + options.terminalAbortSeams?.onDeadlineRecoveryCheckpointForTests?.({ ...checkpoint, at: Date.now() }); + } catch { + // A test observer must never participate in deadline correctness. + } + }; const deadlineManager = new PromptDeadlineManager({ reconciliation, getLeaseMs: () => resolveSdkPromptDeadlineMs(options.settings?.get("sdk.promptDeadlineMs" as never)), @@ -6448,6 +6657,9 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre const seams = options.terminalAbortSeams; const handle = seams?.getActivePromptHandle(); const epoch = seams?.getTerminalTurnEpoch(); + // Capture lifecycle ownership independently of settlement proof. Without + // a tool-set observer the later terminalization hook still fails closed, + // but a matching real end must remain private and recoverable meanwhile. if (!seams || !handle || epoch === undefined) return; const existing = deadlineTerminalizationObservations.get(key); if (existing) return () => cleanupDeadlineTerminalizationObservation(key, existing); @@ -6495,8 +6707,36 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre const observation = deadlineTerminalizationObservations.get(target); const seams = options.terminalAbortSeams; const pendingToolExecutions = seams?.pendingToolExecutions; - if (!observation || !seams || !pendingToolExecutions) return "uncertain"; - const pendingTools = () => pendingToolExecutions(observation.handle); + recordDeadlineRecoveryCheckpoint({ + stage: "terminalization-start", + correlation, + eventCaptured: observation?.eventCaptured, + eventPrepared: observation?.eventPrepared, + terminalCommitted: observation?.terminalCommitted, + }); + let pendingToolCount: number | undefined; + const finish = (result: PromptDeadlineTerminalization, reason: string): PromptDeadlineTerminalization => { + recordDeadlineRecoveryCheckpoint({ + stage: "terminalization-result", + correlation, + result, + reason, + eventCaptured: observation?.eventCaptured, + eventPrepared: observation?.eventPrepared, + terminalCommitted: observation?.terminalCommitted, + pendingToolCount, + }); + // Notify deadline manager about termination result to adjust retry strategy + deadlineManager.notifyTerminationResult(correlation, result, reason); + return result; + }; + if (!observation || !seams || !pendingToolExecutions) + return finish("uncertain", "missing-observation-or-tool-observer"); + const pendingTools = () => { + const pending = pendingToolExecutions(observation.handle); + pendingToolCount = pending.length; + return pending; + }; const awaitEventPreparation = async (): Promise => { if (observation.eventPrepared) return true; const captured = await Promise.race([ @@ -6507,7 +6747,15 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre }; if (observation.eventCaptured) { const prepared = await awaitEventPreparation(); - return prepared && pendingTools().length === 0 ? "settled" : "uncertain"; + pendingToolCount = pendingTools().length; + return finish( + prepared && pendingToolCount === 0 ? "settled" : "uncertain", + !prepared + ? "captured-event-not-prepared" + : pendingToolCount > 0 + ? "tools-still-pending" + : "captured-event-and-tools-settled", + ); } if (!observation.eventCaptured) { @@ -6521,13 +6769,13 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre entry => lifecycleCorrelationKey(entry.correlation) === target, ) ) - return "uncertain"; + return finish("uncertain", "lifecycle-batch-no-longer-owns-correlation"); if ( !isCurrent() || seams.getActivePromptHandle() !== observation.handle || seams.getTerminalTurnEpoch() !== observation.epoch ) - return "uncertain"; + return finish("uncertain", "lease-or-active-run-mismatch"); } await waitForToolCallBoundary({ pending: pendingTools, @@ -6536,14 +6784,22 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre }); if (observation.eventCaptured) { const prepared = await awaitEventPreparation(); - return prepared && pendingTools().length === 0 ? "settled" : "uncertain"; + pendingToolCount = pendingTools().length; + return finish( + prepared && pendingToolCount === 0 ? "settled" : "uncertain", + !prepared + ? "captured-event-not-prepared" + : pendingToolCount > 0 + ? "tools-still-pending" + : "captured-event-and-tools-settled", + ); } if ( !isCurrent() || seams.getActivePromptHandle() !== observation.handle || seams.getTerminalTurnEpoch() !== observation.epoch ) - return "uncertain"; + return finish("uncertain", "lease-or-active-run-mismatch-after-tool-boundary"); let steeringSnapshotToken: number | undefined; let proof: { status: string; terminalScope?: unknown } | undefined; @@ -6560,7 +6816,15 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre }); if (proof.status !== "settled") { const prepared = await awaitEventPreparation(); - return prepared && pendingTools().length === 0 ? "settled" : "uncertain"; + pendingToolCount = pendingTools().length; + return finish( + prepared && pendingToolCount === 0 ? "settled" : "uncertain", + !prepared + ? "abort-proof-not-settled-and-event-not-prepared" + : pendingToolCount > 0 + ? "tools-still-pending" + : "captured-event-and-tools-settled", + ); } const terminalScope = proof.terminalScope as | { abortedAttemptEpoch?: unknown; lineageIdHash?: unknown } @@ -6570,17 +6834,26 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre typeof terminalScope.lineageIdHash !== "string" || isOwnedAttemptRegistrationIncomplete(terminalScope.lineageIdHash, observation.epoch) ) - return "uncertain"; + return finish("uncertain", "terminal-scope-mismatch"); const exactJobs = findOwnedRegistrationsForTurn(terminalScope.lineageIdHash, observation.epoch); if (exactJobs.length > 0) { const endpointId = exactJobs[0]?.endpointId; const manager = AsyncJobManager.forEndpoint(endpointId) ?? AsyncJobManager.instance(); - if (!manager || (await settleOwnedWork(manager, exactJobs, 500)) !== "stopped") return "uncertain"; + if (!manager || (await settleOwnedWork(manager, exactJobs, 500)) !== "stopped") + return finish("uncertain", "owned-work-not-stopped"); } const prepared = await awaitEventPreparation(); - return prepared && pendingTools().length === 0 ? "settled" : "uncertain"; + pendingToolCount = pendingTools().length; + return finish( + prepared && pendingToolCount === 0 ? "settled" : "uncertain", + !prepared + ? "captured-event-not-prepared" + : pendingToolCount > 0 + ? "tools-still-pending" + : "captured-event-and-tools-settled", + ); } catch { - return "uncertain"; + return finish("uncertain", "terminal-abort-error"); } finally { if (steeringSnapshotToken !== undefined && proof?.status !== "settled") seams.discardTerminalAbortSteeringSnapshot?.(steeringSnapshotToken); @@ -6592,21 +6865,57 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre const terminalOutcome = observation ? deadlineOutcomeAfterAbort(correlation, observation.terminalOutcome, observation.terminalHasActivity) : undefined; - if (!observation?.eventCaptured || !observation.eventPrepared || terminalOutcome === undefined) + let terminalIsCurrent: boolean | undefined; + let pendingToolCount = -1; + const recordPublicationResult = ( + result: SdkOnlyDeadlineRecoveryCheckpoint["result"], + reason: string, + errorCodeValue?: string, + ): void => { + recordDeadlineRecoveryCheckpoint({ + stage: "publication-result", + correlation, + result, + reason, + eventCaptured: observation?.eventCaptured, + eventPrepared: observation?.eventPrepared, + terminalCommitted: observation?.terminalCommitted, + leaseCurrent: terminalIsCurrent, + pendingToolCount, + errorCode: errorCodeValue, + }); + }; + recordDeadlineRecoveryCheckpoint({ + stage: "publication-start", + correlation, + eventCaptured: observation?.eventCaptured, + eventPrepared: observation?.eventPrepared, + terminalCommitted: observation?.terminalCommitted, + }); + if (!observation?.eventCaptured || !observation.eventPrepared || terminalOutcome === undefined) { + recordPublicationResult("rejected", "missing-captured-terminal-outcome"); return false; + } if (!observation.terminalCommitted) { const pendingTools = options.terminalAbortSeams?.pendingToolExecutions; - let terminalIsCurrent = false; - let pendingToolCount = -1; try { terminalIsCurrent = isCurrent(); if (pendingTools) pendingToolCount = pendingTools(observation.handle).length; - } catch { + } catch (error) { + recordPublicationResult("rejected", "publication-precheck-threw", errorCode(error)); + return false; + } + if (!terminalIsCurrent || !pendingTools || pendingToolCount > 0) { + recordPublicationResult("rejected", "publication-precheck-rejected"); return false; } - if (!terminalIsCurrent || !pendingTools || pendingToolCount > 0) return false; try { - if (!isCurrent() || pendingTools(observation.handle).length > 0) return false; + terminalIsCurrent = isCurrent(); + pendingToolCount = pendingTools(observation.handle).length; + if (!terminalIsCurrent || pendingToolCount > 0) { + recordPublicationResult("rejected", "publication-recheck-rejected"); + return false; + } // Commit the authoritative Q26 result before exposing its terminal // boundary; a crash between publication and this write would strand // restart recovery with only a hidden pending claim. @@ -6619,11 +6928,13 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre observation.terminalOutcome = terminalOutcome; observation.terminalCommitted = true; observation.clearUnrecordedFailure?.(); - } catch { + } catch (error) { + recordPublicationResult("rejected", "terminal-transition-persist-failed", errorCode(error)); return false; } } const published = publishDeadlineTerminalBoundary(observation, correlation, terminalOutcome); + recordPublicationResult(published ? "published" : "not-published", "terminal-boundary-result"); return { outcome: terminalOutcome, published } satisfies PromptDeadlinePublicationResult; }, // Persist the agent's uncommitted work before the retirement below tears diff --git a/packages/coding-agent/src/sdk/prompt-deadline-manager.ts b/packages/coding-agent/src/sdk/prompt-deadline-manager.ts index e410c5fe7e0..7db84045e6c 100644 --- a/packages/coding-agent/src/sdk/prompt-deadline-manager.ts +++ b/packages/coding-agent/src/sdk/prompt-deadline-manager.ts @@ -107,6 +107,7 @@ export class PromptDeadlineManager { readonly #deadlineDeferredTerminalTransitions = new Set(); readonly #deadlineTerminalizationConfirmed = new Set(); readonly #terminalPublicationPending = new Map(); + readonly #toolsStillPendingOnLastCheck = new Set(); readonly #deadlineStartCleanup = new Map void>(); readonly #pendingTerminalFailureReasons = new Map< string, @@ -578,13 +579,32 @@ export class PromptDeadlineManager { this.clear(correlation); } - #scheduleTerminalPublicationRetry(key: string): void { + #scheduleTerminalPublicationRetry(key: string, delayMs?: number): void { this.#clearTimer(key); - const timer = setTimeout(() => void this.#onDeadline(key), UNCERTAINTY_RETRY_DELAY_MS); + // Use immediate retry (0ms) if tools were pending on last check; + // otherwise use normal retry delay. This allows quick detection when tools drain. + const effectiveDelayMs = + delayMs ?? (this.#toolsStillPendingOnLastCheck.has(key) ? 0 : UNCERTAINTY_RETRY_DELAY_MS); + const timer = setTimeout(() => void this.#onDeadline(key), effectiveDelayMs); (timer as unknown as { unref?: () => void }).unref?.(); this.#timers.set(key, timer); } + /** Notify about termination check results to adjust retry strategy. */ + notifyTerminationResult( + correlation: InvocationCorrelation, + _result: PromptDeadlineTerminalization, + reason: string, + ): void { + const key = leaseKey(correlation); + if (reason.includes("tools-still-pending")) { + this.#toolsStillPendingOnLastCheck.add(key); + } else { + // Tools have settled or are no longer relevant + this.#toolsStillPendingOnLastCheck.delete(key); + } + } + #retry(key: string): void { const attempts = (this.#expiryRetries.get(key) ?? 0) + 1; this.#expiryRetries.set(key, attempts); @@ -817,6 +837,7 @@ export class PromptDeadlineManager { this.#terminalPublicationPending.delete(key); this.#pendingTerminalFailureReasons.delete(key); this.#pendingTerminalEvidence.delete(key); + this.#toolsStillPendingOnLastCheck.delete(key); } clearAll(): void { @@ -839,6 +860,7 @@ export class PromptDeadlineManager { this.#terminalPublicationPending.clear(); this.#pendingTerminalFailureReasons.clear(); this.#pendingTerminalEvidence.clear(); + this.#toolsStillPendingOnLastCheck.clear(); } /** For tests: current deadline or undefined if no lease. */ diff --git a/packages/coding-agent/src/sdk/reconciliation-extensions.ts b/packages/coding-agent/src/sdk/reconciliation-extensions.ts index f569627cdae..e4514ce5aa8 100644 --- a/packages/coding-agent/src/sdk/reconciliation-extensions.ts +++ b/packages/coding-agent/src/sdk/reconciliation-extensions.ts @@ -10,5 +10,6 @@ export { type DurableReconciliationRecord, type DurableSteerReconciliationRecord, type ReconciliationStore, + type ReconciliationStoreInstrumentationEvent, resolveReconciliationSessionFile, } from "./bus/reconciliation-store"; diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 8ac22b7959c..d82eb748f27 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -1739,9 +1739,14 @@ export async function runSubprocessOnce(options: ExecutorOptions): Promise { + resumeRunner = (_subagentId, message, resumeDescriptor, resumeToolCallId, admissionEndpointId) => { const descriptor = isTaskResumeDescriptor(resumeDescriptor?.data) ? resumeDescriptor.data : undefined; if (!descriptor) return undefined; const admission = (() => { @@ -1208,7 +1208,7 @@ export class TaskTool implements AgentTool string | null; /** Register cleanup with the current logical session lifecycle. */ - registerSessionCleanup: (cleanup: () => Promise | void) => (() => void) | void; + registerSessionCleanup: (cleanup: () => Promise | void) => (() => void) | undefined; /** Reject execution after the owning session has begun disposal. */ assertEvalExecutionAllowed?: () => void; /** Track this whole invocation through its transcript append. */ diff --git a/packages/coding-agent/src/tools/tool-catalog.generated.ts b/packages/coding-agent/src/tools/tool-catalog.generated.ts index a85bc36b1d8..aa73f860212 100644 --- a/packages/coding-agent/src/tools/tool-catalog.generated.ts +++ b/packages/coding-agent/src/tools/tool-catalog.generated.ts @@ -27,7 +27,7 @@ export const TOOL_CATALOG: Readonly> = { "read": { "name": "read", "label": "Read", - "description": "Read files, directories, archives, SQLite databases, images, documents, internal resources, and web URLs through a single `path` string.\n\n\n- One tool for filesystem, archives, SQLite, images, documents (PDF/DOCX/PPTX/XLSX/RTF/EPUB/ipynb), internal URIs, and web URLs (reader-mode by default).\n- You SHOULD parallelize independent reads when exploring related files.\n- You SHOULD reach for `read` — not a browser/puppeteer tool — for fetching web content.\n\n\n## Parameters\n\n- `path` — required. Local path, internal URI (`agent://`, `artifact://`, `rule://`, `local://`), or URL. Append `:` for line ranges, raw mode, or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).\n- `truncation` — optional `head` | `last` | `both`; selects which end of an over-budget result to retain. Configured default: last (factory default: `last`); structural summaries and routes such as URLs, directories and converted documents default to `head`. A line-range selector still bounds the selection — this only picks which end of that selection survives the byte/line cap. SQLite row queries page via their own `limit`/`offset` and ignore it.\n## Selectors\nAppend `:` to `path`. The bare path falls back to the default mode.\n\n- _(none)_ — parseable code → structural summary (signatures kept, bodies elided); a plain text file → a bounded receipt of about 50 lines or 10 KiB, whichever is smaller; the configured truncation direction is last (factory default: last). Line+hash anchors keep their real file line numbers and a footer names the omitted range. Archive members use the larger 3000-line / 50 KiB budget. Converted documents, notebooks, URLs and directory listings still start from the beginning.\n- `:50` / `:50-` — read from line 50 onward.\n- `:50-200` — lines 50–200 inclusive.\n- `:50+150` — 150 lines starting at line 50.\n- `:20+1` — exactly one line.\n- `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged).\n- `:raw` — verbatim text; no anchors, no summary, no line prefixes.\n- `:2-4:raw` or `:raw:2-4` — range AND verbatim; the two compose in either order.\n- `:conflicts` — one-line-per-block index of every unresolved git merge conflict.\n\n# Files\n\n- Reading a directory path returns a depth-limited dirent listing.\n- Parseable code without a selector returns a **structural summary**: declarations kept, large bodies collapsed to `..` (merged brace pair) or `…` (standalone). Summarized output ends with a footer of the form:\n\n `[NN lines across MM elided regions; read :raw or a line range like :1-9999 for verbatim content]`\n\n If the elided body is what you actually need, re-issue the **exact selector the footer names**. NEVER guess what's inside `..` / `…` — those markers carry no content.\n- Structural summaries honor explicit `truncation`: `head` retains the beginning, `last` the end, and `both` both ends within the summary byte budget. Complete summary units and their original source anchors stay intact and in source order; budget omissions are marked where they occur. This does not switch to raw source. Summaries that fit are unchanged by direction.\n- Directional windows identify the retained first/last lines and the omitted range; use the `re-read :1-` or `:raw` hint in the footer to recover the full content.\n\n# Documents & Notebooks\n\nExtracts text from PDF, Word, PowerPoint, Excel, RTF, and EPUB. Notebooks (`.ipynb`) are shown as editable `# %% [type] cell:N` text; edits round-trip back to the underlying JSON preserving notebook metadata. Add `:raw` to a notebook to bypass the converter and read the JSON directly.\n\n# Images\n\nReading an image path returns the image itself for visual inspection by a vision-capable model.\n\n# Archives\n\nSupports `.tar`, `.tar.gz`, `.tgz`, `.zip`. Use `archive.ext:path/inside/archive` to read a member, and append a normal selector to the inner path: `archive.zip:dir/file.ts:50-60`.\n\n# SQLite\n\nFor `.sqlite`, `.sqlite3`, `.db`, `.db3`:\n- `file.db` — list tables with row counts\n- `file.db:table` — schema + sample rows\n- `file.db:table:key` — single row by primary key\n- `file.db:table?limit=50&offset=100` — paginated rows\n- `file.db:table?where=status='active'&order=created:desc` — filtered rows\n- `file.db?q=SELECT …` — read-only SELECT query\n\n# URLs\n\n- Default reader-mode: HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs → clean text/markdown.\n- `:raw` returns untouched HTML; line selectors (`:50`, `:50-100`, `:50+150`) paginate the cached fetched output.\n- Bare `host:port` URLs collide with the selector grammar — add a trailing slash before the selector: `https://example.com/:80`.\n\n# Internal URIs\n\n`agent://`, `artifact://`, `rule://`, and `local://.md` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://` to recover full output that a previous bash/eval/tool result spilled or truncated.\n\nBundled skills have no filesystem home, so the skill tool and skill discovery report them as `embedded:gjc/skills//SKILL.md`; read that identifier verbatim. Bundled skill fragments, when surfaced, likewise use `embedded:gjc/skill-fragments/...` identifiers.\n\n\n- Always include `path`; never call `read` with `{}`.\n- For line ranges, append the selector to `path`.\n- Re-issue the selector named by a summary footer before relying on elided content.\n", + "description": "Read files, directories, archives, SQLite databases, images, documents, internal resources, and web URLs through a single `path` string.\n\n\n- One tool for filesystem, archives, SQLite, images, documents (PDF/DOCX/PPTX/XLSX/RTF/EPUB/ipynb), internal URIs, and web URLs (reader-mode by default).\n- You SHOULD parallelize independent reads when exploring related files.\n- You SHOULD reach for `read` — not a browser/puppeteer tool — for fetching web content.\n\n\n## Parameters\n\n- `path` — required. Local path, internal URI (`agent://`, `artifact://`, `rule://`, `local://`), or URL. Append `:` for line ranges, raw mode, or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).\n- `truncation` — optional `head` | `last` | `both`; selects which end of an over-budget result to retain. Configured default: last (factory default: `last`); structural summaries and routes such as URLs, directories and converted documents default to `head`. A line-range selector still bounds the selection — this only picks which end of that selection survives the byte/line cap. SQLite row queries page via their own `limit`/`offset` and ignore it.\n## Selectors\nAppend `:` to `path`. The bare path falls back to the default mode.\n\n- _(none)_ — parseable code → structural summary (signatures kept, bodies elided); a plain text file → a bounded receipt of about 50 lines or 10 KiB, whichever is smaller; the configured truncation direction is last (factory default: last). Line+hash anchors keep their real file line numbers and a footer names the omitted range. Archive members use the larger 3000-line / 50 KiB budget. Converted documents, notebooks, URLs and directory listings still start from the beginning.\n- `:50` / `:50-` — read from line 50 onward.\n- `:50-200` — lines 50–200 inclusive.\n- `:50+150` — 150 lines starting at line 50.\n- `:20+1` — exactly one line.\n- `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged).\n- `:raw` — verbatim text; no anchors, no summary, no line prefixes.\n- `:2-4:raw` or `:raw:2-4` — range AND verbatim; the two compose in either order.\n- `:conflicts` — one-line-per-block index of every unresolved git merge conflict.\n\n# Files\n\n- Reading a directory path returns a depth-limited dirent listing.\n- Parseable code without a selector returns a **structural summary**: declarations kept, large bodies collapsed to `..` (merged brace pair) or `…` (standalone). Summarized output ends with a footer of the form:\n\n `[NN lines across MM elided regions; read :raw or a line range like :1-9999 for verbatim content]`\n\n If the elided body is what you actually need, re-issue the **exact selector the footer names**. NEVER guess what's inside `..` / `…` — those markers carry no content.\n- Structural summaries honor explicit `truncation`: `head` retains the beginning, `last` the end, and `both` both ends within the summary byte budget. Complete summary units and their original source anchors stay intact and in source order; budget omissions are marked where they occur. This does not switch to raw source. Summaries that fit are unchanged by direction.\n- Directional windows identify the retained first/last lines and the omitted range; use the `re-read :1-` or `:raw` hint in the footer to recover the full content.\n\n# Documents & Notebooks\n\nExtracts text from PDF, Word, PowerPoint, Excel, RTF, and EPUB. Notebooks (`.ipynb`) are shown as editable `# %% [type] cell:N` text; edits round-trip back to the underlying JSON preserving notebook metadata. Add `:raw` to a notebook to bypass the converter and read the JSON directly.\n\n# Images\n\nReading an image path returns the image itself for visual inspection by a vision-capable model.\n\n# Archives\n\nSupports `.tar`, `.tar.gz`, `.tgz`, `.zip`. Use `archive.ext:path/inside/archive` to read a member, and append a normal selector to the inner path: `archive.zip:dir/file.ts:50-60`.\n\n# SQLite\n\nFor `.sqlite`, `.sqlite3`, `.db`, `.db3`:\n- `file.db` — list tables with row counts\n- `file.db:table` — schema + sample rows\n- `file.db:table:key` — single row by primary key\n- `file.db:table?limit=50&offset=100` — paginated rows\n- `file.db:table?where=status='active'&order=created:desc` — filtered rows\n- `file.db?q=SELECT …` — read-only SELECT query\n\n# URLs\n\n- Default reader-mode: HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs → clean text/markdown.\n- `:raw` returns untouched HTML; line selectors (`:50`, `:50-100`, `:50+150`) paginate the cached fetched output.\n- Bare `host:port` URLs collide with the selector grammar — add a trailing slash before the selector: `https://example.com/:80`.\n\n# Internal URIs\n\n`agent://`, `artifact://`, `rule://`, and `local://.md` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://` to recover full output that a previous bash/eval/tool result spilled or truncated.\n\nBundled skills have no filesystem home, so the skill tool and skill discovery report them as `embedded:gjc/skills//SKILL.md`; read that identifier verbatim. Bundled skill fragments, when surfaced, likewise use `embedded:gjc/skill-fragments/…` identifiers.\n\n\n- Always include `path`; never call `read` with `{}`.\n- For line ranges, append the selector to `path`.\n- Re-issue the selector named by a summary footer before relying on elided content.\n", "parameters": { "type": "object", "properties": { diff --git a/packages/coding-agent/test/acp/acp-progress-command.test.ts b/packages/coding-agent/test/acp/acp-progress-command.test.ts index 2040a9eddcf..0d841d63add 100644 --- a/packages/coding-agent/test/acp/acp-progress-command.test.ts +++ b/packages/coding-agent/test/acp/acp-progress-command.test.ts @@ -143,6 +143,12 @@ describe("ACP /progress builtin", () => { ); try { const { sessionId } = await agent.newSession({ cwd, mcpServers: [] }); + // `session/new` schedules client-visible bootstrap updates asynchronously. Match the + // advertised-command production path before testing prompt admission. + await waitFor( + () => updates.some(update => update.update.sessionUpdate === "available_commands_update"), + "ACP available commands", + ); // Hold each session.progress query on a gate the test settles; other queries pass through. let gate = Promise.withResolvers(); diff --git a/packages/coding-agent/test/agent-session-message-pipeline.test.ts b/packages/coding-agent/test/agent-session-message-pipeline.test.ts index c0a9d91c622..a6ed1890d48 100644 --- a/packages/coding-agent/test/agent-session-message-pipeline.test.ts +++ b/packages/coding-agent/test/agent-session-message-pipeline.test.ts @@ -1108,9 +1108,10 @@ describe("AgentSession message pipeline", () => { }); try { await sdkIntegrationStarted.promise; - await Bun.sleep(0); - expect(events.filter(event => event.type === "agent_end")).toHaveLength(2); + // turn_end may start worker reconciliation before the terminal event is + // published, so worker-start is not a barrier for agent_end delivery. await secondAgentEnd.promise; + expect(events.filter(event => event.type === "agent_end")).toHaveLength(2); } finally { unsubscribe(); } diff --git a/packages/coding-agent/test/async-job-manager.test.ts b/packages/coding-agent/test/async-job-manager.test.ts index 51d88b93dcf..d1bdc25e929 100644 --- a/packages/coding-agent/test/async-job-manager.test.ts +++ b/packages/coding-agent/test/async-job-manager.test.ts @@ -1304,4 +1304,88 @@ describe("AsyncJobManager", () => { expect(AsyncJobManager.instance()).toBeUndefined(); expect(AsyncJobManager.forEndpoint("test-session")).toBeUndefined(); }); + + test("rekeyForEndpoint migrates queued resume registrations from E1 to E2", async () => { + // Test that when a manager is rekeyed from E1 to E2, any queued resume + // entries that were registered at E1 are migrated to E2. This verifies that + // the E1-bound lineage/admission tuple is migrated atomically during rekey + // so ownership resolves at E2 and retirement of the queued entry uses the + // same migrated identity. + const endpointE1 = "endpoint-1-rekey-source"; + const endpointE2 = "endpoint-2-rekey-target"; + const manager = new AsyncJobManager({ onJobComplete: async () => {} }); + try { + // Register the manager at E1 + expect(AsyncJobManager.registerForEndpoint(endpointE1, manager)).toBe(true); + expect(AsyncJobManager.endpointIdOf(manager)).toBe(endpointE1); + + const subagentId = "test-subagent-rekey"; + const seq = 1; + const queuedGeneration = `queued:${subagentId}:${seq}`; + const lineageIdHash = "test-lineage-hash"; + const attemptEpoch = 42; + + // Register an owned registration at E1 for the queued generation + registerOwnedRegistration({ + endpointId: endpointE1, + lineageIdHash, + promptAttemptEpoch: attemptEpoch, + endpointGeneration: 1, + jobId: queuedGeneration, + jobGeneration: queuedGeneration, + }); + + // Verify the registration exists at E1 + const regAtE1Before = lookupOwnedRegistration(queuedGeneration, queuedGeneration, endpointE1); + expect(regAtE1Before).toBeDefined(); + expect(regAtE1Before?.endpointId).toBe(endpointE1); + + // Manually create a queued resume entry in the manager with the admission endpoint set to E1 + // This simulates the state after a resume was queued at E1 + // We create a dummy subagent record with a queued field + manager.registerResumeDescriptor( + { subagentId, ownerId: "test-owner", data: { sessionFile: "" } }, + () => "dummy-job-id", + ); + const subagentRecord = manager.getSubagentRecord(subagentId); + if (subagentRecord) { + // Simulate the state of a queued resume by manually setting the queued field + subagentRecord.status = "queued"; + subagentRecord.queued = { + ownerId: "test-owner", + seq, + message: "resume message", + admissionEndpointId: endpointE1, + createdAt: Date.now(), + }; + } + + // Rekey the manager from E1 to E2 + const rekeySuccess = AsyncJobManager.rekeyForEndpoint(endpointE1, endpointE2, manager); + expect(rekeySuccess).toBe(true); + + // Verify the manager is now registered at E2, not E1 + expect(AsyncJobManager.forEndpoint(endpointE1)).toBeUndefined(); + expect(AsyncJobManager.forEndpoint(endpointE2)).toBe(manager); + expect(AsyncJobManager.endpointIdOf(manager)).toBe(endpointE2); + + // Verify the queued registration was migrated from E1 to E2 + const regAtE1After = lookupOwnedRegistration(queuedGeneration, queuedGeneration, endpointE1); + expect(regAtE1After).toBeUndefined(); // No longer at E1 + + const regAtE2After = lookupOwnedRegistration(queuedGeneration, queuedGeneration, endpointE2); + expect(regAtE2After).toBeDefined(); // Now at E2 + expect(regAtE2After?.endpointId).toBe(endpointE2); + expect(regAtE2After?.lineageIdHash).toBe(lineageIdHash); + expect(regAtE2After?.promptAttemptEpoch).toBe(attemptEpoch); + + // Verify the queued resume's admission endpoint was NOT updated (remains E1) + // so the resumed job will register at E1, not E2 + const updatedRecord = manager.getSubagentRecord(subagentId); + expect(updatedRecord?.queued?.admissionEndpointId).toBe(endpointE1); + } finally { + await manager.dispose({ timeoutMs: 100 }); + AsyncJobManager.unregisterManager(manager); + } + }); }); diff --git a/packages/coding-agent/test/async/job-manager-redteam.test.ts b/packages/coding-agent/test/async/job-manager-redteam.test.ts index 46f9c6cce6d..ab802e67f1e 100644 --- a/packages/coding-agent/test/async/job-manager-redteam.test.ts +++ b/packages/coding-agent/test/async/job-manager-redteam.test.ts @@ -186,4 +186,100 @@ describe("AsyncJobManager red-team invariants", () => { await manager.dispose({ timeoutMs: 200 }); }); + + test("rejects malformed queued IDs with invalid sequence numbers", async () => { + const manager = new AsyncJobManager({ onJobComplete: () => {} }); + + // Register a valid subagent to get a queued ID to test + const subagentRecord: SubagentRecord = { + subagentId: "test-sub", + currentJobId: "test-job", + historicalJobIds: [], + status: "queued", + queued: { seq: 1, createdAt: Date.now() }, + sessionFile: `/tmp/test-sub.jsonl`, + resumable: true, + }; + manager.registerSubagentRecord(subagentRecord); + manager.registerResumeDescriptor(descriptor("test-sub")); + + // Malformed IDs should return false (not cancel) + const malformedIds = [ + "queued:test-sub:1junk", // Invalid: contains non-digit suffix + "queued:test-sub:1.5", // Invalid: decimal number + "queued:test-sub:01", // Invalid: leading zero + "queued:test-sub:0", // Invalid: zero (seq starts at 1) + "queued:test-sub:", // Invalid: empty suffix + "queued:test-sub: 1", // Invalid: space before number + "queued:test-sub:1 ", // Invalid: space after number + ]; + + for (const id of malformedIds) { + const result = manager.cancel(id); + expect(result).toBe(false); + } + + // Verify the subagent record still exists and was not cancelled by malformed attempts + let rec = manager.getSubagentRecord("test-sub"); + expect(rec?.status).toBe("queued"); + expect(rec?.queued?.seq).toBe(1); + + // Valid queued ID should now cancel successfully + const validId = "queued:test-sub:1"; + expect(manager.cancel(validId)).toBe(true); + rec = manager.getSubagentRecord("test-sub"); + expect(rec?.status).toBe("cancelled"); + + await manager.dispose({ timeoutMs: 200 }); + }); + + test("rejects queued cancellation for stale generation when replacement is registered (regression)", async () => { + const manager = new AsyncJobManager({ onJobComplete: () => {} }); + + // Register initial queued subagent with seq=1 + const initialRecord: SubagentRecord = { + subagentId: "test-sub", + currentJobId: "test-job-1", + historicalJobIds: [], + status: "queued", + queued: { seq: 1, createdAt: Date.now() }, + sessionFile: `/tmp/test-sub-1.jsonl`, + resumable: true, + }; + manager.registerSubagentRecord(initialRecord); + manager.registerResumeDescriptor(descriptor("test-sub")); + + // Cancel the first generation successfully + const firstId = "queued:test-sub:1"; + expect(manager.cancel(firstId)).toBe(true); + let rec = manager.getSubagentRecord("test-sub"); + expect(rec?.status).toBe("cancelled"); + + // Register replacement with same stable ID but seq=2 + const replacementRecord: SubagentRecord = { + subagentId: "test-sub", + currentJobId: "test-job-2", + historicalJobIds: ["test-job-1"], + status: "queued", + queued: { seq: 2, createdAt: Date.now() }, + sessionFile: `/tmp/test-sub-2.jsonl`, + resumable: true, + }; + manager.registerSubagentRecord(replacementRecord); + + // Attempt to cancel with old seq should fail (generation mismatch) + const staleId = "queued:test-sub:1"; + expect(manager.cancel(staleId)).toBe(false); + rec = manager.getSubagentRecord("test-sub"); + expect(rec?.status).toBe("queued"); + expect(rec?.queued?.seq).toBe(2); + + // Cancel with correct seq should succeed + const correctId = "queued:test-sub:2"; + expect(manager.cancel(correctId)).toBe(true); + rec = manager.getSubagentRecord("test-sub"); + expect(rec?.status).toBe("cancelled"); + + await manager.dispose({ timeoutMs: 200 }); + }); }); diff --git a/packages/coding-agent/test/async/job-manager-resume-queue.test.ts b/packages/coding-agent/test/async/job-manager-resume-queue.test.ts index 7a75dd7b604..93f66ab160a 100644 --- a/packages/coding-agent/test/async/job-manager-resume-queue.test.ts +++ b/packages/coding-agent/test/async/job-manager-resume-queue.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; import { AsyncJobManager, type SubagentRunOutcome } from "@gajae-code/coding-agent/async/job-manager"; +import { + bindToolLineage, + lookupOwnedRegistration, + registerOwnedIfLineaged, +} from "@gajae-code/coding-agent/session/terminal-abort"; /** Build a manager that records every delivered completion. */ function makeManager(opts?: { maxRunningJobs?: number; retentionMs?: number }) { @@ -52,9 +57,9 @@ function spawnControllable(manager: AsyncJobManager, subagentId: string, ownerId /** A resume runner that re-spawns a subagent which completes immediately. */ function installResumeRunner(manager: AsyncJobManager) { - manager.setResumeRunner((subagentId, message) => { + manager.setResumeRunner((subagentId, message, _descriptor, resumeToolCallId, admissionEndpointId) => { const rec = manager.getSubagentRecord(subagentId); - return manager.register( + const jobId = manager.register( "task", subagentId, async (): Promise => ({ @@ -67,6 +72,9 @@ function installResumeRunner(manager: AsyncJobManager) { metadata: { subagent: { id: subagentId, agent: "executor", agentSource: "bundled" } }, }, ); + // Register the resumed job as owned work using the admission endpoint + registerOwnedIfLineaged(manager, resumeToolCallId, jobId, admissionEndpointId); + return jobId; }); } @@ -451,10 +459,108 @@ describe("AsyncJobManager subagent pause/resume/queue", () => { expect(completions.map(completion => completion.text)).toContain("resumed:C"); const proof = await manager.cancelAndProveOwnerSubagents(lease, { timeoutMs: 50 }); expect(proof).toMatchObject({ confirmed: true, terminalIds: ["A"], unresolvedIds: [] }); + expect(manager.getSubagentRecord("A")?.status).toBe("cancelled"); manager.finishOwnerSubagentShutdown(lease, "release"); await manager.dispose({ timeoutMs: 500 }); }); + test("a stale queued generation cannot resume a replacement owner's entry", async () => { + const { manager, completions } = makeManager({ maxRunningJobs: 1 }); + installResumeRunner(manager); + const a = spawnControllable(manager, "A", "owner-a"); + expect(manager.pauseSubagent("A").ok).toBe(true); + a.release(); + await manager.waitForAll(); + + const blocker = spawnControllable(manager, "BLOCK", "owner-blocker"); + expect(manager.resumeSubagent("A", { ownerId: "owner-a" }, "stale").queued).toBe(true); + manager.registerSubagentRecord({ + subagentId: "A", + ownerId: "owner-b", + currentJobId: null, + historicalJobIds: ["A"], + status: "paused", + sessionFile: "/tmp/A-owner-b.jsonl", + resumable: true, + }); + expect(manager.resumeSubagent("A", { ownerId: "owner-b" }, "current").queued).toBe(true); + + blocker.release(); + await manager.waitForAll(); + await manager.drainDeliveries({ timeoutMs: 500 }); + + expect(completions.map(completion => completion.text)).toContain("resumed:current"); + expect(completions.map(completion => completion.text)).not.toContain("resumed:stale"); + expect(manager.getSubagentRecord("A", { ownerId: "owner-b" })?.status).toBe("completed"); + await manager.dispose({ timeoutMs: 500 }); + }); + + test("stale queued generation's owned registration is retired on replacement (endpoint-transition regression)", async () => { + // Tests that when a subagent is resumed with a tool call ID, and later + // a replacement owner/generation is registered, the stale registration is + // properly retired using its original endpoint identity, not the manager's + // current endpoint (which may have been rekeyed). + const { manager } = makeManager({ maxRunningJobs: 1 }); + installResumeRunner(manager); + const a = spawnControllable(manager, "A", "owner-a"); + expect(manager.pauseSubagent("A").ok).toBe(true); + a.release(); + await manager.waitForAll(); + + // Set up a tool lineage for the stale resume + const staleToolCallId = "tool-call-stale-123"; + const managerEndpointId = "endpoint-v1"; + bindToolLineage(staleToolCallId, { + lineageIdHash: "hash-stale", + promptAttemptEpoch: 1, + endpointGeneration: 1, + endpointId: managerEndpointId, + }); + + const blocker = spawnControllable(manager, "BLOCK", "owner-blocker"); + // Queue the stale resume WITH a tool call ID to exercise registration + expect(manager.resumeSubagent("A", { ownerId: "owner-a" }, "stale", staleToolCallId).queued).toBe(true); + // Verify the stale queued generation has an owned registration + const staleGen = `queued:A:1`; + const staleReg = lookupOwnedRegistration(staleGen, staleGen, managerEndpointId); + expect(staleReg).toBeDefined(); + + // Replace with a new owner/record + manager.registerSubagentRecord({ + subagentId: "A", + ownerId: "owner-b", + currentJobId: null, + historicalJobIds: ["A"], + status: "paused", + sessionFile: "/tmp/A-owner-b.jsonl", + resumable: true, + }); + // Queue the new resume (also with tool call ID) + const currentToolCallId = "tool-call-current-456"; + bindToolLineage(currentToolCallId, { + lineageIdHash: "hash-current", + promptAttemptEpoch: 2, + endpointGeneration: 1, + endpointId: managerEndpointId, + }); + expect(manager.resumeSubagent("A", { ownerId: "owner-b" }, "current", currentToolCallId).queued).toBe(true); + // The current generation should have its own registration + const currentGen = `queued:A:2`; + const currentReg = lookupOwnedRegistration(currentGen, currentGen, managerEndpointId); + expect(currentReg).toBeDefined(); + + // Drain and complete; this will clean up the stale queued entry + blocker.release(); + await manager.waitForAll(); + await manager.drainDeliveries({ timeoutMs: 500 }); + + // After drain, the stale registration should have been retired + const staleRegAfter = lookupOwnedRegistration(staleGen, staleGen, managerEndpointId); + expect(staleRegAfter).toBeUndefined(); + + await manager.dispose({ timeoutMs: 500 }); + }); + test("cancelSubagent on a paused subagent marks cancelled but keeps the record (AC10)", async () => { const { manager } = makeManager(); const a = spawnControllable(manager, "A"); @@ -548,4 +654,220 @@ describe("AsyncJobManager subagent pause/resume/queue", () => { expect(manager.getLiveHandle("A")).toBeUndefined(); await manager.dispose({ timeoutMs: 500 }); }); + + test("stale queued registrations are retired when manager is rekeyed (endpoint-transition regression)", async () => { + // Tests that when a subagent is resumed with a tool call ID at endpoint E1, + // queued, and then the manager is rekeyed to E2 before the queue is drained, + // the stale queued registration is properly retired using the saved admission + // endpoint (E1), not the manager's current endpoint (E2). + const { manager } = makeManager({ maxRunningJobs: 1 }); + installResumeRunner(manager); + + // Start with A paused, then add a blocker to fill capacity + const a = spawnControllable(manager, "A", "owner-a"); + expect(manager.pauseSubagent("A").ok).toBe(true); + a.release(); + await manager.waitForAll(); + + // Now start a blocker to fill the max capacity slot + const blocker = spawnControllable(manager, "BLOCK", "owner-blocker"); + + // Set up a tool lineage at endpoint E1 + const staleToolCallId = "tool-call-stale-endpoint-123"; + const endpointE1 = "endpoint-e1"; + bindToolLineage(staleToolCallId, { + lineageIdHash: "hash-stale", + promptAttemptEpoch: 1, + endpointGeneration: 1, + endpointId: endpointE1, + }); + + // Queue the resume with the tool call ID; this saves endpointE1 as the admission endpoint + expect(manager.resumeSubagent("A", { ownerId: "owner-a" }, "stale", staleToolCallId).queued).toBe(true); + const staleGen = `queued:A:1`; + const staleRegBefore = lookupOwnedRegistration(staleGen, staleGen, endpointE1); + expect(staleRegBefore).toBeDefined(); + + // Replace with a new owner/record + manager.registerSubagentRecord({ + subagentId: "A", + ownerId: "owner-b", + currentJobId: null, + historicalJobIds: ["A"], + status: "paused", + sessionFile: "/tmp/A-owner-b.jsonl", + resumable: true, + }); + // Queue the new resume (also with tool call ID at E2) + const endpointE2 = "endpoint-e2"; + const currentToolCallId = "tool-call-current-456"; + bindToolLineage(currentToolCallId, { + lineageIdHash: "hash-current", + promptAttemptEpoch: 2, + endpointGeneration: 1, + endpointId: endpointE2, + }); + expect(manager.resumeSubagent("A", { ownerId: "owner-b" }, "current", currentToolCallId).queued).toBe(true); + + // Drain the queue by releasing the blocker; the stale entry should be cleaned up using the saved E1 endpoint + blocker.release(); + await manager.waitForAll(); + await manager.drainDeliveries({ timeoutMs: 500 }); + + // The stale registration should have been retired (using the saved E1 endpoint) + const staleRegAfter = lookupOwnedRegistration(staleGen, staleGen, endpointE1); + expect(staleRegAfter).toBeUndefined(); + + await manager.dispose({ timeoutMs: 500 }); + }); + + test("saturated pool: stale queued entries are retired even when all slots are full", async () => { + // When a queued resume is replaced by a record for a different owner while + // the pool is at max capacity, the stale entry must be retired immediately, + // not deferred until a slot becomes free. This prevents owned aborts from + // returning unsettled for generations that can never execute. + const { manager } = makeManager({ maxRunningJobs: 1 }); + installResumeRunner(manager); + + // First, spawn A, pause it to create a paused record, then set up blocker + const a = spawnControllable(manager, "A", "owner-a"); + expect(manager.pauseSubagent("A").ok).toBe(true); + a.release(); + await manager.waitForAll(); + + // Spawn a blocker to fill the single capacity slot + const blocker = spawnControllable(manager, "BLOCKER", "owner-blocker"); + expect(manager.getRunningJobs().length).toBe(1); + + // Queue a resume for A (owner-a); this saves the admission endpoint + const toolCallIdA = "tool-call-a-123"; + const endpointA = "endpoint-a"; + bindToolLineage(toolCallIdA, { + lineageIdHash: "hash-a", + promptAttemptEpoch: 1, + endpointGeneration: 1, + endpointId: endpointA, + }); + const resumeA = manager.resumeSubagent("A", { ownerId: "owner-a" }, "A message", toolCallIdA); + expect(resumeA.ok).toBe(true); + expect(resumeA.queued).toBe(true); + const staleGenA = `queued:A:1`; + const staleRegABefore = lookupOwnedRegistration(staleGenA, staleGenA, endpointA); + expect(staleRegABefore).toBeDefined(); + + // Replace A's record with B's record (same subagent ID, different owner) + // This makes the queued entry stale + manager.registerSubagentRecord({ + subagentId: "A", + ownerId: "owner-b", + currentJobId: null, + historicalJobIds: ["A"], + status: "paused", + sessionFile: "/tmp/A-owner-b.jsonl", + resumable: true, + }); + + // Queue the new resume for B + const toolCallIdB = "tool-call-b-456"; + const endpointB = "endpoint-b"; + bindToolLineage(toolCallIdB, { + lineageIdHash: "hash-b", + promptAttemptEpoch: 2, + endpointGeneration: 1, + endpointId: endpointB, + }); + const resumeB = manager.resumeSubagent("A", { ownerId: "owner-b" }, "B message", toolCallIdB); + expect(resumeB.queued).toBe(true); + const validGenB = `queued:A:2`; + const validRegB = lookupOwnedRegistration(validGenB, validGenB, endpointB); + expect(validRegB).toBeDefined(); + + // Critically: A's stale entry must be retired NOW, even though the pool is + // still saturated. The stale registration should be unregistered and marked + // terminal immediately when the replacement was queued. + const staleRegAAfter = lookupOwnedRegistration(staleGenA, staleGenA, endpointA); + expect(staleRegAAfter).toBeUndefined(); + + // The stale generation should be marked as terminal in the manager. + const staleJobA = manager.getJob(staleGenA); + expect(staleJobA?.status).toBe("cancelled"); + + // Release the blocker and verify B's resume can start + blocker.release(); + await manager.waitForAll(); + + await manager.dispose({ timeoutMs: 500 }); + }); + + test("valid queued resume ownership is preserved across manager rekey", async () => { + // Tests that when a subagent is resumed with a tool call ID at endpoint E1, + // queued, and then the manager is rekeyed to E2 while the entry remains VALID + // (not stale, still in the queue), the valid entry uses its saved admission + // endpoint to unregister the owned tuple when the queue is drained. + const { manager } = makeManager({ maxRunningJobs: 1 }); + installResumeRunner(manager); + + // Start with A paused, then add a blocker to fill capacity + const a = spawnControllable(manager, "A", "owner-a"); + expect(manager.pauseSubagent("A").ok).toBe(true); + a.release(); + await manager.waitForAll(); + + // Start a blocker to fill the single capacity slot + const blocker = spawnControllable(manager, "BLOCK", "owner-blocker"); + + // Set up a tool lineage at endpoint E1 + const validToolCallId = "tool-call-valid-e1-123"; + const endpointE1 = "endpoint-e1-valid"; + bindToolLineage(validToolCallId, { + lineageIdHash: "hash-valid-e1", + promptAttemptEpoch: 1, + endpointGeneration: 1, + endpointId: endpointE1, + }); + + // Register the manager at endpoint E1 + const registeredAtE1 = AsyncJobManager.registerForEndpoint(endpointE1, manager); + expect(registeredAtE1).toBe(true); + + // Queue the resume with the tool call ID at E1; this saves endpointE1 as the admission endpoint + expect(manager.resumeSubagent("A", { ownerId: "owner-a" }, "valid", validToolCallId).queued).toBe(true); + const validGen = `queued:A:1`; + const validRegBefore = lookupOwnedRegistration(validGen, validGen, endpointE1); + expect(validRegBefore).toBeDefined(); + + // Rekey the manager from E1 to E2: the valid queue entry is now at a different endpoint + const endpointE2 = "endpoint-e2-valid"; + const rekeySuccess = AsyncJobManager.rekeyForEndpoint(endpointE1, endpointE2, manager); + expect(rekeySuccess).toBe(true); + // Verify the manager has been moved to E2 + expect(AsyncJobManager.endpointIdOf(manager)).toBe(endpointE2); + + // Drain the queue by releasing the blocker; the valid entry should be cleaned up + // using the saved E1 endpoint, not the manager's current E2 endpoint + blocker.release(); + await manager.waitForAll(); + await manager.drainDeliveries({ timeoutMs: 500 }); + + // The valid registration should have been retired (using the saved E1 endpoint) + const validRegAfter = lookupOwnedRegistration(validGen, validGen, endpointE1); + expect(validRegAfter).toBeUndefined(); + + // The resumed job should have transitioned from queued to running/completed + const rec = manager.getSubagentRecord("A"); + expect(rec?.status).not.toBe("queued"); + + // Verify that the resumed job is registered at the admission endpoint E1, + // not at the current endpoint E2 + if (rec?.currentJobId && rec?.currentJobGeneration) { + // Should exist at E1 (admission endpoint) + const runningRegE1 = lookupOwnedRegistration(rec.currentJobId, rec.currentJobGeneration, endpointE1); + expect(runningRegE1).toBeDefined(); + // Should NOT exist at E2 (current endpoint) + const runningRegE2 = lookupOwnedRegistration(rec.currentJobId, rec.currentJobGeneration, endpointE2); + expect(runningRegE2).toBeUndefined(); + } + + await manager.dispose({ timeoutMs: 500 }); + }); }); diff --git a/packages/coding-agent/test/eval/python-lifecycle.redteam.test.ts b/packages/coding-agent/test/eval/python-lifecycle.redteam.test.ts index ef08659e881..bb43ea7c038 100644 --- a/packages/coding-agent/test/eval/python-lifecycle.redteam.test.ts +++ b/packages/coding-agent/test/eval/python-lifecycle.redteam.test.ts @@ -30,7 +30,7 @@ class FakeKernel { alive = true; executeCalls: string[] = []; shutdownCalls = 0; - shutdownResult: KernelShutdownResult = { confirmed: true }; + shutdownExitCode: number | null = 0; private readonly executeImpl?: (code: string, options?: KernelExecuteOptions) => Promise; constructor(executeImpl?: (code: string, options?: KernelExecuteOptions) => Promise) { @@ -45,7 +45,7 @@ class FakeKernel { async shutdown(): Promise { this.shutdownCalls += 1; this.alive = false; - return this.shutdownResult; + return { confirmed: this.shutdownExitCode !== null }; } isAlive(): boolean { @@ -102,7 +102,12 @@ async function settlesWithin(promise: Promise, timeoutMs: number): Prom ]); } -function countAbortListeners(signal: AbortSignal): { readonly count: () => number; readonly restore: () => void } { +type AbortListenerCounter = { + readonly count: () => number; + readonly restore: () => void; +}; + +function countAbortListeners(signal: AbortSignal): AbortListenerCounter { let count = 0; const originalAdd = signal.addEventListener.bind(signal); const originalRemove = signal.removeEventListener.bind(signal); @@ -362,7 +367,7 @@ describe("python eval lifecycle red-team", () => { Bun.env.PI_PYTHON_SKIP_CHECK = "1"; using tempDir = TempDir.createSync("@gjc-python-lifecycle-redteam-"); const controller = new AbortController(); - const listeners = countAbortListeners(controller.signal); + let listeners: AbortListenerCounter | undefined; const kernelStarted = Promise.withResolvers(); let shutdown: (() => Promise) | undefined; let execution: Promise | undefined; @@ -371,6 +376,11 @@ describe("python eval lifecycle red-team", () => { try { const kernel = await originalStart({ cwd: tempDir.path() }); shutdown = () => kernel.shutdown({ timeoutMs: 100 }); + const execute = kernel.execute.bind(kernel); + kernel.execute = (code, options) => { + if (!listeners && options?.signal) listeners = countAbortListeners(options.signal); + return execute(code, options); + }; kernelStarted.resolve(); return kernel; } catch (error) { @@ -392,16 +402,16 @@ describe("python eval lifecycle red-team", () => { ); await kernelStarted.promise; await waitForFile(executionReadyFile); - expect(listeners.count()).toBe(1); + expect(listeners?.count()).toBe(1); if (!shutdown) throw new Error("Python kernel did not expose shutdown after startup"); await shutdown(); await execution; - expect(listeners.count()).toBe(0); + expect(listeners?.count()).toBe(0); } finally { await shutdown?.().catch(() => undefined); await execution?.catch(() => undefined); - listeners.restore(); + listeners?.restore(); } }, LIFECYCLE_TEST_TIMEOUT_MS, @@ -416,7 +426,7 @@ describe("python eval lifecycle red-team", () => { const startup = Promise.withResolvers(); const startupCalled = Promise.withResolvers(); const kernel = new FakeKernel(); - kernel.shutdownResult = { confirmed: false }; + kernel.shutdownExitCode = null; let startupFinished = false; let executionSettled = false; PythonKernel.start = async () => { @@ -453,9 +463,11 @@ describe("python eval lifecycle red-team", () => { } expect(startupFinished).toBe(true); expect(kernel.shutdownCalls).toBe(1); - await disposeAllKernelSessions(); + await expect(disposeAllKernelSessions()).rejects.toMatchObject({ + name: "PythonKernelShutdownUnconfirmedError", + }); expect(kernel.shutdownCalls).toBe(2); - kernel.shutdownResult = { confirmed: true }; + kernel.shutdownExitCode = 0; await disposeAllKernelSessions(); expect(kernel.shutdownCalls).toBe(3); }, @@ -466,6 +478,7 @@ describe("python eval lifecycle red-team", () => { Bun.env.PI_PYTHON_SKIP_CHECK = "1"; using tempDir = TempDir.createSync("@gjc-python-lifecycle-redteam-"); const firstKernel = new FakeKernel(); + firstKernel.shutdownExitCode = 0; const secondKernel = new FakeKernel(); let startCalls = 0; PythonKernel.start = async () => { diff --git a/packages/coding-agent/test/fixtures/read-goldens/changed/prompt-read-default.hl.json b/packages/coding-agent/test/fixtures/read-goldens/changed/prompt-read-default.hl.json new file mode 100644 index 00000000000..0a32c9d6ee7 --- /dev/null +++ b/packages/coding-agent/test/fixtures/read-goldens/changed/prompt-read-default.hl.json @@ -0,0 +1,38 @@ +{ + "text": "271wf|prose-271 stable fixture text stable fixture text stable fixture text stable fixture text\n272df|prose-272 stable fixture text stable fixture text stable fixture text stable fixture text\n273rk|prose-273 stable fixture text stable fixture text stable fixture text stable fixture text\n274fn|prose-274 stable fixture text stable fixture text stable fixture text stable fixture text\n275mj|prose-275 stable fixture text stable fixture text stable fixture text stable fixture text\n276wm|prose-276 stable fixture text stable fixture text stable fixture text stable fixture text\n277ne|prose-277 stable fixture text stable fixture text stable fixture text stable fixture text\n278ox|prose-278 stable fixture text stable fixture text stable fixture text stable fixture text\n279cn|prose-279 stable fixture text stable fixture text stable fixture text stable fixture text\n280pu|prose-280 stable fixture text stable fixture text stable fixture text stable fixture text\n281pr|prose-281 stable fixture text stable fixture text stable fixture text stable fixture text\n282mk|prose-282 stable fixture text stable fixture text stable fixture text stable fixture text\n283xr|prose-283 stable fixture text stable fixture text stable fixture text stable fixture text\n284cq|prose-284 stable fixture text stable fixture text stable fixture text stable fixture text\n285sy|prose-285 stable fixture text stable fixture text stable fixture text stable fixture text\n286sc|prose-286 stable fixture text stable fixture text stable fixture text stable fixture text\n287qq|prose-287 stable fixture text stable fixture text stable fixture text stable fixture text\n288ud|prose-288 stable fixture text stable fixture text stable fixture text stable fixture text\n289oo|prose-289 stable fixture text stable fixture text stable fixture text stable fixture text\n290ik|prose-290 stable fixture text stable fixture text stable fixture text stable fixture text\n291dg|prose-291 stable fixture text stable fixture text stable fixture text stable fixture text\n292rc|prose-292 stable fixture text stable fixture text stable fixture text stable fixture text\n293ip|prose-293 stable fixture text stable fixture text stable fixture text stable fixture text\n294ku|prose-294 stable fixture text stable fixture text stable fixture text stable fixture text\n295ri|prose-295 stable fixture text stable fixture text stable fixture text stable fixture text\n296us|prose-296 stable fixture text stable fixture text stable fixture text stable fixture text\n297dt|prose-297 stable fixture text stable fixture text stable fixture text stable fixture text\n298sf|prose-298 stable fixture text stable fixture text stable fixture text stable fixture text\n299bg|prose-299 stable fixture text stable fixture text stable fixture text stable fixture text\n300ba|prose-300 stable fixture text stable fixture text stable fixture text stable fixture text\n301zc|prose-301 stable fixture text stable fixture text stable fixture text stable fixture text\n302tx|prose-302 stable fixture text stable fixture text stable fixture text stable fixture text\n303fl|prose-303 stable fixture text stable fixture text stable fixture text stable fixture text\n304lc|prose-304 stable fixture text stable fixture text stable fixture text stable fixture text\n305cu|prose-305 stable fixture text stable fixture text stable fixture text stable fixture text\n306ls|prose-306 stable fixture text stable fixture text stable fixture text stable fixture text\n307xm|prose-307 stable fixture text stable fixture text stable fixture text stable fixture text\n308sv|prose-308 stable fixture text stable fixture text stable fixture text stable fixture text\n309hm|prose-309 stable fixture text stable fixture text stable fixture text stable fixture text\n310mk|prose-310 stable fixture text stable fixture text stable fixture text stable fixture text\n311ag|prose-311 stable fixture text stable fixture text stable fixture text stable fixture text\n312yv|prose-312 stable fixture text stable fixture text stable fixture text stable fixture text\n313mj|prose-313 stable fixture text stable fixture text stable fixture text stable fixture text\n314pb|prose-314 stable fixture text stable fixture text stable fixture text stable fixture text\n315me|prose-315 stable fixture text stable fixture text stable fixture text stable fixture text\n316da|prose-316 stable fixture text stable fixture text stable fixture text stable fixture text\n317hk|prose-317 stable fixture text stable fixture text stable fixture text stable fixture text\n318yy|prose-318 stable fixture text stable fixture text stable fixture text stable fixture text\n319fc|prose-319 stable fixture text stable fixture text stable fixture text stable fixture text\n320io|prose-320 stable fixture text stable fixture text stable fixture text stable fixture text\n\n[Showing last 50 of 320 lines (lines 271-320, ~5 KiB); omitted lines 1-270; re-read {TMP}/prose.txt:1-320 or {TMP}/prose.txt:raw for the full file]", + "displayContent": { + "text": "prose-271 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-272 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-273 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-274 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-275 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-276 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-277 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-278 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-279 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-280 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-281 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-282 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-283 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-284 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-285 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-286 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-287 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-288 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-289 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-290 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-291 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-292 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-293 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-294 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-295 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-296 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-297 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-298 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-299 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-300 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-301 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-302 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-303 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-304 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-305 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-306 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-307 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-308 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-309 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-310 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-311 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-312 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-313 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-314 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-315 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-316 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-317 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-318 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-319 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-320 stable fixture text stable fixture text stable fixture text stable fixture text", + "startLine": 271 + }, + "truncation": { + "content": "prose-271 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-272 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-273 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-274 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-275 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-276 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-277 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-278 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-279 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-280 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-281 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-282 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-283 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-284 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-285 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-286 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-287 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-288 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-289 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-290 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-291 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-292 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-293 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-294 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-295 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-296 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-297 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-298 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-299 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-300 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-301 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-302 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-303 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-304 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-305 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-306 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-307 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-308 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-309 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-310 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-311 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-312 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-313 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-314 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-315 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-316 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-317 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-318 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-319 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-320 stable fixture text stable fixture text stable fixture text stable fixture text", + "truncated": true, + "truncatedBy": "lines", + "totalLines": 320, + "totalBytes": 28799, + "outputLines": 50, + "outputBytes": 4499, + "lastLinePartial": false, + "firstLineExceedsLimit": false + }, + "meta": { + "source": { + "type": "path", + "value": "{TMP}/prose.txt" + }, + "truncation": { + "direction": "tail", + "truncatedBy": "lines", + "noticeOwner": "body", + "totalLines": 320, + "totalBytes": 28799, + "outputLines": 50, + "outputBytes": 4499, + "maxBytes": 10240, + "shownRange": { + "start": 271, + "end": 320 + } + } + } +} diff --git a/packages/coding-agent/test/fixtures/read-goldens/changed/prompt-read-default.plain.json b/packages/coding-agent/test/fixtures/read-goldens/changed/prompt-read-default.plain.json new file mode 100644 index 00000000000..0986ace3a94 --- /dev/null +++ b/packages/coding-agent/test/fixtures/read-goldens/changed/prompt-read-default.plain.json @@ -0,0 +1,38 @@ +{ + "text": "prose-271 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-272 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-273 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-274 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-275 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-276 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-277 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-278 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-279 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-280 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-281 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-282 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-283 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-284 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-285 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-286 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-287 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-288 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-289 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-290 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-291 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-292 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-293 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-294 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-295 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-296 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-297 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-298 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-299 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-300 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-301 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-302 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-303 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-304 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-305 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-306 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-307 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-308 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-309 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-310 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-311 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-312 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-313 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-314 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-315 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-316 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-317 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-318 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-319 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-320 stable fixture text stable fixture text stable fixture text stable fixture text\n\n[Showing last 50 of 320 lines (lines 271-320, ~5 KiB); omitted lines 1-270; re-read {TMP}/prose.txt:1-320 or {TMP}/prose.txt:raw for the full file]", + "displayContent": { + "text": "prose-271 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-272 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-273 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-274 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-275 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-276 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-277 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-278 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-279 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-280 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-281 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-282 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-283 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-284 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-285 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-286 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-287 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-288 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-289 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-290 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-291 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-292 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-293 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-294 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-295 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-296 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-297 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-298 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-299 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-300 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-301 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-302 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-303 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-304 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-305 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-306 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-307 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-308 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-309 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-310 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-311 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-312 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-313 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-314 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-315 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-316 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-317 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-318 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-319 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-320 stable fixture text stable fixture text stable fixture text stable fixture text", + "startLine": 271 + }, + "truncation": { + "content": "prose-271 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-272 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-273 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-274 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-275 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-276 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-277 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-278 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-279 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-280 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-281 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-282 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-283 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-284 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-285 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-286 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-287 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-288 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-289 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-290 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-291 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-292 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-293 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-294 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-295 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-296 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-297 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-298 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-299 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-300 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-301 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-302 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-303 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-304 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-305 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-306 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-307 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-308 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-309 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-310 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-311 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-312 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-313 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-314 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-315 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-316 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-317 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-318 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-319 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-320 stable fixture text stable fixture text stable fixture text stable fixture text", + "truncated": true, + "truncatedBy": "lines", + "totalLines": 320, + "totalBytes": 28799, + "outputLines": 50, + "outputBytes": 4499, + "lastLinePartial": false, + "firstLineExceedsLimit": false + }, + "meta": { + "source": { + "type": "path", + "value": "{TMP}/prose.txt" + }, + "truncation": { + "direction": "tail", + "truncatedBy": "lines", + "noticeOwner": "body", + "totalLines": 320, + "totalBytes": 28799, + "outputLines": 50, + "outputBytes": 4499, + "maxBytes": 10240, + "shownRange": { + "start": 271, + "end": 320 + } + } + } +} diff --git a/packages/coding-agent/test/fixtures/read-goldens/changed/prompt-read-head.hl.json b/packages/coding-agent/test/fixtures/read-goldens/changed/prompt-read-head.hl.json new file mode 100644 index 00000000000..7befd084218 --- /dev/null +++ b/packages/coding-agent/test/fixtures/read-goldens/changed/prompt-read-head.hl.json @@ -0,0 +1,37 @@ +{ + "text": "1mk|prose-001 stable fixture text stable fixture text stable fixture text stable fixture text\n2pi|prose-002 stable fixture text stable fixture text stable fixture text stable fixture text\n3tz|prose-003 stable fixture text stable fixture text stable fixture text stable fixture text\n4fl|prose-004 stable fixture text stable fixture text stable fixture text stable fixture text\n5pi|prose-005 stable fixture text stable fixture text stable fixture text stable fixture text\n6dg|prose-006 stable fixture text stable fixture text stable fixture text stable fixture text\n7se|prose-007 stable fixture text stable fixture text stable fixture text stable fixture text\n8uw|prose-008 stable fixture text stable fixture text stable fixture text stable fixture text\n9bx|prose-009 stable fixture text stable fixture text stable fixture text stable fixture text\n10ai|prose-010 stable fixture text stable fixture text stable fixture text stable fixture text\n11dw|prose-011 stable fixture text stable fixture text stable fixture text stable fixture text\n12zf|prose-012 stable fixture text stable fixture text stable fixture text stable fixture text\n13xo|prose-013 stable fixture text stable fixture text stable fixture text stable fixture text\n14on|prose-014 stable fixture text stable fixture text stable fixture text stable fixture text\n15dv|prose-015 stable fixture text stable fixture text stable fixture text stable fixture text\n16uz|prose-016 stable fixture text stable fixture text stable fixture text stable fixture text\n17rz|prose-017 stable fixture text stable fixture text stable fixture text stable fixture text\n18lj|prose-018 stable fixture text stable fixture text stable fixture text stable fixture text\n19df|prose-019 stable fixture text stable fixture text stable fixture text stable fixture text\n20rv|prose-020 stable fixture text stable fixture text stable fixture text stable fixture text\n21lc|prose-021 stable fixture text stable fixture text stable fixture text stable fixture text\n22ou|prose-022 stable fixture text stable fixture text stable fixture text stable fixture text\n23vt|prose-023 stable fixture text stable fixture text stable fixture text stable fixture text\n24gg|prose-024 stable fixture text stable fixture text stable fixture text stable fixture text\n25ux|prose-025 stable fixture text stable fixture text stable fixture text stable fixture text\n26sg|prose-026 stable fixture text stable fixture text stable fixture text stable fixture text\n27me|prose-027 stable fixture text stable fixture text stable fixture text stable fixture text\n28cg|prose-028 stable fixture text stable fixture text stable fixture text stable fixture text\n29tx|prose-029 stable fixture text stable fixture text stable fixture text stable fixture text\n30hr|prose-030 stable fixture text stable fixture text stable fixture text stable fixture text\n31aq|prose-031 stable fixture text stable fixture text stable fixture text stable fixture text\n32jp|prose-032 stable fixture text stable fixture text stable fixture text stable fixture text\n33bt|prose-033 stable fixture text stable fixture text stable fixture text stable fixture text\n34nu|prose-034 stable fixture text stable fixture text stable fixture text stable fixture text\n35mc|prose-035 stable fixture text stable fixture text stable fixture text stable fixture text\n36co|prose-036 stable fixture text stable fixture text stable fixture text stable fixture text\n37pz|prose-037 stable fixture text stable fixture text stable fixture text stable fixture text\n38us|prose-038 stable fixture text stable fixture text stable fixture text stable fixture text\n39kk|prose-039 stable fixture text stable fixture text stable fixture text stable fixture text\n40by|prose-040 stable fixture text stable fixture text stable fixture text stable fixture text\n41jd|prose-041 stable fixture text stable fixture text stable fixture text stable fixture text\n42vs|prose-042 stable fixture text stable fixture text stable fixture text stable fixture text\n43nn|prose-043 stable fixture text stable fixture text stable fixture text stable fixture text\n44jf|prose-044 stable fixture text stable fixture text stable fixture text stable fixture text\n45tf|prose-045 stable fixture text stable fixture text stable fixture text stable fixture text\n46ll|prose-046 stable fixture text stable fixture text stable fixture text stable fixture text\n47tb|prose-047 stable fixture text stable fixture text stable fixture text stable fixture text\n48hb|prose-048 stable fixture text stable fixture text stable fixture text stable fixture text\n49nr|prose-049 stable fixture text stable fixture text stable fixture text stable fixture text\n50cp|prose-050 stable fixture text stable fixture text stable fixture text stable fixture text\n\n[Showing first 50 of 320 lines (~5 KiB); re-read {TMP}/prose.txt:1-320 or {TMP}/prose.txt:raw for the full file]\n\n[Showing lines 1-50 of 320. Use :51 to continue]", + "displayContent": { + "text": "prose-001 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-002 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-003 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-004 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-005 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-006 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-007 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-008 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-009 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-010 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-011 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-012 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-013 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-014 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-015 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-016 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-017 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-018 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-019 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-020 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-021 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-022 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-023 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-024 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-025 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-026 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-027 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-028 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-029 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-030 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-031 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-032 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-033 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-034 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-035 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-036 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-037 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-038 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-039 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-040 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-041 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-042 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-043 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-044 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-045 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-046 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-047 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-048 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-049 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-050 stable fixture text stable fixture text stable fixture text stable fixture text", + "startLine": 1 + }, + "truncation": { + "content": "prose-001 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-002 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-003 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-004 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-005 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-006 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-007 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-008 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-009 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-010 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-011 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-012 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-013 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-014 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-015 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-016 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-017 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-018 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-019 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-020 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-021 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-022 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-023 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-024 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-025 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-026 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-027 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-028 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-029 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-030 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-031 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-032 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-033 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-034 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-035 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-036 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-037 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-038 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-039 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-040 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-041 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-042 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-043 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-044 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-045 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-046 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-047 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-048 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-049 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-050 stable fixture text stable fixture text stable fixture text stable fixture text", + "truncated": true, + "truncatedBy": "lines", + "totalLines": 320, + "totalBytes": 4499, + "outputLines": 50, + "outputBytes": 4499, + "lastLinePartial": false, + "firstLineExceedsLimit": false + }, + "meta": { + "source": { + "type": "path", + "value": "{TMP}/prose.txt" + }, + "truncation": { + "direction": "head", + "truncatedBy": "lines", + "totalLines": 320, + "totalBytes": 4499, + "outputLines": 50, + "outputBytes": 4499, + "shownRange": { + "start": 1, + "end": 50 + }, + "nextOffset": 51 + } + } +} diff --git a/packages/coding-agent/test/fixtures/read-goldens/changed/prompt-read-head.plain.json b/packages/coding-agent/test/fixtures/read-goldens/changed/prompt-read-head.plain.json new file mode 100644 index 00000000000..72f6d118e91 --- /dev/null +++ b/packages/coding-agent/test/fixtures/read-goldens/changed/prompt-read-head.plain.json @@ -0,0 +1,37 @@ +{ + "text": "prose-001 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-002 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-003 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-004 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-005 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-006 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-007 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-008 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-009 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-010 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-011 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-012 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-013 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-014 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-015 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-016 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-017 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-018 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-019 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-020 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-021 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-022 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-023 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-024 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-025 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-026 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-027 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-028 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-029 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-030 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-031 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-032 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-033 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-034 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-035 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-036 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-037 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-038 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-039 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-040 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-041 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-042 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-043 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-044 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-045 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-046 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-047 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-048 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-049 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-050 stable fixture text stable fixture text stable fixture text stable fixture text\n\n[Showing first 50 of 320 lines (~5 KiB); re-read {TMP}/prose.txt:1-320 or {TMP}/prose.txt:raw for the full file]\n\n[Showing lines 1-50 of 320. Use :51 to continue]", + "displayContent": { + "text": "prose-001 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-002 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-003 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-004 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-005 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-006 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-007 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-008 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-009 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-010 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-011 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-012 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-013 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-014 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-015 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-016 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-017 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-018 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-019 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-020 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-021 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-022 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-023 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-024 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-025 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-026 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-027 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-028 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-029 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-030 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-031 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-032 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-033 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-034 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-035 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-036 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-037 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-038 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-039 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-040 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-041 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-042 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-043 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-044 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-045 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-046 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-047 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-048 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-049 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-050 stable fixture text stable fixture text stable fixture text stable fixture text", + "startLine": 1 + }, + "truncation": { + "content": "prose-001 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-002 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-003 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-004 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-005 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-006 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-007 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-008 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-009 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-010 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-011 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-012 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-013 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-014 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-015 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-016 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-017 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-018 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-019 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-020 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-021 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-022 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-023 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-024 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-025 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-026 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-027 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-028 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-029 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-030 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-031 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-032 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-033 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-034 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-035 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-036 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-037 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-038 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-039 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-040 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-041 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-042 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-043 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-044 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-045 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-046 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-047 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-048 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-049 stable fixture text stable fixture text stable fixture text stable fixture text\nprose-050 stable fixture text stable fixture text stable fixture text stable fixture text", + "truncated": true, + "truncatedBy": "lines", + "totalLines": 320, + "totalBytes": 4499, + "outputLines": 50, + "outputBytes": 4499, + "lastLinePartial": false, + "firstLineExceedsLimit": false + }, + "meta": { + "source": { + "type": "path", + "value": "{TMP}/prose.txt" + }, + "truncation": { + "direction": "head", + "truncatedBy": "lines", + "totalLines": 320, + "totalBytes": 4499, + "outputLines": 50, + "outputBytes": 4499, + "shownRange": { + "start": 1, + "end": 50 + }, + "nextOffset": 51 + } + } +} diff --git a/packages/coding-agent/test/fixtures/read-goldens/manifest.json b/packages/coding-agent/test/fixtures/read-goldens/manifest.json index b7307c8881b..adef3dd9060 100644 --- a/packages/coding-agent/test/fixtures/read-goldens/manifest.json +++ b/packages/coding-agent/test/fixtures/read-goldens/manifest.json @@ -3,343 +3,636 @@ "bucket": "invariant", "phase": 0, "name": "summary-ts", - "request": { "path": "{FIXTURE}/summary.ts" }, - "settings": { "read.summarize.enabled": true }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/summary.ts" + }, + "settings": { + "read.summarize.enabled": true + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "range-50-200", - "request": { "path": "{FIXTURE}/prose.txt:50-200" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/prose.txt:50-200" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "range-50-plus-150", - "request": { "path": "{FIXTURE}/prose.txt:50+150" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/prose.txt:50+150" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "raw-small", - "request": { "path": "{FIXTURE}/raw-small.txt:raw" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/raw-small.txt:raw" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "raw-range", - "request": { "path": "{FIXTURE}/prose.txt:2-4:raw" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/prose.txt:2-4:raw" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "multi-range", - "request": { "path": "{FIXTURE}/prose.txt:5-16,60-73" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/prose.txt:5-16,60-73" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "conflicts-index", - "request": { "path": "{FIXTURE}/conflicts.txt:conflicts" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/conflicts.txt:conflicts" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "out-of-bounds", - "request": { "path": "{FIXTURE}/ten-lines.txt:900-999" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/ten-lines.txt:900-999" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "local-bare-head", - "request": { "path": "{FIXTURE}/prose.txt", "truncation": "head" }, - "capturedAs": { "path": "{FIXTURE}/prose.txt" }, - "settings": { "read.summarize.enabled": false, "read.truncation": "head" }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/prose.txt", + "truncation": "head" + }, + "capturedAs": { + "path": "{FIXTURE}/prose.txt" + }, + "settings": { + "read.summarize.enabled": false, + "read.truncation": "head" + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "url-page", - "request": { "path": "https://8.8.8.8/read-goldens" }, - "settings": { "read.summarize.enabled": false, "fetch.enabled": true }, - "hashLines": [true, false] + "request": { + "path": "https://8.8.8.8/read-goldens" + }, + "settings": { + "read.summarize.enabled": false, + "fetch.enabled": true + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "dir-local", - "request": { "path": "{FIXTURE}/local-tree" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/local-tree" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "dir-archive", - "request": { "path": "{FIXTURE}/bundle.tar.gz" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/bundle.tar.gz" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "markit-bare", - "request": { "path": "{FIXTURE}/markit.pdf" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/markit.pdf" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "markit-ranged", - "request": { "path": "{FIXTURE}/markit.pdf:2-4" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/markit.pdf:2-4" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "notebook-bare", - "request": { "path": "{FIXTURE}/notebook.ipynb" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/notebook.ipynb" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "notebook-ranged", - "request": { "path": "{FIXTURE}/notebook.ipynb:2-6" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/notebook.ipynb:2-6" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "internal-artifact", - "request": { "path": "artifact://0" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "artifact://0" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "internal-artifact-ranged", - "request": { "path": "artifact://0:2-4" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "artifact://0:2-4" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "sqlite-list", - "request": { "path": "{FIXTURE}/app.sqlite" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/app.sqlite" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "sqlite-schema", - "request": { "path": "{FIXTURE}/app.sqlite:users" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/app.sqlite:users" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "sqlite-row", - "request": { "path": "{FIXTURE}/app.sqlite:users:2" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/app.sqlite:users:2" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "sqlite-query", - "request": { "path": "{FIXTURE}/app.sqlite:users?limit=2&offset=1" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/app.sqlite:users?limit=2&offset=1" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "sqlite-raw", - "request": { "path": "{FIXTURE}/app.sqlite?q=SELECT+name+FROM+users+ORDER+BY+id" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/app.sqlite?q=SELECT+name+FROM+users+ORDER+BY+id" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "archive-member-ranged", - "request": { "path": "{FIXTURE}/bundle.tar.gz:docs/poem.txt:2-4" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/bundle.tar.gz:docs/poem.txt:2-4" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "archive-large-ranged", - "request": { "path": "{FIXTURE}/bundle.tar.gz:docs/large-lines.txt:1-3200" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/bundle.tar.gz:docs/large-lines.txt:1-3200" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "archive-bytes-ranged", - "request": { "path": "{FIXTURE}/bundle.tar.gz:docs/bytes.txt:1-120" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/bundle.tar.gz:docs/bytes.txt:1-120" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "archive-dir-nested", - "request": { "path": "{FIXTURE}/bundle.tar.gz:nested" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/bundle.tar.gz:nested" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "empty-file", - "request": { "path": "{FIXTURE}/empty.txt" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/empty.txt" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "invariant", "phase": 0, "name": "single-line-no-newline", - "request": { "path": "{FIXTURE}/single-line.txt" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/single-line.txt" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "changed", "phase": 1, "name": "local-bare-tail", - "request": { "path": "{FIXTURE}/prose.txt" }, - - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false], + "request": { + "path": "{FIXTURE}/prose.txt" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ], "reason": "The default bare-read window changes from the first lines to the last lines." }, { "bucket": "changed", "phase": 1, "name": "archive-member-bare-tail", - "request": { "path": "{FIXTURE}/bundle.tar.gz:docs/large-lines.txt" }, - - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false], + "request": { + "path": "{FIXTURE}/bundle.tar.gz:docs/large-lines.txt" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ], "reason": "Archive member bare reads adopt tail truncation and a new recovery footer." }, { "bucket": "changed", "phase": 1, "name": "acp-bare-parity", - "request": { "path": "{FIXTURE}/prose.txt" }, - - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false], + "request": { + "path": "{FIXTURE}/prose.txt" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ], "reason": "ACP-backed bare reads use the baseline 3000-line/50 KiB directional budget; disk receipts retain their 50-line/10 KiB budget." }, { "bucket": "changed", "phase": 1, "name": "local-bare-notice-owner", - "request": { "path": "{FIXTURE}/prose.txt" }, - - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false], + "request": { + "path": "{FIXTURE}/prose.txt" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ], "reason": "The wrapper no longer owns the bare-read truncation notice." }, { "bucket": "changed", "phase": 1, "name": "giant-single-line", - "request": { "path": "{FIXTURE}/giant-single-line.txt" }, - - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false], + "request": { + "path": "{FIXTURE}/giant-single-line.txt" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ], "reason": "A 6000-emoji single line changes to a tail snippet instead of a head snippet." }, { "bucket": "changed", "phase": 1, "name": "local-bare-both", - "request": { "path": "{FIXTURE}/prose.txt", "truncation": "both" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false], + "request": { + "path": "{FIXTURE}/prose.txt", + "truncation": "both" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ], "reason": "Bare reads gain the new both-direction truncation mode." }, { "bucket": "changed", "phase": 1, "name": "giant-last-partial", - "request": { "path": "{FIXTURE}/giant-single-line.txt", "truncation": "last" }, - - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false], + "request": { + "path": "{FIXTURE}/giant-single-line.txt", + "truncation": "last" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ], "reason": "Tail-partial mode changes oversized single-line output semantics." }, { "bucket": "changed", "phase": 1, "name": "range-tail", - "request": { "path": "{FIXTURE}/range-tail.txt:1-120", "truncation": "last" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false], + "request": { + "path": "{FIXTURE}/range-tail.txt:1-120", + "truncation": "last" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ], "reason": "An explicit range with truncation=last retains the end of the selected window." }, { - "bucket": "surface", + "bucket": "changed", "phase": 1, "name": "prompt-read-default", - "request": { "path": "{FIXTURE}/prose.txt" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/prose.txt" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ], + "reason": "The read tool description prompt changed." }, { - "bucket": "surface", + "bucket": "changed", "phase": 1, "name": "prompt-read-head", - "request": { "path": "{FIXTURE}/prose.txt", "truncation": "head" }, - "settings": { "read.summarize.enabled": false, "read.truncation": "head" }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/prose.txt", + "truncation": "head" + }, + "settings": { + "read.summarize.enabled": false, + "read.truncation": "head" + }, + "hashLines": [ + true, + false + ], + "reason": "The read tool description prompt changed." }, { "bucket": "surface", "phase": 1, "name": "docs-read-flow-line", - "request": { "path": "{FIXTURE}/docs-read-flow.md:50" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/docs-read-flow.md:50" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] }, { "bucket": "surface", "phase": 1, "name": "cli-read-help", - "request": { "path": "{FIXTURE}/cli-read-help.txt" }, - "settings": { "read.summarize.enabled": false }, - "hashLines": [true, false] + "request": { + "path": "{FIXTURE}/cli-read-help.txt" + }, + "settings": { + "read.summarize.enabled": false + }, + "hashLines": [ + true, + false + ] } ] diff --git a/packages/coding-agent/test/fixtures/read-goldens/surface/prompt-read-default.hl.json b/packages/coding-agent/test/fixtures/read-goldens/surface/prompt-read-default.hl.json deleted file mode 100644 index 3ffaff33ea2..00000000000 --- a/packages/coding-agent/test/fixtures/read-goldens/surface/prompt-read-default.hl.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "text": "Read files, directories, archives, SQLite databases, images, documents, internal resources, and web URLs through a single `path` string.\n\n\n- One tool for filesystem, archives, SQLite, images, documents (PDF/DOCX/PPTX/XLSX/RTF/EPUB/ipynb), internal URIs, and web URLs (reader-mode by default).\n- You SHOULD parallelize independent reads when exploring related files.\n- You SHOULD reach for `read` — not a browser/puppeteer tool — for fetching web content.\n\n\n## Parameters\n\n- `path` — required. Local path, internal URI (`agent://`, `artifact://`, `rule://`, `local://`), or URL. Append `:` for line ranges, raw mode, or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).\n- `truncation` — optional `head` | `last` | `both`; selects which end of an over-budget result to retain. Configured default: last (factory default: `last`); structural summaries and routes such as URLs, directories and converted documents default to `head`. A line-range selector still bounds the selection — this only picks which end of that selection survives the byte/line cap. SQLite row queries page via their own `limit`/`offset` and ignore it.\n## Selectors\nAppend `:` to `path`. The bare path falls back to the default mode.\n\n- _(none)_ — parseable code → structural summary (signatures kept, bodies elided); a plain text file → a bounded receipt of about 50 lines or 10 KiB, whichever is smaller; the configured truncation direction is last (factory default: last). Line+hash anchors keep their real file line numbers and a footer names the omitted range. Archive members use the larger 3000-line / 50 KiB budget. Converted documents, notebooks, URLs and directory listings still start from the beginning.\n- `:50` / `:50-` — read from line 50 onward.\n- `:50-200` — lines 50–200 inclusive.\n- `:50+150` — 150 lines starting at line 50.\n- `:20+1` — exactly one line.\n- `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged).\n- `:raw` — verbatim text; no anchors, no summary, no line prefixes.\n- `:2-4:raw` or `:raw:2-4` — range AND verbatim; the two compose in either order.\n- `:conflicts` — one-line-per-block index of every unresolved git merge conflict.\n\n# Files\n\n- Reading a directory path returns a depth-limited dirent listing.\n- Reading a file with an explicit selector returns lines prefixed with `line+hash` anchors: `41th|def alpha():`. The 2-char hash is a content fingerprint that `edit` / `apply_patch` consume — copy it verbatim, NEVER fabricate. The pipe character after the hash is a separator, not part of the file content.\n- Parseable code without a selector returns a **structural summary**: declarations kept, large bodies collapsed to `..` (merged brace pair) or `…` (standalone). Summarized output ends with a footer of the form:\n\n `[NN lines across MM elided regions; read :raw or a line range like :1-9999 for verbatim content]`\n\n If the elided body is what you actually need, re-issue the **exact selector the footer names**. NEVER guess what's inside `..` / `…` — those markers carry no content.\n- Structural summaries honor explicit `truncation`: `head` retains the beginning, `last` the end, and `both` both ends within the summary byte budget. Complete summary units and their original source anchors stay intact and in source order; budget omissions are marked where they occur. This does not switch to raw source. Summaries that fit are unchanged by direction.\n- Directional windows identify the retained first/last lines and the omitted range; use the `re-read :1-` or `:raw` hint in the footer to recover the full content.\n\n# Documents & Notebooks\n\nExtracts text from PDF, Word, PowerPoint, Excel, RTF, and EPUB. Notebooks (`.ipynb`) are shown as editable `# %% [type] cell:N` text; edits round-trip back to the underlying JSON preserving notebook metadata. Add `:raw` to a notebook to bypass the converter and read the JSON directly.\n\n# Images\n\nReading an image path returns the image itself for visual inspection by a vision-capable model.\n\n# Archives\n\nSupports `.tar`, `.tar.gz`, `.tgz`, `.zip`. Use `archive.ext:path/inside/archive` to read a member, and append a normal selector to the inner path: `archive.zip:dir/file.ts:50-60`.\n\n# SQLite\n\nFor `.sqlite`, `.sqlite3`, `.db`, `.db3`:\n- `file.db` — list tables with row counts\n- `file.db:table` — schema + sample rows\n- `file.db:table:key` — single row by primary key\n- `file.db:table?limit=50&offset=100` — paginated rows\n- `file.db:table?where=status='active'&order=created:desc` — filtered rows\n- `file.db?q=SELECT …` — read-only SELECT query\n\n# URLs\n\n- Default reader-mode: HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs → clean text/markdown.\n- `:raw` returns untouched HTML; line selectors (`:50`, `:50-100`, `:50+150`) paginate the cached fetched output.\n- Bare `host:port` URLs collide with the selector grammar — add a trailing slash before the selector: `https://example.com/:80`.\n\n# Internal URIs\n\n`agent://`, `artifact://`, `rule://`, and `local://.md` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://` to recover full output that a previous bash/eval/tool result spilled or truncated.\n\nBundled skills have no filesystem home, so the skill tool and skill discovery report them as `embedded:gjc/skills//SKILL.md`; read that identifier verbatim. Bundled skill fragments, when surfaced, likewise use `embedded:gjc/skill-fragments/...` identifiers.\n\n\n- Always include `path`; never call `read` with `{}`.\n- For line ranges, append the selector to `path`.\n- Re-issue the selector named by a summary footer before relying on elided content.\n", - "displayContent": null, - "truncation": null, - "meta": null -} diff --git a/packages/coding-agent/test/fixtures/read-goldens/surface/prompt-read-default.plain.json b/packages/coding-agent/test/fixtures/read-goldens/surface/prompt-read-default.plain.json deleted file mode 100644 index 43b74211e30..00000000000 --- a/packages/coding-agent/test/fixtures/read-goldens/surface/prompt-read-default.plain.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "text": "Read files, directories, archives, SQLite databases, images, documents, internal resources, and web URLs through a single `path` string.\n\n\n- One tool for filesystem, archives, SQLite, images, documents (PDF/DOCX/PPTX/XLSX/RTF/EPUB/ipynb), internal URIs, and web URLs (reader-mode by default).\n- You SHOULD parallelize independent reads when exploring related files.\n- You SHOULD reach for `read` — not a browser/puppeteer tool — for fetching web content.\n\n\n## Parameters\n\n- `path` — required. Local path, internal URI (`agent://`, `artifact://`, `rule://`, `local://`), or URL. Append `:` for line ranges, raw mode, or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).\n- `truncation` — optional `head` | `last` | `both`; selects which end of an over-budget result to retain. Configured default: last (factory default: `last`); structural summaries and routes such as URLs, directories and converted documents default to `head`. A line-range selector still bounds the selection — this only picks which end of that selection survives the byte/line cap. SQLite row queries page via their own `limit`/`offset` and ignore it.\n## Selectors\nAppend `:` to `path`. The bare path falls back to the default mode.\n\n- _(none)_ — parseable code → structural summary (signatures kept, bodies elided); a plain text file → a bounded receipt of about 50 lines or 10 KiB, whichever is smaller; the configured truncation direction is last (factory default: last). Line+hash anchors keep their real file line numbers and a footer names the omitted range. Archive members use the larger 3000-line / 50 KiB budget. Converted documents, notebooks, URLs and directory listings still start from the beginning.\n- `:50` / `:50-` — read from line 50 onward.\n- `:50-200` — lines 50–200 inclusive.\n- `:50+150` — 150 lines starting at line 50.\n- `:20+1` — exactly one line.\n- `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged).\n- `:raw` — verbatim text; no anchors, no summary, no line prefixes.\n- `:2-4:raw` or `:raw:2-4` — range AND verbatim; the two compose in either order.\n- `:conflicts` — one-line-per-block index of every unresolved git merge conflict.\n\n# Files\n\n- Reading a directory path returns a depth-limited dirent listing.\n- Parseable code without a selector returns a **structural summary**: declarations kept, large bodies collapsed to `..` (merged brace pair) or `…` (standalone). Summarized output ends with a footer of the form:\n\n `[NN lines across MM elided regions; read :raw or a line range like :1-9999 for verbatim content]`\n\n If the elided body is what you actually need, re-issue the **exact selector the footer names**. NEVER guess what's inside `..` / `…` — those markers carry no content.\n- Structural summaries honor explicit `truncation`: `head` retains the beginning, `last` the end, and `both` both ends within the summary byte budget. Complete summary units and their original source anchors stay intact and in source order; budget omissions are marked where they occur. This does not switch to raw source. Summaries that fit are unchanged by direction.\n- Directional windows identify the retained first/last lines and the omitted range; use the `re-read :1-` or `:raw` hint in the footer to recover the full content.\n\n# Documents & Notebooks\n\nExtracts text from PDF, Word, PowerPoint, Excel, RTF, and EPUB. Notebooks (`.ipynb`) are shown as editable `# %% [type] cell:N` text; edits round-trip back to the underlying JSON preserving notebook metadata. Add `:raw` to a notebook to bypass the converter and read the JSON directly.\n\n# Images\n\nReading an image path returns the image itself for visual inspection by a vision-capable model.\n\n# Archives\n\nSupports `.tar`, `.tar.gz`, `.tgz`, `.zip`. Use `archive.ext:path/inside/archive` to read a member, and append a normal selector to the inner path: `archive.zip:dir/file.ts:50-60`.\n\n# SQLite\n\nFor `.sqlite`, `.sqlite3`, `.db`, `.db3`:\n- `file.db` — list tables with row counts\n- `file.db:table` — schema + sample rows\n- `file.db:table:key` — single row by primary key\n- `file.db:table?limit=50&offset=100` — paginated rows\n- `file.db:table?where=status='active'&order=created:desc` — filtered rows\n- `file.db?q=SELECT …` — read-only SELECT query\n\n# URLs\n\n- Default reader-mode: HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs → clean text/markdown.\n- `:raw` returns untouched HTML; line selectors (`:50`, `:50-100`, `:50+150`) paginate the cached fetched output.\n- Bare `host:port` URLs collide with the selector grammar — add a trailing slash before the selector: `https://example.com/:80`.\n\n# Internal URIs\n\n`agent://`, `artifact://`, `rule://`, and `local://.md` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://` to recover full output that a previous bash/eval/tool result spilled or truncated.\n\nBundled skills have no filesystem home, so the skill tool and skill discovery report them as `embedded:gjc/skills//SKILL.md`; read that identifier verbatim. Bundled skill fragments, when surfaced, likewise use `embedded:gjc/skill-fragments/...` identifiers.\n\n\n- Always include `path`; never call `read` with `{}`.\n- For line ranges, append the selector to `path`.\n- Re-issue the selector named by a summary footer before relying on elided content.\n", - "displayContent": null, - "truncation": null, - "meta": null -} diff --git a/packages/coding-agent/test/fixtures/read-goldens/surface/prompt-read-head.hl.json b/packages/coding-agent/test/fixtures/read-goldens/surface/prompt-read-head.hl.json deleted file mode 100644 index 5cd1ef38527..00000000000 --- a/packages/coding-agent/test/fixtures/read-goldens/surface/prompt-read-head.hl.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "text": "Read files, directories, archives, SQLite databases, images, documents, internal resources, and web URLs through a single `path` string.\n\n\n- One tool for filesystem, archives, SQLite, images, documents (PDF/DOCX/PPTX/XLSX/RTF/EPUB/ipynb), internal URIs, and web URLs (reader-mode by default).\n- You SHOULD parallelize independent reads when exploring related files.\n- You SHOULD reach for `read` — not a browser/puppeteer tool — for fetching web content.\n\n\n## Parameters\n\n- `path` — required. Local path, internal URI (`agent://`, `artifact://`, `rule://`, `local://`), or URL. Append `:` for line ranges, raw mode, or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).\n- `truncation` — optional `head` | `last` | `both`; selects which end of an over-budget result to retain. Configured default: head (factory default: `last`); structural summaries and routes such as URLs, directories and converted documents default to `head`. A line-range selector still bounds the selection — this only picks which end of that selection survives the byte/line cap. SQLite row queries page via their own `limit`/`offset` and ignore it.\n## Selectors\nAppend `:` to `path`. The bare path falls back to the default mode.\n\n- _(none)_ — parseable code → structural summary (signatures kept, bodies elided); a plain text file → a bounded receipt of about 50 lines or 10 KiB, whichever is smaller; the configured truncation direction is head (factory default: last). Line+hash anchors keep their real file line numbers and a footer names the omitted range. Archive members use the larger 3000-line / 50 KiB budget. Converted documents, notebooks, URLs and directory listings still start from the beginning.\n- `:50` / `:50-` — read from line 50 onward.\n- `:50-200` — lines 50–200 inclusive.\n- `:50+150` — 150 lines starting at line 50.\n- `:20+1` — exactly one line.\n- `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged).\n- `:raw` — verbatim text; no anchors, no summary, no line prefixes.\n- `:2-4:raw` or `:raw:2-4` — range AND verbatim; the two compose in either order.\n- `:conflicts` — one-line-per-block index of every unresolved git merge conflict.\n\n# Files\n\n- Reading a directory path returns a depth-limited dirent listing.\n- Reading a file with an explicit selector returns lines prefixed with `line+hash` anchors: `41th|def alpha():`. The 2-char hash is a content fingerprint that `edit` / `apply_patch` consume — copy it verbatim, NEVER fabricate. The pipe character after the hash is a separator, not part of the file content.\n- Parseable code without a selector returns a **structural summary**: declarations kept, large bodies collapsed to `..` (merged brace pair) or `…` (standalone). Summarized output ends with a footer of the form:\n\n `[NN lines across MM elided regions; read :raw or a line range like :1-9999 for verbatim content]`\n\n If the elided body is what you actually need, re-issue the **exact selector the footer names**. NEVER guess what's inside `..` / `…` — those markers carry no content.\n- Structural summaries honor explicit `truncation`: `head` retains the beginning, `last` the end, and `both` both ends within the summary byte budget. Complete summary units and their original source anchors stay intact and in source order; budget omissions are marked where they occur. This does not switch to raw source. Summaries that fit are unchanged by direction.\n- Directional windows identify the retained first/last lines and the omitted range; use the `re-read :1-` or `:raw` hint in the footer to recover the full content.\n\n# Documents & Notebooks\n\nExtracts text from PDF, Word, PowerPoint, Excel, RTF, and EPUB. Notebooks (`.ipynb`) are shown as editable `# %% [type] cell:N` text; edits round-trip back to the underlying JSON preserving notebook metadata. Add `:raw` to a notebook to bypass the converter and read the JSON directly.\n\n# Images\n\nReading an image path returns the image itself for visual inspection by a vision-capable model.\n\n# Archives\n\nSupports `.tar`, `.tar.gz`, `.tgz`, `.zip`. Use `archive.ext:path/inside/archive` to read a member, and append a normal selector to the inner path: `archive.zip:dir/file.ts:50-60`.\n\n# SQLite\n\nFor `.sqlite`, `.sqlite3`, `.db`, `.db3`:\n- `file.db` — list tables with row counts\n- `file.db:table` — schema + sample rows\n- `file.db:table:key` — single row by primary key\n- `file.db:table?limit=50&offset=100` — paginated rows\n- `file.db:table?where=status='active'&order=created:desc` — filtered rows\n- `file.db?q=SELECT …` — read-only SELECT query\n\n# URLs\n\n- Default reader-mode: HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs → clean text/markdown.\n- `:raw` returns untouched HTML; line selectors (`:50`, `:50-100`, `:50+150`) paginate the cached fetched output.\n- Bare `host:port` URLs collide with the selector grammar — add a trailing slash before the selector: `https://example.com/:80`.\n\n# Internal URIs\n\n`agent://`, `artifact://`, `rule://`, and `local://.md` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://` to recover full output that a previous bash/eval/tool result spilled or truncated.\n\nBundled skills have no filesystem home, so the skill tool and skill discovery report them as `embedded:gjc/skills//SKILL.md`; read that identifier verbatim. Bundled skill fragments, when surfaced, likewise use `embedded:gjc/skill-fragments/...` identifiers.\n\n\n- Always include `path`; never call `read` with `{}`.\n- For line ranges, append the selector to `path`.\n- Re-issue the selector named by a summary footer before relying on elided content.\n", - "displayContent": null, - "truncation": null, - "meta": null -} diff --git a/packages/coding-agent/test/fixtures/read-goldens/surface/prompt-read-head.plain.json b/packages/coding-agent/test/fixtures/read-goldens/surface/prompt-read-head.plain.json deleted file mode 100644 index ee2d6aa5705..00000000000 --- a/packages/coding-agent/test/fixtures/read-goldens/surface/prompt-read-head.plain.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "text": "Read files, directories, archives, SQLite databases, images, documents, internal resources, and web URLs through a single `path` string.\n\n\n- One tool for filesystem, archives, SQLite, images, documents (PDF/DOCX/PPTX/XLSX/RTF/EPUB/ipynb), internal URIs, and web URLs (reader-mode by default).\n- You SHOULD parallelize independent reads when exploring related files.\n- You SHOULD reach for `read` — not a browser/puppeteer tool — for fetching web content.\n\n\n## Parameters\n\n- `path` — required. Local path, internal URI (`agent://`, `artifact://`, `rule://`, `local://`), or URL. Append `:` for line ranges, raw mode, or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).\n- `truncation` — optional `head` | `last` | `both`; selects which end of an over-budget result to retain. Configured default: head (factory default: `last`); structural summaries and routes such as URLs, directories and converted documents default to `head`. A line-range selector still bounds the selection — this only picks which end of that selection survives the byte/line cap. SQLite row queries page via their own `limit`/`offset` and ignore it.\n## Selectors\nAppend `:` to `path`. The bare path falls back to the default mode.\n\n- _(none)_ — parseable code → structural summary (signatures kept, bodies elided); a plain text file → a bounded receipt of about 50 lines or 10 KiB, whichever is smaller; the configured truncation direction is head (factory default: last). Line+hash anchors keep their real file line numbers and a footer names the omitted range. Archive members use the larger 3000-line / 50 KiB budget. Converted documents, notebooks, URLs and directory listings still start from the beginning.\n- `:50` / `:50-` — read from line 50 onward.\n- `:50-200` — lines 50–200 inclusive.\n- `:50+150` — 150 lines starting at line 50.\n- `:20+1` — exactly one line.\n- `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged).\n- `:raw` — verbatim text; no anchors, no summary, no line prefixes.\n- `:2-4:raw` or `:raw:2-4` — range AND verbatim; the two compose in either order.\n- `:conflicts` — one-line-per-block index of every unresolved git merge conflict.\n\n# Files\n\n- Reading a directory path returns a depth-limited dirent listing.\n- Parseable code without a selector returns a **structural summary**: declarations kept, large bodies collapsed to `..` (merged brace pair) or `…` (standalone). Summarized output ends with a footer of the form:\n\n `[NN lines across MM elided regions; read :raw or a line range like :1-9999 for verbatim content]`\n\n If the elided body is what you actually need, re-issue the **exact selector the footer names**. NEVER guess what's inside `..` / `…` — those markers carry no content.\n- Structural summaries honor explicit `truncation`: `head` retains the beginning, `last` the end, and `both` both ends within the summary byte budget. Complete summary units and their original source anchors stay intact and in source order; budget omissions are marked where they occur. This does not switch to raw source. Summaries that fit are unchanged by direction.\n- Directional windows identify the retained first/last lines and the omitted range; use the `re-read :1-` or `:raw` hint in the footer to recover the full content.\n\n# Documents & Notebooks\n\nExtracts text from PDF, Word, PowerPoint, Excel, RTF, and EPUB. Notebooks (`.ipynb`) are shown as editable `# %% [type] cell:N` text; edits round-trip back to the underlying JSON preserving notebook metadata. Add `:raw` to a notebook to bypass the converter and read the JSON directly.\n\n# Images\n\nReading an image path returns the image itself for visual inspection by a vision-capable model.\n\n# Archives\n\nSupports `.tar`, `.tar.gz`, `.tgz`, `.zip`. Use `archive.ext:path/inside/archive` to read a member, and append a normal selector to the inner path: `archive.zip:dir/file.ts:50-60`.\n\n# SQLite\n\nFor `.sqlite`, `.sqlite3`, `.db`, `.db3`:\n- `file.db` — list tables with row counts\n- `file.db:table` — schema + sample rows\n- `file.db:table:key` — single row by primary key\n- `file.db:table?limit=50&offset=100` — paginated rows\n- `file.db:table?where=status='active'&order=created:desc` — filtered rows\n- `file.db?q=SELECT …` — read-only SELECT query\n\n# URLs\n\n- Default reader-mode: HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs → clean text/markdown.\n- `:raw` returns untouched HTML; line selectors (`:50`, `:50-100`, `:50+150`) paginate the cached fetched output.\n- Bare `host:port` URLs collide with the selector grammar — add a trailing slash before the selector: `https://example.com/:80`.\n\n# Internal URIs\n\n`agent://`, `artifact://`, `rule://`, and `local://.md` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://` to recover full output that a previous bash/eval/tool result spilled or truncated.\n\nBundled skills have no filesystem home, so the skill tool and skill discovery report them as `embedded:gjc/skills//SKILL.md`; read that identifier verbatim. Bundled skill fragments, when surfaced, likewise use `embedded:gjc/skill-fragments/...` identifiers.\n\n\n- Always include `path`; never call `read` with `{}`.\n- For line ranges, append the selector to `path`.\n- Re-issue the selector named by a summary footer before relying on elided content.\n", - "displayContent": null, - "truncation": null, - "meta": null -} diff --git a/packages/coding-agent/test/harness-control-plane/session-lease.test.ts b/packages/coding-agent/test/harness-control-plane/session-lease.test.ts index a744f010f45..87148c49c8c 100644 --- a/packages/coding-agent/test/harness-control-plane/session-lease.test.ts +++ b/packages/coding-agent/test/harness-control-plane/session-lease.test.ts @@ -39,26 +39,25 @@ describe("SessionLease", () => { }); }); - it("heals an aged scrubbed removal transition instead of wedging the lease lock", async () => { + it("heals a scrubbed dead-owner removal transition through the lease lock", async () => { const filePath = sessionPaths(root, SID).lease; const orphanPath = `${filePath}.lock.removing`; + const infoPath = path.join(orphanPath, "info"); await mkdir(orphanPath, { recursive: true }); - await writeFile(path.join(orphanPath, "info"), "", "utf8"); - await writeFile(filePath, JSON.stringify({ ownerId: "owner-a" }), "utf8"); - const old = new Date(Date.now() - 120_000); - await utimes(path.join(orphanPath, "info"), old, old); - // A scrubbed transition retains the remover's sibling record. Age and - // zero-byte info alone cannot prove an ownerless generation abandoned. - const orphan = await stat(orphanPath, { bigint: true }); + await writeFile(infoPath, "", "utf8"); + const transition = await stat(orphanPath, { bigint: true }); + // The remover record is kept beside the scrubbed tree so recovery can prove + // this exact generation is dead without treating an ownerless empty info as proof. await writeFile( `${orphanPath}.owner`, JSON.stringify({ - owner: { pid: 2_147_483_647, timestamp: old.getTime(), owner_token: "dead-remover" }, - rootDev: orphan.dev.toString(), - rootIno: orphan.ino.toString(), + owner: { pid: 2_147_483_647, timestamp: Date.now(), owner_token: "dead-remover" }, + rootDev: transition.dev.toString(), + rootIno: transition.ino.toString(), }), "utf8", ); + await writeFile(filePath, JSON.stringify({ ownerId: "owner-a" }), "utf8"); await releaseLease(root, SID, "owner-a"); @@ -82,20 +81,40 @@ describe("SessionLease", () => { ).toBe(false); }); + it("does not release through a scrubbed transition with a live remover", async () => { + const filePath = sessionPaths(root, SID).lease; + const orphanPath = `${filePath}.lock.removing`; + const infoPath = path.join(orphanPath, "info"); + const leaseBytes = JSON.stringify({ ownerId: "owner-a" }); + await mkdir(orphanPath, { recursive: true }); + await writeFile(infoPath, "", "utf8"); + const transition = await stat(orphanPath, { bigint: true }); + const ownerRecord = JSON.stringify({ + owner: { pid: process.pid, timestamp: Date.now(), owner_token: "live-remover" }, + rootDev: transition.dev.toString(), + rootIno: transition.ino.toString(), + }); + await writeFile(`${orphanPath}.owner`, ownerRecord, "utf8"); + await writeFile(filePath, leaseBytes, "utf8"); + + await expect(releaseLease(root, SID, "owner-a")).rejects.toMatchObject({ code: "lease_lock_timeout" }); + expect(await readFile(filePath, "utf8")).toBe(leaseBytes); + expect(await readFile(infoPath, "utf8")).toBe(""); + expect(await readFile(`${orphanPath}.owner`, "utf8")).toBe(ownerRecord); + }); + it("preserves a refused orphan transition diagnostic instead of mapping it to lease timeout", async () => { const filePath = sessionPaths(root, SID).lease; const orphanPath = `${filePath}.lock.removing`; const infoPath = path.join(orphanPath, "info"); await mkdir(orphanPath, { recursive: true }); await writeFile(infoPath, "", "utf8"); - // An unscrubbed payload keeps the transition outside the proven native - // scrub residue, so acquisition must refuse adoption and keep the typed - // diagnostic instead of folding it into a retryable lease timeout. - await writeFile(path.join(orphanPath, "unretired-payload"), "not scrubbed", "utf8"); + // A zero-byte info file has no parsed owner identity. Age alone cannot + // prove that this ownerless transition was scrubbed, so it must remain + // fail-closed and retain the typed diagnostic. await writeFile(filePath, JSON.stringify({ ownerId: "owner-a" }), "utf8"); const old = new Date(Date.now() - 120_000); await utimes(infoPath, old, old); - await utimes(path.join(orphanPath, "unretired-payload"), old, old); const failure = await releaseLease(root, SID, "owner-a").catch(error => error); if (!(failure instanceof FileLockAcquireError)) throw new Error("Expected an orphan transition lock failure"); diff --git a/packages/coding-agent/test/runtime-mcp/mcp-project-host-values.test.ts b/packages/coding-agent/test/runtime-mcp/mcp-project-host-values.test.ts index 30c2f31a02d..5759b86c28a 100644 --- a/packages/coding-agent/test/runtime-mcp/mcp-project-host-values.test.ts +++ b/packages/coding-agent/test/runtime-mcp/mcp-project-host-values.test.ts @@ -9,6 +9,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { getAgentDir, getMCPConfigPath, getProjectDir, setAgentDir, setProjectDir } from "@gajae-code/utils"; +import { safeRm } from "../../../../scripts/safe-cleanup"; import { clearConfigValueCache } from "../../src/config/resolve-config-value"; import { MCPCommandController } from "../../src/modes/controllers/runtime-mcp-command-controller"; import { loadAllMCPConfigs } from "../../src/runtime-mcp/config"; @@ -91,8 +92,8 @@ describe("project MCP host values", () => { vi.restoreAllMocks(); clearConfigValueCache(); setAgentDir(originalAgentDir); - await fs.rm(projectDir, { recursive: true, force: true }); - await fs.rm(tempHome, { recursive: true, force: true }); + await safeRm(projectDir, { recursive: true, force: true }); + await safeRm(tempHome, { recursive: true, force: true }); }); async function writeJson(relOrAbs: string, content: unknown, root = projectDir): Promise { diff --git a/packages/coding-agent/test/session-storage.test.ts b/packages/coding-agent/test/session-storage.test.ts index 1314546db6f..96323e164f4 100644 --- a/packages/coding-agent/test/session-storage.test.ts +++ b/packages/coding-agent/test/session-storage.test.ts @@ -3138,7 +3138,7 @@ describe.skipIf(process.platform !== "linux")("managed descendant retained bindi return realClose.call(this); }); try { - expect(() => parent.deriveSubtree("derived")).toThrow("Managed descendant root binding changed"); + expect(() => parent.deriveSubtree("derived")).toThrow("Managed path contains symlink"); if (!retainedChild) throw new Error("Expected the real retained child authority"); expect(closeCalls.filter(authority => authority === retainedChild)).toHaveLength(1); expect(retainedChild.identity()).toMatchObject({ ok: false, code: "closed" }); diff --git a/packages/coding-agent/test/tools/python-tool-builtin.test.ts b/packages/coding-agent/test/tools/python-tool-builtin.test.ts index af0c0ffd932..2062fa756b3 100644 --- a/packages/coding-agent/test/tools/python-tool-builtin.test.ts +++ b/packages/coding-agent/test/tools/python-tool-builtin.test.ts @@ -74,7 +74,7 @@ function makeToolSession(options: { getSessionFile?: () => string | null; getSessionId?: () => string | null; settings?: Settings; - registerSessionCleanup?: (cleanup: () => Promise | void) => (() => void) | void; + registerSessionCleanup?: (cleanup: () => Promise | void) => (() => void) | undefined; assertEvalExecutionAllowed?: () => void; trackEvalExecution?: ToolSession["trackEvalExecution"]; }): ToolSession { @@ -107,7 +107,7 @@ async function loadPythonTool(options: { getSessionFile?: () => string | null; getSessionId?: () => string | null; settings?: Settings; - registerSessionCleanup?: (cleanup: () => Promise | void) => (() => void) | void; + registerSessionCleanup?: (cleanup: () => Promise | void) => (() => void) | undefined; assertEvalExecutionAllowed?: () => void; trackEvalExecution?: ToolSession["trackEvalExecution"]; }): Promise { diff --git a/packages/coding-agent/test/tools/read-goldens.test.ts b/packages/coding-agent/test/tools/read-goldens.test.ts index b6cfec45255..fba81a61ac2 100644 --- a/packages/coding-agent/test/tools/read-goldens.test.ts +++ b/packages/coding-agent/test/tools/read-goldens.test.ts @@ -31,8 +31,8 @@ type Variant = "hl" | "plain"; const EXPECTED_MANIFEST_COUNTS: Record = { invariant: 29, - changed: 8, - surface: 4, + changed: 10, + surface: 2, }; const EXPECTED_MANIFEST_NAMES: Record = { invariant: [ @@ -74,11 +74,13 @@ const EXPECTED_MANIFEST_NAMES: Record = { "giant-single-line", "local-bare-both", "giant-last-partial", + "prompt-read-default", + "prompt-read-head", "range-tail", ], - surface: ["prompt-read-default", "prompt-read-head", "docs-read-flow-line", "cli-read-help"], + surface: ["docs-read-flow-line", "cli-read-help"], }; -const EXPECTED_PHASE_ZERO_GOLDEN_FILES = 58; +const EXPECTED_PHASE_ZERO_GOLDEN_FILES = 58; // phase 0 only has invariant (29 * 2 variants) type Request = { path: string; [key: string]: unknown }; type Entry = { bucket: Bucket; diff --git a/packages/natives/native/diagnostic-artifact.json b/packages/natives/native/diagnostic-artifact.json index b9c537e2d44..0767779e724 100644 --- a/packages/natives/native/diagnostic-artifact.json +++ b/packages/natives/native/diagnostic-artifact.json @@ -3,6 +3,6 @@ "version": "0.18.8", "artifacts": { "pi_natives.darwin-arm64.node": "18cbb004b1fbda2d42eb8cb654d517985faa0ab305113ab4447f851c8b64820b", - "pi_natives.linux-x64-modern.node": "eb8d594762d104a31d103b1bdc0587adb0ebe1f01be80e0c2e2f473b10c73dde" + "pi_natives.linux-x64-modern.node": "4ce30236f23cfb773eac9e83a632a408b8d9af13155843ca387b6afe5fc6fe13" } }