diff --git a/GraphcodeKit/Sources/Sessions/NodeMemory.swift b/GraphcodeKit/Sources/Sessions/NodeMemory.swift index 0c1034ad..c4232ccb 100644 --- a/GraphcodeKit/Sources/Sessions/NodeMemory.swift +++ b/GraphcodeKit/Sources/Sessions/NodeMemory.swift @@ -27,6 +27,7 @@ import Foundation public enum NodeMemory { public static let logFileName = "LOG.txt" public static let wakeFileName = "WAKE.md" + public static let promptFileName = "PROMPT.md" /// How many recent log lines a wake digest carries verbatim (~a few KB). Older /// entries are elided with a pointer at the full log — available on demand, out of @@ -151,6 +152,43 @@ public enum NodeMemory { } } + /// Writes a node's full prompt to its `PROMPT.md` and returns where it landed, or + /// `nil` when writing failed. + /// + /// This is the launch path's last-resort delivery for a prompt too long to type: the + /// command `zmx` types into a session tops out under `MAX_CANON` (1024 bytes), and a + /// multi-KB goal overran it — the tty ate the tail mid-word, the shell parked at a + /// continuation prompt, and the node read `running` while no backend process existed + /// (issue #57). A file has no length limit, so the goal rides here and the typed line + /// carries only `promptPointer`. + /// + /// It lives beside the wake digest deliberately: same per-node lifecycle (`remove` + /// cleans both), same remote delivery channel, and the directory is already what a + /// path-verifying backend gets granted. + public static func writePrompt( + _ text: String, projectPath: String, nodeID: UUID, baseURL: URL = SupportDirectory.url + ) -> URL? { + let url = directory(forProjectPath: projectPath, nodeID: nodeID, baseURL: baseURL) + .appendingPathComponent(promptFileName) + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try text.write(to: url, atomically: true, encoding: .utf8) + return url + } catch { + return nil + } + } + + /// What gets typed in the prompt's place. ASCII only, plain words on both sides of + /// the path — this string rides the same hostile route as the briefing pointer + /// (argv, zmx's typed command line, a canonical-mode tty, sometimes ssh), where an + /// em dash next to the path once ate the file extension (`SessionBriefing.pointer`). + public static func promptPointer(toPromptAt path: String) -> String { + "Your complete instructions are in the file at \(path) - read that file first and " + + "carry out everything it says." + } + /// Removes a node's memory directory — called when the node itself is deleted, the /// same moment its session is torn down. A log for a loop that no longer exists is /// not history, it's litter. diff --git a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift index bf95e6a1..99217f32 100644 --- a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift +++ b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift @@ -44,6 +44,12 @@ public enum RemoteGraphAccess { memoryDirectory(forProjectPath: projectPath, nodeID: nodeID) + "/" + NodeMemory.wakeFileName } + /// Where an oversized prompt lands on the remote host — delivered like the wake + /// digest, and for the same reason the briefing is: too long to type (issue #57). + public static func promptPath(forProjectPath projectPath: String, nodeID: UUID) -> String { + memoryDirectory(forProjectPath: projectPath, nodeID: nodeID) + "/" + NodeMemory.promptFileName + } + /// A shell fragment that lands `files` (home-relative path → content) on the remote /// host, or `nil` when there's nothing to send. One `python3 -c` with a base64 JSON /// manifest rather than heredocs or scp: a single argument survives every quoting diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 892593fa..0480fd44 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -474,9 +474,11 @@ public enum ZmxSessionLauncher { // `zmx` types this command into the session's shell, and a tty in canonical mode // discards everything past `MAX_CANON` (1024 bytes on macOS). Overrunning it does not // fail loudly: the tail is dropped mid-argument and the shell waits forever at a - // continuation prompt for a quote that was eaten. Dropping the briefing is the one - // safe thing to give up — the prompt is the human's, and a loop that launches without - // its briefing merely can't fan out, where a truncated one does nothing at all. + // continuation prompt for a quote that was eaten. Shedding goes in two steps: first + // the briefing — a loop without one merely can't fan out — and if the prompt *itself* + // is what overruns, it moves to a file and a short pointer is typed instead + // (issue #57: a multi-KB goal was eaten mid-word, the shell parked at a continuation + // prompt, and the node read `running` while no backend process ever existed). guard Self.fitsInATypedCommandLine(command) else { // The hooks stay: they are two argv entries against the briefing's several hundred // bytes, and a loop that overran the line is exactly the one worth being able to @@ -487,11 +489,61 @@ public enum ZmxSessionLauncher { hooksFile: hooksFile, sessionName: SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, zmxPath: reportingPath) + let unbriefedCommand = + [ + "run", SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, "-d", + ] + + Self.loginShellInvocation( + of: executable, arguments: unbriefed, scriptSuffix: remoteHooksSuffix) + if Self.fitsInATypedCommandLine(unbriefedCommand) { return unbriefedCommand } + + // The file carries the *unflattened* prompt — a file has no newline hazard, so a + // pasted multi-line goal survives verbatim where the typed line had to collapse it. + let filePrompt = + wakePath.map { "Read your loop memory at \($0) before starting. Then: \(prompt)" } + ?? prompt + guard let projectPath, + let promptFile = NodeMemory.writePrompt( + filePrompt, projectPath: projectPath, nodeID: node.id) + else { return unbriefedCommand } + let pointerPath = + remote == nil + ? promptFile.path + : RemoteGraphAccess.promptPath(forProjectPath: projectPath, nodeID: node.id) + let promptDirectory = + remote == nil + ? promptFile.deletingLastPathComponent().path + : RemoteGraphAccess.memoryDirectory(forProjectPath: projectPath, nodeID: node.id) + let pointered = node.backend.launchArguments( + prompt: NodeMemory.promptPointer(toPromptAt: pointerPath), tier: tier, + briefingPath: briefingPath, settings: settings, + workspacePaths: Self.workspacePaths(forNode: node, projectPath: projectPath) + + [promptDirectory], + hooksFile: hooksFile, + sessionName: SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, + zmxPath: reportingPath) + let pointeredCommand = + [ + "run", SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, "-d", + ] + + Self.loginShellInvocation( + of: executable, arguments: pointered, scriptSuffix: remoteHooksSuffix) + if Self.fitsInATypedCommandLine(pointeredCommand) { return pointeredCommand } + // Deep support-directory paths can push briefing plus pointer past the line even + // now; the pointer is the one part that cannot be given up, so the briefing goes. + let pointeredUnbriefed = node.backend.launchArguments( + prompt: NodeMemory.promptPointer(toPromptAt: pointerPath), tier: tier, + settings: settings, + workspacePaths: Self.workspacePaths(forNode: node, projectPath: projectPath) + + [promptDirectory], + hooksFile: hooksFile, + sessionName: SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, + zmxPath: reportingPath) return [ "run", SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, "-d", ] + Self.loginShellInvocation( - of: executable, arguments: unbriefed, scriptSuffix: remoteHooksSuffix) + of: executable, arguments: pointeredUnbriefed, scriptSuffix: remoteHooksSuffix) } return command } @@ -519,12 +571,13 @@ public enum ZmxSessionLauncher { let sessionName: String? = node.backend == .copilotCLI ? nil : SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName - let resumeArgs = node.backend.launchArguments( - prompt: nil, tier: tier, settings: settings, - workspacePaths: Self.workspacePaths(forNode: node, projectPath: projectPath), - hooksFile: hooksFile, - sessionName: sessionName, - zmxPath: reportingPath) + let resumeArgs = + node.backend.launchArguments( + prompt: nil, tier: tier, settings: settings, + workspacePaths: Self.workspacePaths(forNode: node, projectPath: projectPath), + hooksFile: hooksFile, + sessionName: sessionName, + zmxPath: reportingPath) + ["--resume", sessionID] return [ "run", SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, "-d", @@ -677,6 +730,16 @@ 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 + } } return RemoteGraphAccess.installerScript(files: files) } @@ -904,14 +967,16 @@ public enum ZmxSessionLauncher { let resumeArgs = resumeArguments( forNode: node, sessionID: sessionID, projectPath: projectPath) { - await atomicCheckOrRun(checkArguments: checkArgs, runArguments: resumeArgs, - zmxPath: zmxPath, workingDirectory: wd) + await atomicCheckOrRun( + checkArguments: checkArgs, runArguments: resumeArgs, + zmxPath: zmxPath, workingDirectory: wd) return } guard let runArgs = arguments(forNode: node, projectPath: projectPath) else { return } - await atomicCheckOrRun(checkArguments: checkArgs, runArguments: runArgs, - zmxPath: zmxPath, workingDirectory: wd) + await atomicCheckOrRun( + checkArguments: checkArgs, runArguments: runArgs, + zmxPath: zmxPath, workingDirectory: wd) } private static func atomicCheckOrRun( @@ -921,9 +986,10 @@ public enum ZmxSessionLauncher { let check = quotedCommand([zmxPath] + checkArguments) let run = quotedCommand([zmxPath] + runArguments) let script = "\(check) >/dev/null 2>&1 || \(run)" - guard let session = try? PTYProcessSession( - executable: "/bin/zsh", arguments: ["-c", script], - workingDirectory: workingDirectory) + guard + let session = try? PTYProcessSession( + executable: "/bin/zsh", arguments: ["-c", script], + workingDirectory: workingDirectory) else { return } _ = await session.waitUntilFinished() } diff --git a/graphcode/Tests/SessionBriefingTests.swift b/graphcode/Tests/SessionBriefingTests.swift index 4bf40190..d89011d1 100644 --- a/graphcode/Tests/SessionBriefingTests.swift +++ b/graphcode/Tests/SessionBriefingTests.swift @@ -122,16 +122,27 @@ struct SessionBriefingTests { } @Test - func anOverlongPromptDropsTheBriefingRatherThanCorruptTheCommand() throws { - // A pasted prompt can be long enough on its own to threaten the buffer. The briefing - // is the one thing safe to give up: without it a loop merely can't fan out, whereas a - // truncated command line hangs the session at a continuation prompt. + func anOverlongPromptMovesToAFileRatherThanCorruptTheCommand() throws { + // Issue #57. This test used to assert that an overlong prompt merely dropped the + // briefing and stayed on the line verbatim — but the unbriefed command it accepted + // was itself past the typed-line budget, which is exactly the corruption the budget + // exists to prevent: the tty ate the tail mid-word and the shell parked at a + // continuation prompt while the node read `running`. Past the budget the prompt now + // rides in a file, the typed line carries only a short pointer, and the briefing no + // longer needs to be sacrificed to make room. let huge = String(repeating: "do the thing ", count: 60) + let overlong = node(prompt: huge) + defer { NodeMemory.remove(projectPath: Self.project, nodeID: overlong.id) } let arguments = try #require( - ZmxSessionLauncher.arguments( - forNode: node(prompt: huge), projectPath: Self.project)) - #expect(!arguments.contains("--append-system-prompt-file")) - #expect(arguments.last == huge) + ZmxSessionLauncher.arguments(forNode: overlong, projectPath: Self.project)) + + #expect(ZmxSessionLauncher.fitsInATypedCommandLine(arguments)) + #expect(arguments.contains("--append-system-prompt-file")) + let typed = try #require(arguments.last) + #expect(!typed.contains("do the thing")) + let path = try #require( + typed.components(separatedBy: " ").first { $0.hasSuffix(NodeMemory.promptFileName) }) + #expect(try String(contentsOfFile: path, encoding: .utf8).contains(huge)) } @Test diff --git a/graphcode/Tests/ZmxSessionLauncherTests.swift b/graphcode/Tests/ZmxSessionLauncherTests.swift index 2af2e56b..2815c4e3 100644 --- a/graphcode/Tests/ZmxSessionLauncherTests.swift +++ b/graphcode/Tests/ZmxSessionLauncherTests.swift @@ -166,6 +166,66 @@ struct ZmxSessionLauncherTests { #expect(ZmxSessionLauncher.arguments(forNode: Self.node(prompt: "")) == nil) } + @Test + func aMultiKBGoalIsDeliveredByFileAndTypedAsAPointer() { + // Issue #57: the launch command is typed into a canonical-mode tty, which discards + // everything past MAX_CANON (1024 bytes). A multi-KB goal was eaten mid-word, the + // shell parked at a continuation prompt, and the node read `running` while no + // backend process ever existed. Past the typed-line budget, the prompt must ride in + // a file with only a short pointer on the line. + let goal = String( + repeating: "Resolve the CONFLICT SCOPE in one debate round before moving on. ", count: 40) + let node = LoopNode(title: "Debate", loopType: .goalBased, goal: GoalSpec(summary: goal)) + defer { NodeMemory.remove(projectPath: "/tmp", nodeID: node.id) } + + let arguments = ZmxSessionLauncher.arguments(forNode: node, projectPath: "/tmp") ?? [] + + // The whole point: what gets typed survives the tty. + #expect(ZmxSessionLauncher.fitsInATypedCommandLine(arguments)) + // The typed prompt is the pointer, not the goal. + let typed = arguments.last ?? "" + #expect(!typed.contains("CONFLICT SCOPE")) + #expect(typed.contains(NodeMemory.promptFileName)) + // And the file carries the full goal, nothing dropped mid-string. + let file = NodeMemory.directory(forProjectPath: "/tmp", nodeID: node.id) + .appendingPathComponent(NodeMemory.promptFileName) + let content = (try? String(contentsOf: file, encoding: .utf8)) ?? "" + #expect(content.contains(goal)) + } + + @Test + func aPromptWithinTheLineBudgetIsStillTypedDirectly() { + // The file is the last resort, not the new default: a short goal keeps today's + // behaviour, typed verbatim so nothing has to read a file to know its job. + let node = LoopNode( + title: "Small", loopType: .goalBased, goal: GoalSpec(summary: "Say hello")) + defer { NodeMemory.remove(projectPath: "/tmp", nodeID: node.id) } + + let arguments = ZmxSessionLauncher.arguments(forNode: node, projectPath: "/tmp") ?? [] + + #expect(arguments.last?.contains("Say hello") == true) + #expect(arguments.last?.contains(NodeMemory.promptFileName) != true) + let file = NodeMemory.directory(forProjectPath: "/tmp", nodeID: node.id) + .appendingPathComponent(NodeMemory.promptFileName) + #expect(!FileManager.default.fileExists(atPath: file.path)) + } + + @Test + func theOversizedPromptFileKeepsItsNewlines() { + // The typed line must flatten newlines (zmx submits at `\r`); the file has no such + // hazard, so a pasted multi-line brief survives with its structure intact. + let brief = "# Debate brief\n\n" + String(repeating: "One point per line.\n", count: 60) + let node = LoopNode(title: "Brief", loopType: .timeBased, triggerPrompt: brief) + defer { NodeMemory.remove(projectPath: "/tmp", nodeID: node.id) } + + _ = ZmxSessionLauncher.arguments(forNode: node, projectPath: "/tmp") + + let file = NodeMemory.directory(forProjectPath: "/tmp", nodeID: node.id) + .appendingPathComponent(NodeMemory.promptFileName) + let content = (try? String(contentsOf: file, encoding: .utf8)) ?? "" + #expect(content.contains("# Debate brief\n")) + } + @Test func aReportedActivityLabelIsCollapsedToOneBoundedLine() { // A label is whatever a hook wrote. One that arrives as a paragraph must not reach