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
38 changes: 38 additions & 0 deletions GraphcodeKit/Sources/Sessions/NodeMemory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 83 additions & 17 deletions GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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(
Expand All @@ -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()
}
Expand Down
27 changes: 19 additions & 8 deletions graphcode/Tests/SessionBriefingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions graphcode/Tests/ZmxSessionLauncherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down