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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog.d/workspace-empty-mount.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- `materialize` and `sync` took `"mount": ""` and `"path": ""` as no mount
or path given, and acted on every mount. Without a path, `"mount": ""` is
now refused, naming the mounts; `"path": ""` is refused whatever `"mount"`
holds. Each refusal acts on no mount and says what leaving the field out
covers: every mount, or for `"path"` beside a mount's name, that mount.
73 changes: 73 additions & 0 deletions changelog.d/workspace-reasons.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
- The workspace's reasons for what a push, a sync, a listing or a read
couldn't do now give the way on, or say who can fix it:
- A failed system call is said in plain words, with what helps: an
unlink's failure no longer quotes Node's message with the host's path,
and a code in parentheses became words.
- A push that failed after it began writing or unlinking, or whose
durability barrier failed, says that whether disk holds the change isn't
known until it's checked, and that a materialize of the path checks it
and says how it ended.
- What stands at a push's path or on its way, such as a symlink this mount
doesn't follow, one leading out of the mount, a pipe, a link to a
directory or a directory leading out of the mount, is named with what
gets past it, forced or not. A disk copy a push couldn't verify names
who can fix the cause. A reason that already names the path to use, a
nested mount's place or a link's target, gets nothing added, where "fix
it and materialize again" followed it before. A push of a file whose
stored copy is lost names the read, which takes disk's copy where it
can and otherwise says what can be done.
- A symlink among a path's directories that the mount doesn't follow names
the path to use where it leads to a directory inside the mount, and the
mount whose `followSymlinks` whoever runs the host can set; says that
nothing lies beneath one leading to something that isn't a directory;
and says what gets past one leading nowhere or somewhere its refusal
doesn't name. One leading outside the mount, or a path that leaves the
mount through a symlink, says that nothing done through the mount
reaches what it leads to, and that whoever runs the host can mount it,
or a directory that holds it. A directory on the way that the walk
can't ask gives its errno's way on. The tools quote the path they were
given before the link's reason. A symlink at a path the mount doesn't
follow says that whoever runs the host can set `followSymlinks` where it
leads inside the mount, mount what it leads to, or a directory that
holds it, where it leads outside, or remove it where it leads nowhere,
where before it
said to name the file it leads to by its own path, which the agent
couldn't learn and which may lie in no mount.
- `sync`'s skips, and the incomplete regions of `ls`, `sync`, `grep` and
an agent-action scan, give a way on where the walk failed, met a
change, found the root unavailable, met a link or a place leading out
of the mount or a link to a directory, or reached its cap. A `sync`
skip of a path its walk passed over, one the mount ignores, an editor's
swap or backup file, or one beneath a link the walk doesn't enter,
names the sync of that path, which checks it. A
listing's per-entry notes and `grep`'s per-file skips stay
descriptions.
- A mount whose path holds a file where its directory was is told, as one
whose directory is gone, that whoever runs the host can put the
directory back. A region the walk cap left says that naming a path
beneath it walks only that path, and that whoever runs the host can
raise the mount's `walkCap`.
- `read` of a file the workspace doesn't hold, where disk couldn't be
checked, gives the way on its reason calls for, as a push or a sync
gives it: an errno's, a symlink's, or that of a directory resolving
outside the mount.
- A direct mount's `delete` of a file that changed while it was checked
says that calling again checks it afresh.
- `save_recent_image`, where the store throws on a write, says the
store's errno in plain words with a way on, and still names the images
already saved. The error is logged whole, since an error it can't word
that way sends whoever runs the host to the log.
- A forced `materialize` without `applyDeletions` lists a workspace
deletion that no push makes, forced or not, such as one over or beneath
a symlink this mount doesn't follow, under `skipped` with why, rather
than among the deletions `applyDeletions` would make.
- **Hosts calling `readFileFromDisk`, `readBinaryFromDisk` or
`writeBinary`, or reading a scan's `incomplete`:** codes are unchanged.
The reason after `<path>: ` in a link refusal now ends with its way on:
"the mount's host can set followSymlinks: true" became `use "<target>"
in place of "<link>", or whoever runs this host can set followSymlinks:
true on mount "<mount>"`. A `writeBinary` failure quotes the push's
reasons in these words, and the `incomplete` reasons on `status`'s
`lastAgentActionScan` and the `workspace:agent-action-scan-incomplete`
event carry their way on, an errno in plain words. Code that matches on
those words needs updating.
11 changes: 10 additions & 1 deletion src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import { SEND_TOOLS, bareToolName } from './send-tools.js';
import { boundedErrorMessage, errorMessage } from './error-message.js';
import { ProviderWaits, EVERY_MODEL, isReleaseModel, withoutControls, type ProviderWait } from './provider-waits.js';
import type { WorkspaceModule } from './modules/workspace/index.js';
import { Refusal, errnoCode, errnoWayOn, systemWords } from './modules/workspace/answers.js';
import type { ToolResultGuard, WithheldSpill } from './tool-result-guard.js';
import {
goesNative,
Expand Down Expand Up @@ -33094,7 +33095,8 @@ export class AgentFramework {
// error says that whether the entry changed isn't known
// (writeBinary); the images before it are saved, and the
// failure says so, as a returned one does.
failed(error);
console.error(`[save_recent_image] writing "${destination}" failed:`, error);
failed(thrownWords(error));
return;
}
const inChronicle = (result.data as { inChronicle?: boolean } | undefined)?.inChronicle === true;
Expand Down Expand Up @@ -33576,3 +33578,10 @@ export class AgentFramework {
};
}
}

