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
13 changes: 11 additions & 2 deletions GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,17 @@ public enum RemoteGraphAccess {
///
/// No `makedirs` for the receipt: it is only reached once the shim it vouches for has
/// been written, and that write created the directory.
///
/// `neutered` is what makes the `|| true` above optional. It holds for everything a
/// session can rediscover or do without — the shim, the briefing, the wake digest —
/// but not for a prompt that has moved to a file: there the delivery *is* the
/// instructions, and a launch that proceeds without it starts an agent whose entire
/// brief is a pointer at a file that isn't there. That caller
/// (`ZmxSessionLauncher.remotePromptDelivery`) chains the launch behind this command's
/// exit status instead, so a failed delivery costs a retry rather than a blind pass.
public static func installerScript(
files: [String: String], receipt: (path: String, content: String)? = nil
files: [String: String], receipt: (path: String, content: String)? = nil,
neutered: Bool = true
) -> String? {
guard !files.isEmpty else { return nil }
let manifest = files.mapValues { Data($0.utf8).base64EncodedString() }
Expand All @@ -157,7 +166,7 @@ public enum RemoteGraphAccess {
var argv = ["python3", "-c", program, json.base64EncodedString()]
if let receipt { argv += [receipt.path, receipt.content] }
return argv.map(RemoteProjectLocation.shellQuoted).joined(separator: " ")
+ " >/dev/null 2>&1 || true"
+ " >/dev/null 2>&1" + (neutered ? " || true" : "")
}

/// Installs bridge state through the SSH command's stdin. Only the byte count and
Expand Down
91 changes: 74 additions & 17 deletions GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,15 @@ public enum ZmxSessionLauncher {
["get", SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName]
}

/// Where `arguments(forNode:)` records a prompt it moved to a file, for a remote launch
/// that has to put the file on the host before the pointer at it means anything.
final class ShedPromptReport {
/// The `~/`-relative path the pointer names, set only for a remote project.
var remotePath: String?
/// The bytes that path must hold — the same text written to the local copy.
var text: String?
}

/// The `zmx` argv for a node, or `nil` when there's no prompt to run.
///
/// `zmx run <name> -d <cmd…>` creates the session if it doesn't exist and runs `cmd`
Expand All @@ -1160,9 +1169,16 @@ public enum ZmxSessionLauncher {
/// `zmx` shell-quotes every argument before typing the command into the session's shell
/// (`util.shellQuote`, a standard shlex-style single-quote escape), so it reaches
/// `claude` as exactly one word no matter what quotes, `$(…)`, or `;` it contains.
///
/// `shedPrompt` is how a *remote* caller learns that the argv it just got is a pointer
/// rather than a prompt, and what has to be on the host for it to mean anything. A
/// mutable box rather than a richer return type because the shed branch returns from
/// half a dozen places and only two of them are pointered; every other caller passes
/// nothing and is unaffected.
static func arguments(
forNode node: LoopNode, projectPath: String? = nil,
settings: GraphcodeSettings = GraphcodeSettingsStore.load()
settings: GraphcodeSettings = GraphcodeSettingsStore.load(),
shedPrompt: ShedPromptReport? = nil
) -> [String]? {
guard let prompt = node.sessionPrompt(forProjectPath: projectPath), !prompt.isEmpty else {
return nil
Expand Down Expand Up @@ -1322,10 +1338,17 @@ public enum ZmxSessionLauncher {
let promptFile = NodeMemory.writePrompt(
filePrompt, projectPath: projectPath, nodeID: node.id)
else { return unbriefedCommand }
let remotePromptPath =
remote == nil
? nil : RemoteGraphAccess.promptPath(forProjectPath: projectPath, nodeID: node.id)
let plainPointer = NodeMemory.promptPointer(
toPromptAt: remote == nil
? promptFile.path
: RemoteGraphAccess.promptPath(forProjectPath: projectPath, nodeID: node.id))
toPromptAt: remotePromptPath ?? promptFile.path)
// Called on the returns that type a pointer rather than the prompt itself — those,
// and only those, leave a remote launch owing the host a file.
func reportShedPrompt() {
shedPrompt?.remotePath = remotePromptPath
shedPrompt?.text = filePrompt
}
let directive = node.backend.capabilities.goalDirective
let promptDirectory =
remote == nil
Expand All @@ -1342,13 +1365,17 @@ public enum ZmxSessionLauncher {
for pointer in pointers {
let pointeredCommand = shed(
prompt: pointer, briefingPath: briefingPath, extraPath: promptDirectory)
if Self.fitsInATypedCommandLine(pointeredCommand) { return pointeredCommand }
if Self.fitsInATypedCommandLine(pointeredCommand) {
reportShedPrompt()
return pointeredCommand
}
}
// Deep support-directory paths can push briefing plus pointer past the line even
// now. Only then does the briefing go, keeping whichever prompt form is shorter.
if Self.fitsInATypedCommandLine(unbriefedCommand) { return unbriefedCommand }
let shortestLed = Self.directiveLedPointer(
plainPointer, prompt: singleLine, directive: directive, headLength: 0)
reportShedPrompt()
return shed(prompt: shortestLed, briefingPath: nil, extraPath: promptDirectory)
}
return command
Expand Down Expand Up @@ -1584,16 +1611,23 @@ public enum ZmxSessionLauncher {
settings: GraphcodeSettings = GraphcodeSettingsStore.load(),
bridgeState: RemoteBridgeWireState? = nil
) -> [String]? {
let shedPrompt = ShedPromptReport()
guard
let zmxArguments = arguments(
forNode: node, projectPath: location.projectPath, settings: settings)
forNode: node, projectPath: location.projectPath, settings: settings,
shedPrompt: shedPrompt)
else { return nil }
// The remote twin of the local alive check: raw existence (`zmx get`) answers for a
// husk too — the wrapper shell stays at its prompt after the command inside exits —
// so an ensure keyed on it could never revive a dead remote loop (#215). Only a
// listed session whose task has not ended counts as alive here.
let check = aliveCheckCommand(zmxPath: "zmx", forNode: node)
let run = remoteQuotedCommand(["zmx"] + zmxArguments)
// The launch, behind the delivery of the one file it cannot do without. Nothing is
// prefixed when the prompt was typed in full, which is the ordinary case.
let launchCommand = remoteQuotedCommand(["zmx"] + zmxArguments)
let run =
remotePromptDelivery(shedPrompt, forNode: node)
.map { "\($0) && \(launchCommand)" } ?? launchCommand
// Copilot only, and remote only: an unattended Copilot queues its `--interactive`
// goal behind a per-session folder-trust dialog that nobody is present to answer,
// so a fresh remote Copilot loop booted to an idle screen with its goal parked
Expand Down Expand Up @@ -1781,6 +1815,36 @@ public enum ZmxSessionLauncher {
receipt: (path: RemoteGraphAccess.shimStampPath, content: RemoteGraphAccess.cliShimStamp))
}

/// The delivery for a prompt that moved to a file (issue #57), as its own command
/// chained *into* the fresh launch — `nil` when the prompt was typed in full.
///
/// It does not ride `remoteDeliveryScript`'s manifest, and the split is the fix rather
/// than tidiness. That manifest is one `python3` carrying the 45 KB shim, the briefing
/// and the wake digest — ~105 KB of base64 in a single argv string, against a Linux
/// `MAX_ARG_STRLEN` of 128 KiB — and it ends in `|| true`, deliberately, because a
/// session without its briefing is still a session. A session without its *prompt* is
/// not: shedding now moves the prompt to a file before it drops the briefing (#345), so
/// far more remote loops launch pointered, and any failure in that one best-effort
/// command left the agent booting with its entire brief being a path that isn't there.
///
/// So the prompt travels alone, in a command small enough not to share that fate, and
/// un-neutered: the `&&` in the caller means a delivery that fails takes the launch
/// with it. The node then stays honestly not-running and the next liveness sweep
/// retries, which is the same posture `startRemote` already takes on a dial that fails.
static func remotePromptDelivery(
_ shedPrompt: ShedPromptReport, forNode node: LoopNode
) -> String? {
guard let path = shedPrompt.remotePath, let text = shedPrompt.text else { return nil }
guard let install = RemoteGraphAccess.installerScript(files: [path: text], neutered: false)
else { return nil }
let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName
// Logged on the remote host's dial log rather than swallowed: a launch that never
// happens is invisible otherwise, and this is exactly the failure that used to
// surface only as an agent reporting that its instructions do not exist.
let log = DialLog.fragment(session: name, dial: "ensure", event: "prompt-undelivered")
return "{ \(install) || { \(log); false; }; }"
}

/// `remoteDeliveryScript`'s manifest: home-relative path → content. Copilot's copy of the
/// briefing (`SessionBriefing.copilotInstructionsFile`) goes only to a Copilot session,
/// named by `node` or, for the app's attach, by `backend`.
Expand All @@ -1806,16 +1870,9 @@ public enum ZmxSessionLauncher {
files[RemoteGraphAccess.wakePath(forProjectPath: location.projectPath, nodeID: node.id)] =
wake
}
// An oversized prompt travels the same way (issue #57): `arguments(forNode:)` has
// already written the local copy by the time the ensure dial builds this script.
let promptURL = NodeMemory.directory(
forProjectPath: location.projectPath, nodeID: node.id
).appendingPathComponent(NodeMemory.promptFileName)
if let promptText = try? String(contentsOf: promptURL, encoding: .utf8) {
files[
RemoteGraphAccess.promptPath(forProjectPath: location.projectPath, nodeID: node.id)] =
promptText
}
// An oversized prompt (issue #57) is deliberately *not* here: it is the one file a
// launch cannot start without, so it travels un-neutered in the create branch
// instead — see `remotePromptDelivery`.
}
return files
}
Expand Down
91 changes: 91 additions & 0 deletions graphcode/Tests/RemoteSessionLaunchTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -425,3 +425,94 @@ struct RemoteSessionLaunchTests {
#expect(paths == ["/home/dev/widget"])
}
}

/// The oversized-prompt delivery for a remote launch. A separate extension only
/// because the suite is at swiftlint's `type_body_length` limit.
extension RemoteSessionLaunchTests {
/// A goal far past the typed line, so the launch sheds it to `PROMPT.md`.
private static let oversizedGoal = String(
repeating: "Resolve the conflict before moving on. ", count: 103)

@Test
func anOversizedRemotePromptGetsItsOwnUnneuteredDelivery() throws {
// The reported failure: a Codespace loop booted with its entire brief being
// "your instructions are at ~/.graphcode/…/PROMPT.md", and that file was not there.
// The prompt used to ride the same `|| true` manifest as the 45 KB shim and the
// briefing, so any failure in that one command launched an agent with nothing to do.
let node = LoopNode(
title: "Fix", loopType: .goalBased, goal: GoalSpec(summary: Self.oversizedGoal))
defer { NodeMemory.remove(projectPath: location.projectPath, nodeID: node.id) }
let report = ZmxSessionLauncher.ShedPromptReport()
let arguments = try #require(
ZmxSessionLauncher.arguments(
forNode: node, projectPath: location.projectPath, settings: GraphcodeSettings(),
shedPrompt: report))
let promptPath = RemoteGraphAccess.promptPath(
forProjectPath: location.projectPath, nodeID: node.id)
// The argv really is a pointer rather than the goal, which is what makes the file
// load-bearing in the first place.
#expect(arguments.last?.contains(promptPath) == true)
#expect(report.remotePath == promptPath)

let delivery = try #require(ZmxSessionLauncher.remotePromptDelivery(report, forNode: node))
// Alone in its own manifest: sharing the shim's ~105 KB one is what put it a single
// failure away from a launch that could not use it.
#expect(deliveredPaths(in: delivery) == [promptPath])
// `2>&1 || true` is the neutered installer's own tail — the dial log that follows
// keeps its `|| true`, which is the log being best-effort, not the delivery.
#expect(!delivery.contains("2>&1 || true"))
#expect(delivery.contains("prompt-undelivered"))
}

@Test
func theOversizedPromptLandsBeforeTheLaunchAndGatesIt() throws {
let node = LoopNode(
title: "Fix", loopType: .goalBased, goal: GoalSpec(summary: Self.oversizedGoal))
defer { NodeMemory.remove(projectPath: location.projectPath, nodeID: node.id) }
let script = try #require(
ZmxSessionLauncher.remoteEnsureInvocation(
forNode: node, at: location, settings: GraphcodeSettings())?.last)

// The delivery's failure log is the one token unique to it, so it anchors the
// ordering: written, then `&&`, then the run that depends on it. The *last* `'run'`
// is the fresh branch — the resume branch above it types one too, and needs no
// prompt, having a conversation to pick back up.
let delivery = try #require(script.range(of: "prompt-undelivered"))
let run = try #require(script.range(of: "'run'", options: .backwards))
#expect(delivery.upperBound < run.lowerBound)
#expect(script[delivery.upperBound..<run.lowerBound].contains("&&"))
}

