From 2719c36c9b6ac161c76f5ad6d235c2d5e51aa161 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sun, 13 Sep 2026 10:28:05 -0700 Subject: [PATCH 1/2] Pin that a medium goal keeps its briefing (#345) --- graphcode/Tests/ZmxSessionLauncherTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/graphcode/Tests/ZmxSessionLauncherTests.swift b/graphcode/Tests/ZmxSessionLauncherTests.swift index 12492e2a..806049eb 100644 --- a/graphcode/Tests/ZmxSessionLauncherTests.swift +++ b/graphcode/Tests/ZmxSessionLauncherTests.swift @@ -336,6 +336,28 @@ struct ZmxSessionLauncherTests { #expect(content.contains(goal)) } + @Test + func aMediumGoalMovesToAFileBeforeTheBriefingIsDropped() throws { + // Issue #345: a ~600-byte goal overran the line only once the briefing was added, and + // the ladder shed the briefing first — so the session launched with no idea it was in + // a graph, while a multi-KB goal took the file path and kept it. The prompt is the + // part that can move; the briefing is the part a loop cannot rediscover. + let goal = String(repeating: "Close out every open Milestone 2 item. ", count: 16) + let node = LoopNode(title: "Lead", loopType: .goalBased, goal: GoalSpec(summary: goal)) + defer { NodeMemory.remove(projectPath: "/tmp", nodeID: node.id) } + let settings = GraphcodeSettings(briefsSessionsAboutTheGraph: true) + + let arguments = try #require( + ZmxSessionLauncher.arguments(forNode: node, projectPath: "/tmp", settings: settings)) + + #expect(ZmxSessionLauncher.fitsInATypedCommandLine(arguments)) + #expect(arguments.joined(separator: " ").contains("--append-system-prompt-file")) + #expect(arguments.last?.contains(NodeMemory.promptFileName) == true) + let file = NodeMemory.directory(forProjectPath: "/tmp", nodeID: node.id) + .appendingPathComponent(NodeMemory.promptFileName) + #expect((try? String(contentsOf: file, encoding: .utf8))?.contains(goal) == true) + } + @Test func aPromptWithinTheLineBudgetIsStillTypedDirectly() { // The file is the last resort, not the new default: a short goal keeps today's From 3eb52af7aed7ca9ffee9a1f880473eafbde66bd1 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sun, 13 Sep 2026 10:35:03 -0700 Subject: [PATCH 2/2] Move the prompt to a file before dropping the briefing (#345) A goal that fit the typed line only without the briefing launched unbriefed, while longer goals took the prompt-file path and kept it. Shed in the right order: pointer + briefing first, then drop the briefing only if that still overruns. The unbriefed fallback also keeps the wake pointer now. Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/Sessions/ZmxSessionLauncher.swift | 101 ++++++------------ graphcode/Tests/ZmxSessionLauncherTests.swift | 58 ++++++---- 2 files changed, 71 insertions(+), 88 deletions(-) diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 57d91673..f02a2948 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -1018,33 +1018,35 @@ 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. 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). + // continuation prompt for a quote that was eaten (issue #57: a multi-KB goal was eaten + // mid-word, and the node read `running` while no backend process ever existed). + // + // Shedding moves the prompt before it drops the briefing. The prompt can travel in a + // file behind a short pointer and lose nothing; the briefing is the one thing a + // session cannot rediscover — shedding it first launched medium goals with no idea + // they were in a graph while longer ones kept it (issue #345). The hooks always stay: + // a loop that overran the line is exactly the one worth seeing the real state of. 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 - // see the real state of. - let unbriefed = node.backend.launchArguments( - prompt: singleLine, tier: tier, settings: settings, - workspacePaths: Self.workspacePaths(forNode: node, projectPath: projectPath), - hooksFile: hooksFile, - sessionName: SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, - zmxPath: reportingPath, - sessionsDirectory: sessionsDirectory) - let unbriefedCommand = - [ + func shed(prompt: String, briefingPath: String?, extraPath: String?) -> [String] { + let workspacePaths = paths + (extraPath.map { paths.contains($0) ? [] : [$0] } ?? []) + let arguments = node.backend.launchArguments( + prompt: prompt, tier: tier, briefingPath: briefingPath, settings: settings, + workspacePaths: workspacePaths, + hooksFile: hooksFile, + sessionName: SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, + zmxPath: reportingPath, + sessionsDirectory: sessionsDirectory) + return [ "run", SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, "-d", ] - + Self.loginShellInvocation( - of: executable, arguments: unbriefed, - environment: Self.environment( - forBackend: node.backend, briefingPath: nil, hooksFile: hooksFile, - remoteHooksPath: remoteEnvironmentPath), - scriptSuffix: remoteHooksSuffix) - if Self.fitsInATypedCommandLine(unbriefedCommand) { return unbriefedCommand } + + Self.loginShellInvocation( + of: executable, arguments: arguments, + environment: Self.environment( + forBackend: node.backend, briefingPath: briefingPath, hooksFile: hooksFile, + remoteHooksPath: remoteEnvironmentPath), + scriptSuffix: remoteHooksSuffix) + } + let unbriefedCommand = shed(prompt: promptWithMemory, briefingPath: nil, extraPath: nil) // 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. @@ -1056,54 +1058,21 @@ public enum ZmxSessionLauncher { 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 pointer = NodeMemory.promptPointer( + toPromptAt: 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, - sessionsDirectory: sessionsDirectory) - let pointeredCommand = - [ - "run", SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, "-d", - ] - + Self.loginShellInvocation( - of: executable, arguments: pointered, - environment: Self.environment( - forBackend: node.backend, briefingPath: briefingPath, hooksFile: hooksFile, - remoteHooksPath: remoteEnvironmentPath), - scriptSuffix: remoteHooksSuffix) + let pointeredCommand = shed( + prompt: pointer, briefingPath: briefingPath, extraPath: promptDirectory) 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, - sessionsDirectory: sessionsDirectory) - return [ - "run", SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, "-d", - ] - + Self.loginShellInvocation( - of: executable, arguments: pointeredUnbriefed, - environment: Self.environment( - forBackend: node.backend, briefingPath: nil, hooksFile: hooksFile, - remoteHooksPath: remoteEnvironmentPath), - scriptSuffix: remoteHooksSuffix) + // now. Only then does the briefing go, keeping whichever prompt form is shorter. + if Self.fitsInATypedCommandLine(unbriefedCommand) { return unbriefedCommand } + return shed(prompt: pointer, briefingPath: nil, extraPath: promptDirectory) } return command } diff --git a/graphcode/Tests/ZmxSessionLauncherTests.swift b/graphcode/Tests/ZmxSessionLauncherTests.swift index 806049eb..ad5fcbb6 100644 --- a/graphcode/Tests/ZmxSessionLauncherTests.swift +++ b/graphcode/Tests/ZmxSessionLauncherTests.swift @@ -336,28 +336,6 @@ struct ZmxSessionLauncherTests { #expect(content.contains(goal)) } - @Test - func aMediumGoalMovesToAFileBeforeTheBriefingIsDropped() throws { - // Issue #345: a ~600-byte goal overran the line only once the briefing was added, and - // the ladder shed the briefing first — so the session launched with no idea it was in - // a graph, while a multi-KB goal took the file path and kept it. The prompt is the - // part that can move; the briefing is the part a loop cannot rediscover. - let goal = String(repeating: "Close out every open Milestone 2 item. ", count: 16) - let node = LoopNode(title: "Lead", loopType: .goalBased, goal: GoalSpec(summary: goal)) - defer { NodeMemory.remove(projectPath: "/tmp", nodeID: node.id) } - let settings = GraphcodeSettings(briefsSessionsAboutTheGraph: true) - - let arguments = try #require( - ZmxSessionLauncher.arguments(forNode: node, projectPath: "/tmp", settings: settings)) - - #expect(ZmxSessionLauncher.fitsInATypedCommandLine(arguments)) - #expect(arguments.joined(separator: " ").contains("--append-system-prompt-file")) - #expect(arguments.last?.contains(NodeMemory.promptFileName) == true) - let file = NodeMemory.directory(forProjectPath: "/tmp", nodeID: node.id) - .appendingPathComponent(NodeMemory.promptFileName) - #expect((try? String(contentsOf: file, encoding: .utf8))?.contains(goal) == true) - } - @Test func aPromptWithinTheLineBudgetIsStillTypedDirectly() { // The file is the last resort, not the new default: a short goal keeps today's @@ -562,6 +540,42 @@ struct ZmxSessionLauncherTests { /// The probe's own failures, kept out of the suite body above only because swiftlint's /// `type_body_length` is at its limit there. extension ZmxSessionLauncherTests { + @Test + func aMediumGoalMovesToAFileBeforeTheBriefingIsDropped() throws { + // Issue #345: a ~600-byte goal overran the line only once the briefing was added, and + // the ladder shed the briefing first — so the session launched with no idea it was in + // a graph, while a multi-KB goal took the file path and kept it. The prompt is the + // part that can move; the briefing is the part a loop cannot rediscover. + // Sized from measured baselines so the goal fits unbriefed and overruns briefed — + // the exact window the old ladder resolved by dropping the briefing. + let settings = GraphcodeSettings(briefsSessionsAboutTheGraph: true) + let unbriefedSettings = GraphcodeSettings(briefsSessionsAboutTheGraph: false) + func bytes(_ goal: String, _ settings: GraphcodeSettings) throws -> Int { + let probe = LoopNode(title: "Lead", loopType: .goalBased, goal: GoalSpec(summary: goal)) + defer { NodeMemory.remove(projectPath: "/tmp", nodeID: probe.id) } + let command = try #require( + ZmxSessionLauncher.arguments(forNode: probe, projectPath: "/tmp", settings: settings)) + return command.reduce(0) { $0 + $1.utf8.count + 3 } + } + let budget = ZmxSessionLauncher.maximumTypedCommandBytes + let unbriefedBaseline = try bytes("x", unbriefedSettings) + let goal = String(repeating: "x", count: budget - unbriefedBaseline - 8) + try #require(try bytes(goal, unbriefedSettings) <= budget) + try #require(try bytes("x", settings) + goal.utf8.count > budget) + let node = LoopNode(title: "Lead", loopType: .goalBased, goal: GoalSpec(summary: goal)) + defer { NodeMemory.remove(projectPath: "/tmp", nodeID: node.id) } + + let arguments = try #require( + ZmxSessionLauncher.arguments(forNode: node, projectPath: "/tmp", settings: settings)) + + #expect(ZmxSessionLauncher.fitsInATypedCommandLine(arguments)) + #expect(arguments.joined(separator: " ").contains("--append-system-prompt-file")) + #expect(arguments.last?.contains(NodeMemory.promptFileName) == true) + let file = NodeMemory.directory(forProjectPath: "/tmp", nodeID: node.id) + .appendingPathComponent(NodeMemory.promptFileName) + #expect((try? String(contentsOf: file, encoding: .utf8))?.contains(goal) == true) + } + @Test func aListingThatCouldNotBeTakenIsUnknownNotAbsent() { // `zmx ls` exits 0 whenever it runs at all, so a nil status (the subprocess threw)