function thrownWords(error: unknown): string {
if (error instanceof Refusal) return error.message;
const code = errnoCode(error);
const on = errnoWayOn(code, "Calling again may work; if it fails the same way, whoever runs this host can find the whole error in this host's log.");
return `${systemWords(code, error).replace(/\.$/, '')}. ${on}`;
}
22 changes: 20 additions & 2 deletions src/modules/workspace/answers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ export function errnoWayOn(code: string | undefined, otherwise = 'Calling again
return otherwise;
}

export function errnoClause(code: string | undefined, again: string, then: string): string {
const lasting = `${again} won't change that; `;
if (code === 'ENAMETOOLONG') return `${lasting}a shorter name would get past it`;
const fix = hostFix(code);
if (fix !== undefined) {
return code !== undefined && PASSING.has(code)
? `${again} may get past it; if it fails the same way, whoever runs this host can ${fix}`
: `${lasting}whoever runs this host can ${fix}, and ${then}`;
}
return `${again} checks it afresh; if the same reason comes back, whoever runs this host can fix it on disk`;
}

const OS_ERRNO = new Map<number, string>();
for (const [name, number] of Object.entries(constants.errno)) if (!OS_ERRNO.has(number)) OS_ERRNO.set(number, name);

Expand All @@ -100,9 +112,15 @@ export function changesFiles(tool: string): boolean {
return CHANGING.has(tool);
}

export const ROOT_RECHECK = "Calling again may work; if it fails the same way, whoever runs this host can check the mount's directory and its path.";
export const UNFOLLOWED_STEP = 'whoever runs this host can set followSymlinks: true on this mount if it leads inside the mount, mount what it leads to, or a directory that holds it, if it leads outside, or remove it if it leads nowhere';

export const MOUNT_IT = 'whoever runs this host can mount it, or a directory that holds it';

export const OUTSIDE_STEP = `nothing done through this mount reaches what it leads to, and ${MOUNT_IT}`;

export const ROOT_UNCHECKED = "Calling again won't change that while the mount's directory is gone from its path, and checks it afresh otherwise; whoever runs this host can check the directory and its path.";
export const RESOLVED_OUTSIDE_STEP = 'nothing done through this mount reaches where it resolves, and whoever runs this host can mount that place';

export const ROOT_RECHECK = "Calling again may work; if it fails the same way, whoever runs this host can check the mount's directory and its path.";

export const AWAITS_REOPEN = "The workspace can't check its files against disk until its store is reopened, so calling again won't change that; whoever runs this host can reopen it with a restart.";

Expand Down
7 changes: 4 additions & 3 deletions src/modules/workspace/direct-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import type { BigIntStats } from 'node:fs';
import { lstat, open, realpath } from 'node:fs/promises';
import { relative, sep } from 'node:path';
import { refusedLanding } from './effects.js';
import { ROOT_RECHECK, errnoWayOn, errnoWords, onlyTheHost, systemWords } from './answers.js';
import { OUTSIDE_STEP, RESOLVED_OUTSIDE_STEP, ROOT_RECHECK, UNFOLLOWED_STEP, errnoWayOn, errnoWords, onlyTheHost, systemWords } from './answers.js';
import {
type CheckedDir,
type MountView,
Expand Down Expand Up @@ -135,7 +135,7 @@ export class WriteRefused extends Error {

const AFRESH = 'Calling again checks it afresh.';

const NAME_ITS_OWN = "Calling again won't change that; if you know the file it leads to, name that file by its own path.";
const NAME_ITS_OWN = `Calling again won't change that; ${UNFOLLOWED_STEP}.`;

function rootWayOn(code: string | undefined): string {
return code === 'ENOENT' || code === 'ENOTDIR' ? onlyTheHost("put the mount's directory back at its path") : errnoWayOn(code, ROOT_RECHECK, true);
Expand All @@ -145,7 +145,8 @@ function refusedWayOn(reason: string, code?: string, hostsName = false): string
if (reason === ROOT_UNAVAILABLE) return rootWayOn(code);
if (code !== undefined) return errnoWayOn(code, undefined, hostsName);
if (reason === NOT_FOLLOWED) return NAME_ITS_OWN;
if (reason === OUTSIDE_PARENT || reason === LEAVES_MOUNT) return "Calling again won't change that; the workspace writes only inside its mounts.";
if (reason === LEAVES_MOUNT) return `Calling again won't change that; ${OUTSIDE_STEP}.`;
if (reason === OUTSIDE_PARENT) return `Calling again won't change that; ${RESOLVED_OUTSIDE_STEP}.`;
if (reason === NOT_A_DIRECTORY) return "Calling again won't change that while something other than a directory stands where the path needs one; another path, or removing what stands there, would.";
return null;
}
Expand Down
Loading
Loading