@Test
func aPromptThatFitsTheTypedLineIsDeliveredNowhere() throws {
// The ordinary case pays nothing: no second installer, no extra gate on the launch.
let node = LoopNode(
title: "Fix", loopType: .goalBased, goal: GoalSpec(summary: "tests pass"))
defer { NodeMemory.remove(projectPath: location.projectPath, nodeID: node.id) }
let script = try #require(
ZmxSessionLauncher.remoteEnsureInvocation(
forNode: node, at: location, settings: GraphcodeSettings())?.last)

#expect(!script.contains(NodeMemory.promptFileName))
#expect(!script.contains("prompt-undelivered"))
}

@Test
func theBestEffortManifestNeverCarriesThePrompt() throws {
// Regression guard for the split: a neutered channel must not be what a launch
// depends on, however convenient it is to add one more file to it.
let node = LoopNode(
title: "Fix", loopType: .goalBased, goal: GoalSpec(summary: Self.oversizedGoal))
defer { NodeMemory.remove(projectPath: location.projectPath, nodeID: node.id) }
_ = ZmxSessionLauncher.arguments(
forNode: node, projectPath: location.projectPath, settings: GraphcodeSettings())
let promptFile = NodeMemory.directory(
forProjectPath: location.projectPath, nodeID: node.id
).appendingPathComponent(NodeMemory.promptFileName)
try #require(FileManager.default.fileExists(atPath: promptFile.path))

let files = ZmxSessionLauncher.remoteDeliveryFiles(
forNode: node, at: location, settings: GraphcodeSettings())
#expect(!files.keys.contains { $0.hasSuffix(NodeMemory.promptFileName) })
}
}
Loading