feat(v0.6): launch path carries ResolvedAgent (command + args + env)
LaunchOpts.Agent (string) and WorkspaceEntryOptions.Agent (string) are replaced by *agent.Resolved, carrying Command, Args, and Env. The five entry commands (new, resume, last, open, attach) each construct an AgentSpec from the workspace metadata, apply --agent as a one-shot agent.command override (Open Q 1 — keeps muscle memory for users passing executable paths), call agent.Resolve, and pass the result through. resolveEntryAgent centralises the resume/last/open/attach path. shell.ExecAgent and shell.ExecTmuxAgent gain an args parameter; agent.env is merged into the env map at the session.Run launch switch, AFTER ctask's exported CTASK_* vars (per spec §5: agent.env wins on collision). mergeAgentEnv is the centralised merge. Lease, manifest, write lock, heartbeat, summary, and provisional cleanup are unchanged. The Agent string fields on Lease, SessionSummary, and SessionInfo continue to record the launched command for diagnostics.
This commit is contained in:
@@ -68,7 +68,7 @@ func AdoptExistingPersistentSession(tmuxPath, sessionName, wsDir string, opts La
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"[ctask] warning: failed to remove orphaned lease: %v\n", rmErr)
|
||||
}
|
||||
lease := NewLease(startTime, opts.Agent, opts.Mode)
|
||||
lease := NewLease(startTime, leaseAgentCommand(opts), opts.Mode)
|
||||
if err := WriteLease(leasePath, lease); err != nil {
|
||||
return fmt.Errorf("writing lease: %w", err)
|
||||
}
|
||||
@@ -152,12 +152,12 @@ func finalizeAdopted(opts LaunchOpts, wsDir string, startManifest *Manifest, sta
|
||||
}
|
||||
diff := DiffManifests(startManifest, endManifest)
|
||||
|
||||
agent := opts.Agent
|
||||
agentCmd := leaseAgentCommand(opts)
|
||||
if opts.Shell {
|
||||
agent = "shell"
|
||||
agentCmd = "shell"
|
||||
}
|
||||
info := &SessionInfo{
|
||||
Agent: agent,
|
||||
Agent: agentCmd,
|
||||
Mode: opts.Mode,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
@@ -170,7 +170,7 @@ func finalizeAdopted(opts LaunchOpts, wsDir string, startManifest *Manifest, sta
|
||||
}
|
||||
|
||||
summary := SummarizeFromDiff(
|
||||
sessionID, currentHostname(), agent, opts.Mode,
|
||||
sessionID, currentHostname(), agentCmd, opts.Mode,
|
||||
startTime, endTime, diff, endManifest,
|
||||
)
|
||||
summary.EndReason = "tmux_session_ended"
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/warrenronsiek/ctask/internal/agent"
|
||||
"github.com/warrenronsiek/ctask/internal/workspace"
|
||||
)
|
||||
|
||||
@@ -76,14 +77,14 @@ func (fx *adoptionFixture) stubSeams(t *testing.T) {
|
||||
|
||||
func defaultAdoptionOpts(wsDir string) LaunchOpts {
|
||||
return LaunchOpts{
|
||||
WsDir: wsDir,
|
||||
Agent: "claude",
|
||||
Mode: "local",
|
||||
Slug: "demo",
|
||||
Category: "projects",
|
||||
SessionMode: "persistent",
|
||||
SessionName: "ctask-projects-demo-abc123",
|
||||
TmuxPath: "/usr/bin/tmux",
|
||||
WsDir: wsDir,
|
||||
ResolvedAgent: &agent.Resolved{Command: "claude"},
|
||||
Mode: "local",
|
||||
Slug: "demo",
|
||||
Category: "projects",
|
||||
SessionMode: "persistent",
|
||||
SessionName: "ctask-projects-demo-abc123",
|
||||
TmuxPath: "/usr/bin/tmux",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+61
-11
@@ -7,19 +7,57 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/warrenronsiek/ctask/internal/agent"
|
||||
"github.com/warrenronsiek/ctask/internal/lockfile"
|
||||
"github.com/warrenronsiek/ctask/internal/shell"
|
||||
"github.com/warrenronsiek/ctask/internal/workspace"
|
||||
)
|
||||
|
||||
// execAgent is the test seam for the direct-mode child launch. Tests
|
||||
// replace this to capture the (command, args, wsDir, env) tuple Run
|
||||
// would have passed to shell.ExecAgent. Restored via t.Cleanup.
|
||||
var execAgent = shell.ExecAgent
|
||||
|
||||
// mergeAgentEnv overlays agentEnv on top of base, returning a new map.
|
||||
// Per v0.6 spec §5 ("merged into the agent's environment, after ctask's
|
||||
// own exported vars"), agentEnv keys WIN on collision — the spec is
|
||||
// explicit that user-supplied env vars take precedence. Callers that
|
||||
// want a warning for shadowed CTASK_* keys must emit it themselves
|
||||
// (ctask agents check surfaces this).
|
||||
func mergeAgentEnv(base, agentEnv map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(base)+len(agentEnv))
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range agentEnv {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// leaseAgentCommand returns the command string recorded in leases and
|
||||
// summaries for diagnostics. A nil ResolvedAgent yields "" (only happens
|
||||
// in tests that do not exercise a launch).
|
||||
func leaseAgentCommand(opts LaunchOpts) string {
|
||||
if opts.ResolvedAgent != nil {
|
||||
return opts.ResolvedAgent.Command
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// LaunchOpts configures a session launch.
|
||||
type LaunchOpts struct {
|
||||
WsDir string
|
||||
EnvVars map[string]string
|
||||
Agent string
|
||||
Mode string
|
||||
Slug string
|
||||
Shell bool // true = interactive shell, false = agent
|
||||
|
||||
// ResolvedAgent is the launch-ready agent (command + args + env),
|
||||
// produced by the cmd layer via agent.Resolve. Required in agent mode;
|
||||
// in shell mode it is still populated for lease/summary diagnostics.
|
||||
ResolvedAgent *agent.Resolved
|
||||
|
||||
Mode string
|
||||
Slug string
|
||||
Shell bool // true = interactive shell, false = agent
|
||||
|
||||
// LaunchDir is the workspace-relative launch directory (v0.5). Empty for
|
||||
// tasks and pre-v0.5 projects. When set, Run resolves the absolute path
|
||||
@@ -127,7 +165,7 @@ func Run(opts LaunchOpts) error {
|
||||
leasePath := LeasePath(opts.WsDir)
|
||||
ownLease := !preflight.ActiveLeaseFound
|
||||
if ownLease {
|
||||
lease := NewLease(startTime, opts.Agent, opts.Mode)
|
||||
lease := NewLease(startTime, leaseAgentCommand(opts), opts.Mode)
|
||||
skipped, lockErr := lockfile.WithLock(
|
||||
ctaskWriteLockPath(opts.WsDir),
|
||||
sessionWriteLockTimeout, sessionWriteLockStaleAfter,
|
||||
@@ -187,6 +225,15 @@ func Run(opts LaunchOpts) error {
|
||||
fmt.Fprintln(os.Stderr, launchWarn)
|
||||
}
|
||||
|
||||
// Agent-launch branches dereference ResolvedAgent; a nil here is an
|
||||
// internal wiring bug (the cmd layer must always Resolve before Run).
|
||||
if !opts.Shell && opts.ResolvedAgent == nil {
|
||||
if hb != nil {
|
||||
hb.Stop()
|
||||
}
|
||||
return fmt.Errorf("internal error: LaunchOpts.ResolvedAgent is nil in agent mode")
|
||||
}
|
||||
|
||||
// ---- Run the child ----
|
||||
var childErr error
|
||||
switch {
|
||||
@@ -213,7 +260,9 @@ func Run(opts LaunchOpts) error {
|
||||
if opts.Shell {
|
||||
childErr = shell.ExecTmuxShell(opts.TmuxPath, opts.SessionName, launchAbs, opts.EnvVars)
|
||||
} else {
|
||||
childErr = shell.ExecTmuxAgent(opts.TmuxPath, opts.SessionName, launchAbs, opts.EnvVars, opts.Agent)
|
||||
childErr = shell.ExecTmuxAgent(opts.TmuxPath, opts.SessionName, launchAbs,
|
||||
mergeAgentEnv(opts.EnvVars, opts.ResolvedAgent.Env),
|
||||
opts.ResolvedAgent.Command, opts.ResolvedAgent.Args)
|
||||
}
|
||||
case opts.Shell:
|
||||
childErr = shell.ExecShell(launchAbs, opts.EnvVars, opts.Slug, opts.Mode)
|
||||
@@ -221,7 +270,8 @@ func Run(opts LaunchOpts) error {
|
||||
for _, line := range shell.BannerLines(opts.Mode, opts.Slug, opts.WsDir, opts.LaunchDir) {
|
||||
fmt.Println(line)
|
||||
}
|
||||
childErr = shell.ExecAgent(opts.Agent, launchAbs, opts.EnvVars)
|
||||
childErr = execAgent(opts.ResolvedAgent.Command, opts.ResolvedAgent.Args, launchAbs,
|
||||
mergeAgentEnv(opts.EnvVars, opts.ResolvedAgent.Env))
|
||||
}
|
||||
|
||||
if hb != nil {
|
||||
@@ -284,12 +334,12 @@ func finalize(opts LaunchOpts, startManifest *Manifest, startTime, endTime time.
|
||||
}
|
||||
diff := DiffManifests(startManifest, endManifest)
|
||||
|
||||
agent := opts.Agent
|
||||
agentCmd := leaseAgentCommand(opts)
|
||||
if opts.Shell {
|
||||
agent = "shell"
|
||||
agentCmd = "shell"
|
||||
}
|
||||
info := &SessionInfo{
|
||||
Agent: agent,
|
||||
Agent: agentCmd,
|
||||
Mode: opts.Mode,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
@@ -307,7 +357,7 @@ func finalize(opts LaunchOpts, startManifest *Manifest, startTime, endTime time.
|
||||
}
|
||||
|
||||
summary := SummarizeFromDiff(
|
||||
sessionID, currentHostname(), agent, opts.Mode,
|
||||
sessionID, currentHostname(), agentCmd, opts.Mode,
|
||||
startTime, endTime, diff, endManifest,
|
||||
)
|
||||
if opts.SessionMode == "persistent" {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/warrenronsiek/ctask/internal/agent"
|
||||
)
|
||||
|
||||
func TestMergeAgentEnvOverlays(t *testing.T) {
|
||||
base := map[string]string{"CTASK_WORKSPACE": "/ws", "PATH": "/bin"}
|
||||
agentEnv := map[string]string{"OPENAI_API_KEY": "x", "CTASK_WORKSPACE": "shadowed"}
|
||||
got := mergeAgentEnv(base, agentEnv)
|
||||
|
||||
if got["OPENAI_API_KEY"] != "x" {
|
||||
t.Errorf("OPENAI_API_KEY = %q, want x", got["OPENAI_API_KEY"])
|
||||
}
|
||||
if got["PATH"] != "/bin" {
|
||||
t.Errorf("PATH = %q, want /bin (untouched base)", got["PATH"])
|
||||
}
|
||||
if got["CTASK_WORKSPACE"] != "shadowed" {
|
||||
t.Errorf("CTASK_WORKSPACE = %q, want shadowed (agent.env wins per spec §5)",
|
||||
got["CTASK_WORKSPACE"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMergesAgentEnvIntoChildEnvironment(t *testing.T) {
|
||||
wsDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(wsDir, ".ctask"), 0755); err != nil {
|
||||
t.Fatalf("mkdir .ctask: %v", err)
|
||||
}
|
||||
|
||||
var capturedEnv map[string]string
|
||||
prev := execAgent
|
||||
execAgent = func(command string, args []string, dir string, env map[string]string) error {
|
||||
capturedEnv = env
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { execAgent = prev })
|
||||
|
||||
err := Run(LaunchOpts{
|
||||
WsDir: wsDir,
|
||||
EnvVars: map[string]string{
|
||||
"CTASK_WORKSPACE": "/ws-base",
|
||||
"PATH": "/bin",
|
||||
},
|
||||
Mode: "local",
|
||||
Slug: "env-merge",
|
||||
ResolvedAgent: &agent.Resolved{
|
||||
Command: "go",
|
||||
Args: nil,
|
||||
Env: map[string]string{
|
||||
"OPENAI_API_KEY": "x",
|
||||
"CTASK_WORKSPACE": "shadowed-by-agent-env",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if capturedEnv["OPENAI_API_KEY"] != "x" {
|
||||
t.Errorf("OPENAI_API_KEY = %q, want x", capturedEnv["OPENAI_API_KEY"])
|
||||
}
|
||||
if capturedEnv["PATH"] != "/bin" {
|
||||
t.Errorf("PATH = %q, want /bin", capturedEnv["PATH"])
|
||||
}
|
||||
if capturedEnv["CTASK_WORKSPACE"] != "shadowed-by-agent-env" {
|
||||
t.Errorf("CTASK_WORKSPACE = %q, want shadowed-by-agent-env (spec §5: agent.env wins)",
|
||||
capturedEnv["CTASK_WORKSPACE"])
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/warrenronsiek/ctask/internal/agent"
|
||||
)
|
||||
|
||||
func TestFinalizeWritesSummaryAndClearsLease(t *testing.T) {
|
||||
@@ -31,10 +33,10 @@ func TestFinalizeWritesSummaryAndClearsLease(t *testing.T) {
|
||||
|
||||
end := time.Now().UTC().Truncate(time.Second)
|
||||
opts := LaunchOpts{
|
||||
WsDir: wsDir,
|
||||
Agent: "claude",
|
||||
Mode: "local",
|
||||
Slug: "test",
|
||||
WsDir: wsDir,
|
||||
ResolvedAgent: &agent.Resolved{Command: "claude"},
|
||||
Mode: "local",
|
||||
Slug: "test",
|
||||
}
|
||||
|
||||
if err := finalize(opts, startManifest, start, end, true); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user