diff --git a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift index a18dcbab..0578360b 100644 --- a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift +++ b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift @@ -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() } @@ -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 diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 6c124bd5..0a69b81c 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -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 -d ` creates the session if it doesn't exist and runs `cmd` @@ -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 @@ -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 @@ -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 @@ -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 @@ -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`. @@ -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 } diff --git a/graphcode/Tests/RemoteSessionLaunchTests.swift b/graphcode/Tests/RemoteSessionLaunchTests.swift index 6a409a24..6efd4705 100644 --- a/graphcode/Tests/RemoteSessionLaunchTests.swift +++ b/graphcode/Tests/RemoteSessionLaunchTests.swift @@ -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..