diff --git a/docs/design/system-prompts.ja.md b/docs/design/system-prompts.ja.md index d4905b1e..83cdbb51 100644 --- a/docs/design/system-prompts.ja.md +++ b/docs/design/system-prompts.ja.md @@ -11,12 +11,13 @@ 開始時に注入されるプロンプトは次のとおりです。 ````text -Use `tq` to keep the issue tracker synchronized: +Use `{{ tq.command }}` to keep the issue tracker synchronized: If the `tasq-cli` skill is available, use it as the preferred guidance for tracker operations. -- Prefer typed `tq` commands such as `tq issue`, `tq comment`, and `tq artifact` for issue tracker operations. -- Use `tq api` only when the issue tracker operation has no typed `tq` command. +- Use `{{ tq.command }}` for every Tasq CLI operation, including commands shown elsewhere as `tq`; it identifies the CLI that started the managed services. +- Prefer typed commands such as `{{ tq.command }} issue`, `{{ tq.command }} comment`, and `{{ tq.command }} artifact` for issue tracker operations. +- Use `{{ tq.command }} api` only when the issue tracker operation has no typed command. - Do not call the issue tracker API directly with `curl`, `wget`, or a custom HTTP script. This restriction applies only to the issue tracker API, not to other services or local endpoint verification. - When work starts, move the issue to `in_progress` and leave a progress comment. - Add progress comments at meaningful milestones during the work. @@ -28,24 +29,25 @@ If the `tasq-cli` skill is available, use it as the preferred guidance for track ```sh # Start -tq issue update {{ issue.id }} --status in_progress -tq comment add {{ issue.id }} --author codex --type progress --body "Started work." +{{ tq.command }} issue update {{ issue.id }} --status in_progress +{{ tq.command }} comment add {{ issue.id }} --author codex --type progress --body "Started work." # Meaningful progress milestone -tq comment add {{ issue.id }} --author codex --type progress --body "Implemented the change; running verification." +{{ tq.command }} comment add {{ issue.id }} --author codex --type progress --body "Implemented the change; running verification." # Blocked (use instead of the review handoff) -tq comment add {{ issue.id }} --author codex --type blocker --body "Blocked: explain the blocker and what is needed." -tq issue update {{ issue.id }} --status blocked +{{ tq.command }} comment add {{ issue.id }} --author codex --type blocker --body "Blocked: explain the blocker and what is needed." +{{ tq.command }} issue update {{ issue.id }} --status blocked # Ready for review -tq artifact set {{ issue.id }} --type pull_request -tq comment add {{ issue.id }} --author codex --type handoff --body "PR: ; verification: ." -tq issue update {{ issue.id }} --status review +{{ tq.command }} artifact set {{ issue.id }} --type pull_request +{{ tq.command }} comment add {{ issue.id }} --author codex --type handoff --body "PR: ; verification: ." +{{ tq.command }} issue update {{ issue.id }} --status review ``` -Run the installed `tq` binary from `PATH`. Do not use `go run ./cmd/tq` for -tracker synchronization. +Tasq CLI のすべての操作には `{{ tq.command }}` を使います。managed service では service を起動した +CLI の永続 snapshot を `"$TQ_EXECUTABLE"` として展開し、orchestrator の直接起動では `tq` に +fallback します。`PATH` 上の別 executable に置き換えてはいけません。 ```` `{{ issue.id }}` などのテンプレート変数は、このプロンプトを先頭へ追加した後に展開されます。`tasq.task_work_prompt: false` を設定すると、Issue Tracker と Artifact に関する指示を含む開始プロンプト全体が無効になりますが、継続時の動作は変わりません。 @@ -59,7 +61,7 @@ Pull Request Artifact は、現在レビューを依頼している主要 PR を 再開時に注入されるプロンプトは次のとおりです。 ```text -First run `tq issue update --status in_progress` to keep the issue tracker synchronized. Then continue the same task in this live thread without repeating completed work, and stop when it is ready for handoff. If this continuation creates or updates a pull request, register the primary PR before handoff with `tq artifact set --type pull_request `. On success, add the handoff comment, then move the issue to `review`; on failure, retry a reasonable number of times, then leave a blocker comment and do not move to `review` if it remains unresolved. Otherwise, artifact registration is not required. +First run ` issue update --status in_progress` to keep the issue tracker synchronized. Then continue the same task in this live thread without repeating completed work, and stop when it is ready for handoff. If this continuation creates or updates a pull request, register the primary PR before handoff with ` artifact set --type pull_request `. On success, add the handoff comment, then move the issue to `review`; on failure, retry a reasonable number of times, then leave a blocker comment and do not move to `review` if it remains unresolved. Otherwise, artifact registration is not required. ``` -`` はターン開始前に現在の課題 ID で埋め込まれます。同じ継続プロンプトは、有効な複数ターン実行の後続ターンでも使われます。既存の再開条件または後続ターン条件によって継続ターンが選ばれた場合だけ送信され、継続が無効な場合に余分なターンを追加することはありません。担当する変更要求の指示がある場合は、従来どおりこの注意事項の後ろに追加されます。 +`` は managed run では `"$TQ_EXECUTABLE"`、それ以外では `tq` になります。`` はターン開始前に現在の課題 ID で埋め込まれます。同じ継続プロンプトは、有効な複数ターン実行の後続ターンでも使われます。既存の再開条件または後続ターン条件によって継続ターンが選ばれた場合だけ送信され、継続が無効な場合に余分なターンを追加することはありません。担当する変更要求の指示がある場合は、従来どおりこの注意事項の後ろに追加されます。 diff --git a/docs/design/system-prompts.md b/docs/design/system-prompts.md index 7567f185..70ebfccb 100644 --- a/docs/design/system-prompts.md +++ b/docs/design/system-prompts.md @@ -11,12 +11,13 @@ On a first run, when `Task.ResumeThreadID` is empty, the runner renders the effe The injected start prompt is: ````text -Use `tq` to keep the issue tracker synchronized: +Use `{{ tq.command }}` to keep the issue tracker synchronized: If the `tasq-cli` skill is available, use it as the preferred guidance for tracker operations. -- Prefer typed `tq` commands such as `tq issue`, `tq comment`, and `tq artifact` for issue tracker operations. -- Use `tq api` only when the issue tracker operation has no typed `tq` command. +- Use `{{ tq.command }}` for every Tasq CLI operation, including commands shown elsewhere as `tq`; it identifies the CLI that started the managed services. +- Prefer typed commands such as `{{ tq.command }} issue`, `{{ tq.command }} comment`, and `{{ tq.command }} artifact` for issue tracker operations. +- Use `{{ tq.command }} api` only when the issue tracker operation has no typed command. - Do not call the issue tracker API directly with `curl`, `wget`, or a custom HTTP script. This restriction applies only to the issue tracker API, not to other services or local endpoint verification. - When work starts, move the issue to `in_progress` and leave a progress comment. - Add progress comments at meaningful milestones during the work. @@ -28,24 +29,25 @@ If the `tasq-cli` skill is available, use it as the preferred guidance for track ```sh # Start -tq issue update {{ issue.id }} --status in_progress -tq comment add {{ issue.id }} --author codex --type progress --body "Started work." +{{ tq.command }} issue update {{ issue.id }} --status in_progress +{{ tq.command }} comment add {{ issue.id }} --author codex --type progress --body "Started work." # Meaningful progress milestone -tq comment add {{ issue.id }} --author codex --type progress --body "Implemented the change; running verification." +{{ tq.command }} comment add {{ issue.id }} --author codex --type progress --body "Implemented the change; running verification." # Blocked (use instead of the review handoff) -tq comment add {{ issue.id }} --author codex --type blocker --body "Blocked: explain the blocker and what is needed." -tq issue update {{ issue.id }} --status blocked +{{ tq.command }} comment add {{ issue.id }} --author codex --type blocker --body "Blocked: explain the blocker and what is needed." +{{ tq.command }} issue update {{ issue.id }} --status blocked # Ready for review -tq artifact set {{ issue.id }} --type pull_request -tq comment add {{ issue.id }} --author codex --type handoff --body "PR: ; verification: ." -tq issue update {{ issue.id }} --status review +{{ tq.command }} artifact set {{ issue.id }} --type pull_request +{{ tq.command }} comment add {{ issue.id }} --author codex --type handoff --body "PR: ; verification: ." +{{ tq.command }} issue update {{ issue.id }} --status review ``` -Run the installed `tq` binary from `PATH`. Do not use `go run ./cmd/tq` for -tracker synchronization. +Use `{{ tq.command }}` for every Tasq CLI operation. Managed services render it as +`"$TQ_EXECUTABLE"`, a persistent snapshot of the CLI that started the services; direct +orchestrator launches fall back to `tq`. Do not substitute another executable from `PATH`. ```` Template variables such as `{{ issue.id }}` are rendered after this prompt is prepended. Setting `tasq.task_work_prompt: false` disables this entire start prompt, including its tracker and artifact instructions; it does not change continuation behavior. @@ -59,7 +61,7 @@ On a resumed run, when `Task.ResumeThreadID` is set, the runner resumes the exis The injected resume prompt is: ```text -First run `tq issue update --status in_progress` to keep the issue tracker synchronized. Then continue the same task in this live thread without repeating completed work, and stop when it is ready for handoff. If this continuation creates or updates a pull request, register the primary PR before handoff with `tq artifact set --type pull_request `. On success, add the handoff comment, then move the issue to `review`; on failure, retry a reasonable number of times, then leave a blocker comment and do not move to `review` if it remains unresolved. Otherwise, artifact registration is not required. +First run ` issue update --status in_progress` to keep the issue tracker synchronized. Then continue the same task in this live thread without repeating completed work, and stop when it is ready for handoff. If this continuation creates or updates a pull request, register the primary PR before handoff with ` artifact set --type pull_request `. On success, add the handoff comment, then move the issue to `review`; on failure, retry a reasonable number of times, then leave a blocker comment and do not move to `review` if it remains unresolved. Otherwise, artifact registration is not required. ``` -`` is filled from the current task issue ID before the turn starts. The same continuation prompt is also used for later turns in an enabled multi-turn run. It is sent only when the runner's existing resume or later-turn conditions select a continuation turn; the runner does not add an extra turn when continuation is disabled. Assigned change-request guidance, when present, remains appended after this reminder. +`` uses `"$TQ_EXECUTABLE"` in managed runs and `tq` otherwise. `` is filled from the current task issue ID before the turn starts. The same continuation prompt is also used for later turns in an enabled multi-turn run. It is sent only when the runner's existing resume or later-turn conditions select a continuation turn; the runner does not add an extra turn when continuation is disabled. Assigned change-request guidance, when present, remains appended after this reminder. diff --git a/docs/symphony/WORKFLOW_CONTRACT.ja.md b/docs/symphony/WORKFLOW_CONTRACT.ja.md index 522fb21d..22853f7a 100644 --- a/docs/symphony/WORKFLOW_CONTRACT.ja.md +++ b/docs/symphony/WORKFLOW_CONTRACT.ja.md @@ -86,6 +86,7 @@ front matter の閉じ `---` 以降がすべてプロンプトテンプレート | `{{ issue.title }}` | string | 課題タイトル | | `{{ issue.description }}`| string | 課題説明本文 | | `{{ attempt }}` | int | attempt 番号(初回は 0、リトライは 1 以上) | +| `{{ tq.command }}` | string | service 起動元から継承した CLI command | 変数は単純な文字列置換で展開されます。認識されない `{{ ... }}` トークンはそのまま残ります。 @@ -96,9 +97,15 @@ front matter の閉じ `---` 以降がすべてプロンプトテンプレート #### Issue ステータス更新 -デフォルトでは、Tasq はエージェントに `tq` CLI で progress comment と issue status update を行うように -指示を注入します。実行時環境に `tq` が `PATH` 上にあり、`TQ_API_URL` が -issue-tracker のエンドポイントに設定されている必要があります。 +デフォルトでは、Tasq はエージェントに `{{ tq.command }}` が表す CLI command で progress comment と +issue status update を行うよう指示を注入します。`tq service start` で起動した service は、起動元の +CLI を `TQ_HOME` 配下の永続 managed executable へコピーし、その path を `TQ_EXECUTABLE` で継承します。そのため `tqdev` から起動した場合は、 +`PATH` 上で別の `tq` が先に見つかっても同じ `tqdev` executable を使い続けます。この環境契約を持たずに +orchestrator を直接起動した場合は、後方互換性のため `tq` に fallback します。 + +managed agent run は `TQ_MANAGED_RUN=1` も継承します。この文脈では、run を所有する orchestrator を +終了させる可能性があるため、`tq update` と `tq service stop` は service state を変更する前に失敗します。 +service lifecycle command と update command は user shell から実行してください。 ワークフロー作成者は通常、これらの `tq` 指示をプロンプトテンプレートで繰り返す必要はありません。 `tasq.task_work_prompt` を `false` に設定した場合、ワークフローテンプレート側で同等の issue-tracker diff --git a/docs/symphony/WORKFLOW_CONTRACT.md b/docs/symphony/WORKFLOW_CONTRACT.md index 1e02b7cf..db085099 100644 --- a/docs/symphony/WORKFLOW_CONTRACT.md +++ b/docs/symphony/WORKFLOW_CONTRACT.md @@ -87,6 +87,7 @@ issue-tracker synchronization instructions before template variable expansion, s | `{{ issue.title }}` | string | Issue title | | `{{ issue.description }}`| string | Issue description body | | `{{ attempt }}` | int | Attempt number (0 for first run, >=1 for retries) | +| `{{ tq.command }}` | string | CLI command inherited from the service starter | Variables are replaced by simple string substitution. Unrecognized `{{ ... }}` tokens are left as-is. @@ -98,9 +99,17 @@ interacting with the issue-tracker. #### Issue Status Updates -By default, Tasq injects instructions that tell the agent to use the `tq` CLI for progress comments -and issue status updates. The runtime environment must have `tq` on `PATH` and `TQ_API_URL` set to -the issue-tracker endpoint. +By default, Tasq injects instructions that tell the agent to use the CLI command represented by +`{{ tq.command }}` for progress comments and issue status updates. Services started by +`tq service start` copy the starter into a persistent managed executable under `TQ_HOME` and +inherit that path through `TQ_EXECUTABLE`; a +`tqdev` starter therefore keeps using that same `tqdev` executable even if another `tq` appears +earlier on `PATH`. Direct orchestrator launches without this environment contract fall back to +`tq` for backward compatibility. + +Managed agent runs also inherit `TQ_MANAGED_RUN=1`. In that context, `tq update` and +`tq service stop` fail before changing service state because either command could terminate the +orchestrator that owns the run. Run lifecycle and update commands from a user shell instead. Workflow authors normally do not need to repeat these `tq` instructions in the prompt template. If `tasq.task_work_prompt` is set to `false`, the workflow template is responsible for providing diff --git a/internal/cli/tq/command_test.go b/internal/cli/tq/command_test.go index bb605dfd..48622b21 100644 --- a/internal/cli/tq/command_test.go +++ b/internal/cli/tq/command_test.go @@ -183,6 +183,34 @@ func TestUpdateProfileAllowed(t *testing.T) { } } +func TestUpdateRejectsOrchestratorManagedRun(t *testing.T) { + t.Setenv(tqconfig.EnvManagedRun, "1") + runner := &fakeUpdateRunner{} + + _, stderr, code := runCLIWithUpdateRunner(t, []string{"update", "-y"}, "", runner) + + if code != 1 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + assertStringSlice(t, runner.calls, nil) + if message := decodeCLIError(t, stderr); !strings.Contains(message, "orchestrator-managed run") { + t.Fatalf("error = %q", message) + } +} + +func TestServiceStopRejectsOrchestratorManagedRun(t *testing.T) { + t.Setenv(tqconfig.EnvManagedRun, "1") + + _, stderr, code := runCLI(t, []string{"service", "stop"}) + + if code != 1 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if message := decodeCLIError(t, stderr); !strings.Contains(message, "orchestrator-managed run") { + t.Fatalf("error = %q", message) + } +} + func TestUpdateYesSkipsConfirmation(t *testing.T) { runner := &fakeUpdateRunner{ current: "tq v0.1.0 (commit: old)", diff --git a/internal/cli/tq/service.go b/internal/cli/tq/service.go index 363ceba1..51a96661 100644 --- a/internal/cli/tq/service.go +++ b/internal/cli/tq/service.go @@ -64,6 +64,11 @@ func (a app) routeService(ctx context.Context, args []string, cfg config) error return nil } action := args[0] + if action == "stop" { + if err := rejectManagedServiceMutation("stop"); err != nil { + return err + } + } switch action { case "start": return a.serviceStart(ctx, args[1:], cfg) @@ -116,6 +121,10 @@ func (a app) serviceStart(ctx context.Context, args []string, cfg config) error if err := checkMigrationTargetsNoPending(ctx); err != nil { return fmt.Errorf("migration pre-flight check failed: %w", err) } + cliExecutable, err := prepareManagedCLI(home) + if err != nil { + return err + } addresses, fallback, err := serviceStartAddresses() if err != nil { @@ -149,7 +158,7 @@ func (a app) serviceStart(ctx context.Context, args []string, cfg config) error "-db", issueDB, }, } - if _, err := startManagedService(ctx, home, issueService); err != nil { + if _, err := startManagedService(ctx, home, cliExecutable, issueService); err != nil { return err } if err := waitIssueTrackerHealthy(ctx, "http://"+issueAddr); err != nil { @@ -166,7 +175,7 @@ func (a app) serviceStart(ctx context.Context, args []string, cfg config) error "-port", strconv.Itoa(servicePort(orchestratorAddr)), }, } - if _, err := startManagedService(ctx, home, orchestratorService); err != nil { + if _, err := startManagedService(ctx, home, cliExecutable, orchestratorService); err != nil { _ = stopServiceByName(context.Background(), serviceIssueTracker) return err } @@ -185,7 +194,7 @@ func (a app) serviceStart(ctx context.Context, args []string, cfg config) error "-orchestrator-url", "http://" + orchestratorAddr, }, } - if _, err := startManagedService(ctx, home, webService); err != nil { + if _, err := startManagedService(ctx, home, cliExecutable, webService); err != nil { _ = stopServiceByName(context.Background(), serviceOrchestrator) _ = stopServiceByName(context.Background(), serviceIssueTracker) return err @@ -376,8 +385,8 @@ func (a app) serviceStatus(ctx context.Context, args []string, cfg config) error return writeServiceStatuses(a.stdout, statuses) } -func startManagedService(ctx context.Context, home string, service managedService) (int, error) { - cmd, err := commandForService(ctx, home, service) +func startManagedService(ctx context.Context, home string, cliExecutable string, service managedService) (int, error) { + cmd, err := commandForService(ctx, home, cliExecutable, service) if err != nil { return 0, err } @@ -395,13 +404,13 @@ func startManagedService(ctx context.Context, home string, service managedServic return cmd.Process.Pid, nil } -func commandForService(ctx context.Context, home string, service managedService) (*exec.Cmd, error) { +func commandForService(ctx context.Context, home string, cliExecutable string, service managedService) (*exec.Cmd, error) { executable := serviceExecutablePath(home, service.name) if err := validateServiceExecutable(service.name, executable); err != nil { return nil, err } command := exec.CommandContext(ctx, executable, service.args...) - command.Env = serviceCommandEnv(home, os.Environ()) + command.Env = serviceCommandEnv(home, cliExecutable, os.Environ()) return command, nil } @@ -440,14 +449,65 @@ func serviceExecutablePath(home string, name serviceName) string { return filepath.Join(tqconfig.SystemDir(home), "bin", string(name)) } -func serviceCommandEnv(home string, environment []string) []string { - filtered := make([]string, 0, len(environment)+1) +func serviceCommandEnv(home string, cliExecutable string, environment []string) []string { + filtered := make([]string, 0, len(environment)+3) for _, entry := range environment { - if !strings.HasPrefix(entry, tqconfig.EnvHome+"=") { + if !environmentEntryMatches(entry, tqconfig.EnvHome) && + !environmentEntryMatches(entry, tqconfig.EnvExecutable) && + !environmentEntryMatches(entry, tqconfig.EnvManagedRun) { filtered = append(filtered, entry) } } - return append(filtered, tqconfig.EnvHome+"="+home) + return append(filtered, + tqconfig.EnvHome+"="+home, + tqconfig.EnvExecutable+"="+cliExecutable, + tqconfig.EnvManagedRun+"=1", + ) +} + +func environmentEntryMatches(entry string, name string) bool { + return strings.HasPrefix(entry, name+"=") +} + +func currentExecutable() (string, error) { + executable, err := os.Executable() + if err != nil { + return "", fmt.Errorf("resolve tq executable: %w", err) + } + executable, err = filepath.Abs(executable) + if err != nil { + return "", fmt.Errorf("resolve absolute tq executable: %w", err) + } + if resolved, err := filepath.EvalSymlinks(executable); err == nil { + executable = resolved + } + return filepath.Clean(executable), nil +} + +func prepareManagedCLI(home string) (string, error) { + source, err := currentExecutable() + if err != nil { + return "", err + } + return copyManagedCLI(home, source) +} + +func copyManagedCLI(home string, source string) (string, error) { + destination := filepath.Join(serviceInstallDir(home), "tq-managed") + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return "", fmt.Errorf("create managed tq directory: %w", err) + } + if err := installExecutable(source, destination); err != nil { + return "", fmt.Errorf("prepare managed tq executable: %w", err) + } + return destination, nil +} + +func rejectManagedServiceMutation(action string) error { + if os.Getenv(tqconfig.EnvManagedRun) != "1" { + return nil + } + return fmt.Errorf("tq service %s is unavailable inside an orchestrator-managed run; run it from a user shell", action) } func openServiceLog(home string, name string) (*os.File, error) { diff --git a/internal/cli/tq/service_test.go b/internal/cli/tq/service_test.go index 87572a7c..d72f31f5 100644 --- a/internal/cli/tq/service_test.go +++ b/internal/cli/tq/service_test.go @@ -57,13 +57,16 @@ func TestServiceStartAddressesUsesFallbackWhenDefaultPortIsUnavailable(t *testin } } -func TestServiceCommandEnvReplacesTQHome(t *testing.T) { +func TestServiceCommandEnvSetsManagedExecutionContract(t *testing.T) { home := t.TempDir() - environment := serviceCommandEnv(home, []string{ + cliExecutable := filepath.Join(t.TempDir(), "tqdev") + environment := serviceCommandEnv(home, cliExecutable, []string{ "PATH=/usr/bin", "TQ_HOME=/other/home", "TQ_HOME_SUFFIX=preserved", "TQ_HOME=/another/home", + "TQ_EXECUTABLE=/old/tq", + "TQ_MANAGED_RUN=0", }) var homes []string @@ -78,6 +81,14 @@ func TestServiceCommandEnvReplacesTQHome(t *testing.T) { if !containsString(environment, "TQ_HOME_SUFFIX=preserved") { t.Fatalf("environment = %v, want TQ_HOME_SUFFIX preserved", environment) } + for _, want := range []string{ + tqconfig.EnvExecutable + "=" + cliExecutable, + tqconfig.EnvManagedRun + "=1", + } { + if !containsString(environment, want) { + t.Fatalf("environment = %v, want %q", environment, want) + } + } } func TestValidateServiceExecutablesReportsAllUnavailableTargets(t *testing.T) { @@ -109,7 +120,8 @@ func TestCommandForServiceUsesHomeSystemBinExecutable(t *testing.T) { if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatalf("write service executable: %v", err) } - command, err := commandForService(context.Background(), home, managedService{name: serviceIssueTracker}) + cliExecutable := filepath.Join(t.TempDir(), "tqdev") + command, err := commandForService(context.Background(), home, cliExecutable, managedService{name: serviceIssueTracker}) if err != nil { t.Fatalf("command for service: %v", err) } @@ -119,6 +131,65 @@ func TestCommandForServiceUsesHomeSystemBinExecutable(t *testing.T) { if !containsString(command.Env, tqconfig.EnvHome+"="+home) { t.Fatalf("command environment does not contain resolved home: %v", command.Env) } + if !containsString(command.Env, tqconfig.EnvExecutable+"="+cliExecutable) || !containsString(command.Env, tqconfig.EnvManagedRun+"=1") { + t.Fatalf("command environment does not contain managed execution contract: %v", command.Env) + } +} + +func TestPrepareManagedCLICopiesExecutableToPersistentServicePath(t *testing.T) { + home := t.TempDir() + source := filepath.Join(t.TempDir(), "temporary-go-run-binary") + if err := os.WriteFile(source, []byte("managed cli"), 0o755); err != nil { + t.Fatalf("write source executable: %v", err) + } + + destination, err := copyManagedCLI(home, source) + if err != nil { + t.Fatalf("copy managed cli: %v", err) + } + if err := os.Remove(source); err != nil { + t.Fatalf("remove temporary source: %v", err) + } + content, err := os.ReadFile(destination) + if err != nil { + t.Fatalf("read persistent managed cli: %v", err) + } + if string(content) != "managed cli" { + t.Fatalf("managed cli content = %q", content) + } +} + +func TestServiceStartDoesNotReplaceManagedCLIWhenServiceIsRunning(t *testing.T) { + home := t.TempDir() + t.Setenv(tqconfig.EnvHome, home) + if _, err := tqconfig.EnsureHome(); err != nil { + t.Fatalf("ensure home: %v", err) + } + managedCLI := filepath.Join(serviceInstallDir(home), "tq-managed") + if err := os.MkdirAll(filepath.Dir(managedCLI), 0o755); err != nil { + t.Fatalf("create managed cli directory: %v", err) + } + if err := os.WriteFile(managedCLI, []byte("original cli"), 0o755); err != nil { + t.Fatalf("write managed cli: %v", err) + } + if err := tqconfig.UpdateState(func(state *tqconfig.State) error { + state.IssueTracker = &tqconfig.ServiceState{PID: os.Getpid()} + return nil + }); err != nil { + t.Fatalf("write running service state: %v", err) + } + + err := (app{}).serviceStart(context.Background(), nil, config{}) + if err == nil || !strings.Contains(err.Error(), "already running") { + t.Fatalf("service start error = %v", err) + } + content, err := os.ReadFile(managedCLI) + if err != nil { + t.Fatalf("read managed cli: %v", err) + } + if string(content) != "original cli" { + t.Fatalf("managed cli was replaced: %q", content) + } } func TestValidateServiceExecutableRejectsNonRegularOrNonExecutableFiles(t *testing.T) { diff --git a/internal/cli/tq/update.go b/internal/cli/tq/update.go index a30a714f..0e314dad 100644 --- a/internal/cli/tq/update.go +++ b/internal/cli/tq/update.go @@ -53,6 +53,9 @@ func (a app) update(ctx context.Context, args []string, cfg config) error { if fs.NArg() != 0 { return usageError("update does not accept positional arguments") } + if os.Getenv(tqconfig.EnvManagedRun) == "1" { + return errors.New("tq update is unavailable inside an orchestrator-managed run; run it from a user shell") + } profile, err := tqconfig.DefaultHomeProfile() if err != nil { return err diff --git a/internal/config/home.go b/internal/config/home.go index 8041e77e..a3993c5b 100644 --- a/internal/config/home.go +++ b/internal/config/home.go @@ -9,7 +9,9 @@ import ( ) const ( - EnvHome = "TQ_HOME" + EnvHome = "TQ_HOME" + EnvExecutable = "TQ_EXECUTABLE" + EnvManagedRun = "TQ_MANAGED_RUN" ) var defaultHomeProfile = "" diff --git a/internal/orchestrator/runner/prompt.go b/internal/orchestrator/runner/prompt.go index 8bb71c3c..3dbe50cb 100644 --- a/internal/orchestrator/runner/prompt.go +++ b/internal/orchestrator/runner/prompt.go @@ -3,13 +3,16 @@ package runner import ( "errors" "fmt" + "os" "strconv" "strings" "time" + + tqconfig "github.com/version-1/tasq/internal/config" ) func continuationPrompt(task Task) string { - prompt := fmt.Sprintf(continuationGuidance, task.Issue.ID, task.Issue.ID) + prompt := fmt.Sprintf(continuationGuidance, tasqCommand(), task.Issue.ID) changeRequestGuidance := changeRequestGuidanceForTask(task) if changeRequestGuidance == "" { return prompt @@ -101,6 +104,7 @@ func templateVariables(task Task) map[string]string { attempt = task.Attempt } return map[string]string{ + "tq.command": tasqCommand(), "issue.id": strconv.FormatInt(task.Issue.ID, 10), "issue.title": task.Issue.Title, "issue.description": task.Issue.Description, @@ -112,3 +116,10 @@ func templateVariables(task Task) map[string]string { "attempt": strconv.Itoa(attempt), } } + +func tasqCommand() string { + if os.Getenv(tqconfig.EnvManagedRun) == "1" && strings.TrimSpace(os.Getenv(tqconfig.EnvExecutable)) != "" { + return `"$TQ_EXECUTABLE"` + } + return "tq" +} diff --git a/internal/orchestrator/runner/runner.go b/internal/orchestrator/runner/runner.go index 1b6a379d..af4b98ee 100644 --- a/internal/orchestrator/runner/runner.go +++ b/internal/orchestrator/runner/runner.go @@ -138,12 +138,13 @@ func (r CodexRunner) Run(ctx context.Context, task Task) Result { var templateVariablePattern = regexp.MustCompile(`{{\s*([^{}]+?)\s*}}`) var templateNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$`) -const defaultTaskWorkPrompt = "Use `tq` to keep the issue tracker synchronized:\n" + +const defaultTaskWorkPrompt = "Use `{{ tq.command }}` to keep the issue tracker synchronized:\n" + "\n" + "If the `tasq-cli` skill is available, use it as the preferred guidance for tracker operations.\n" + "\n" + - "- Prefer typed `tq` commands such as `tq issue`, `tq comment`, and `tq artifact` for issue tracker operations.\n" + - "- Use `tq api` only when the issue tracker operation has no typed `tq` command.\n" + + "- Use `{{ tq.command }}` for every Tasq CLI operation, including commands shown elsewhere as `tq`; it identifies the CLI that started the managed services.\n" + + "- Prefer typed commands such as `{{ tq.command }} issue`, `{{ tq.command }} comment`, and `{{ tq.command }} artifact` for issue tracker operations.\n" + + "- Use `{{ tq.command }} api` only when the issue tracker operation has no typed command.\n" + "- Do not call the issue tracker API directly with `curl`, `wget`, or a custom HTTP script. This restriction applies only to the issue tracker API, not to other services or local endpoint verification.\n" + "- When work starts, move the issue to `in_progress` and leave a progress comment.\n" + "- Add progress comments at meaningful milestones during the work.\n" + @@ -155,23 +156,22 @@ const defaultTaskWorkPrompt = "Use `tq` to keep the issue tracker synchronized:\ "\n" + "```sh\n" + "# Start\n" + - "tq issue update {{ issue.id }} --status in_progress\n" + - "tq comment add {{ issue.id }} --author codex --type progress --body \"Started work.\"\n" + + "{{ tq.command }} issue update {{ issue.id }} --status in_progress\n" + + "{{ tq.command }} comment add {{ issue.id }} --author codex --type progress --body \"Started work.\"\n" + "\n" + "# Meaningful progress milestone\n" + - "tq comment add {{ issue.id }} --author codex --type progress --body \"Implemented the change; running verification.\"\n" + + "{{ tq.command }} comment add {{ issue.id }} --author codex --type progress --body \"Implemented the change; running verification.\"\n" + "\n" + "# Blocked (use instead of the review handoff)\n" + - "tq comment add {{ issue.id }} --author codex --type blocker --body \"Blocked: explain the blocker and what is needed.\"\n" + - "tq issue update {{ issue.id }} --status blocked\n" + + "{{ tq.command }} comment add {{ issue.id }} --author codex --type blocker --body \"Blocked: explain the blocker and what is needed.\"\n" + + "{{ tq.command }} issue update {{ issue.id }} --status blocked\n" + "\n" + "# Ready for review\n" + - "tq artifact set {{ issue.id }} --type pull_request \n" + - "tq comment add {{ issue.id }} --author codex --type handoff --body \"PR: ; verification: .\"\n" + - "tq issue update {{ issue.id }} --status review\n" + + "{{ tq.command }} artifact set {{ issue.id }} --type pull_request \n" + + "{{ tq.command }} comment add {{ issue.id }} --author codex --type handoff --body \"PR: ; verification: .\"\n" + + "{{ tq.command }} issue update {{ issue.id }} --status review\n" + "```\n" + "\n" + - "Run the installed `tq` binary from `PATH`. Do not use `go run ./cmd/tq` for\n" + - "tracker synchronization." + "Do not substitute another executable from `PATH` and do not use `go run ./cmd/tq` for tracker synchronization." -const continuationGuidance = "First run `tq issue update %d --status in_progress` to keep the issue tracker synchronized. Then continue the same task in this live thread without repeating completed work, and stop when it is ready for handoff. If this continuation creates or updates a pull request, register the primary PR before handoff with `tq artifact set %d --type pull_request `. On success, add the handoff comment, then move the issue to `review`; on failure, retry a reasonable number of times, then leave a blocker comment and do not move to `review` if it remains unresolved. Otherwise, artifact registration is not required." +const continuationGuidance = "First run `%[1]s issue update %[2]d --status in_progress` to keep the issue tracker synchronized. Then continue the same task in this live thread without repeating completed work, and stop when it is ready for handoff. If this continuation creates or updates a pull request, register the primary PR before handoff with `%[1]s artifact set %[2]d --type pull_request `. On success, add the handoff comment, then move the issue to `review`; on failure, retry a reasonable number of times, then leave a blocker comment and do not move to `review` if it remains unresolved. Otherwise, artifact registration is not required." diff --git a/internal/orchestrator/runner/runner_test.go b/internal/orchestrator/runner/runner_test.go index ee8094d3..9800b37d 100644 --- a/internal/orchestrator/runner/runner_test.go +++ b/internal/orchestrator/runner/runner_test.go @@ -596,8 +596,9 @@ func TestRenderPromptInjectsTaskWorkPromptByDefault(t *testing.T) { } } for _, want := range []string{ - "Prefer typed `tq` commands", - "Use `tq api` only when the issue tracker operation has no typed `tq` command.", + "Use `tq` for every Tasq CLI operation", + "Prefer typed commands such as `tq issue`", + "Use `tq api` only when the issue tracker operation has no typed command.", "Do not call the issue tracker API directly with `curl`, `wget`, or a custom HTTP script.", "not to other services or local endpoint verification", "primary PR being submitted for review", @@ -622,6 +623,41 @@ func TestRenderPromptInjectsTaskWorkPromptByDefault(t *testing.T) { } } +func TestRenderPromptUsesManagedServiceExecutable(t *testing.T) { + t.Setenv("TQ_EXECUTABLE", "/tmp/tqdev") + t.Setenv("TQ_MANAGED_RUN", "1") + + prompt, err := renderPrompt(Task{ + PromptTemplate: "Work on {{ issue.id }}.", + Issue: entity.Issue{ID: 7}, + }) + if err != nil { + t.Fatalf("render prompt: %v", err) + } + for _, want := range []string{ + "Use `\"$TQ_EXECUTABLE\"` to keep the issue tracker synchronized", + `"$TQ_EXECUTABLE" issue update 7 --status in_progress`, + `"$TQ_EXECUTABLE" artifact set 7 --type pull_request `, + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt missing %q: %q", want, prompt) + } + } +} + +func TestRenderPromptIgnoresExecutableOutsideManagedRun(t *testing.T) { + t.Setenv("TQ_EXECUTABLE", "/tmp/stale-tqdev") + t.Setenv("TQ_MANAGED_RUN", "") + + prompt, err := renderPrompt(Task{PromptTemplate: "Work on {{ issue.id }}.", Issue: entity.Issue{ID: 7}}) + if err != nil { + t.Fatalf("render prompt: %v", err) + } + if strings.Contains(prompt, "TQ_EXECUTABLE") || !strings.Contains(prompt, "tq issue update 7 --status in_progress") { + t.Fatalf("prompt did not fall back to tq: %q", prompt) + } +} + func TestRenderPromptSkipsTaskWorkPromptWhenDisabled(t *testing.T) { t.Parallel() @@ -701,6 +737,22 @@ func TestContinuationGuidanceIncludesChangeRequests(t *testing.T) { } } +func TestContinuationGuidanceUsesManagedServiceExecutable(t *testing.T) { + t.Setenv("TQ_EXECUTABLE", "/tmp/tqdev") + t.Setenv("TQ_MANAGED_RUN", "1") + + prompt := continuationPrompt(Task{Issue: entity.Issue{ID: 7}}) + + for _, want := range []string{ + `"$TQ_EXECUTABLE" issue update 7 --status in_progress`, + `"$TQ_EXECUTABLE" artifact set 7 --type pull_request `, + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt missing %q: %q", want, prompt) + } + } +} + func boolPtr(value bool) *bool { return &value }