diff --git a/Makefile b/Makefile index 70d8bdd9..c04c3dfd 100644 --- a/Makefile +++ b/Makefile @@ -71,7 +71,7 @@ build-tq-dev: ## Build tq and service binaries with the dev profile. mkdir -p "$(HOME)/.tasq-dev/system/bin" go build -ldflags "-X github.com/version-1/tasq/internal/buildinfo.commit=$(TQ_BUILD_COMMIT) -X github.com/version-1/tasq/internal/config.defaultHomeProfile=dev" -o ./tqdev ./cmd/tq go build -o "$(HOME)/.tasq-dev/system/bin/issue-tracker" ./cmd/issue-tracker - go build -o "$(HOME)/.tasq-dev/system/bin/orchestrator" ./cmd/orchestrator + go build -ldflags "-X github.com/version-1/tasq/internal/config.defaultHomeProfile=dev" -o "$(HOME)/.tasq-dev/system/bin/orchestrator" ./cmd/orchestrator go build -ldflags "-X github.com/version-1/tasq/internal/buildinfo.commit=$(TQ_BUILD_COMMIT)" -o "$(HOME)/.tasq-dev/system/bin/web" ./cmd/web .PHONY: deploy-tq-dev diff --git a/cmd/orchestrator/main.go b/cmd/orchestrator/main.go index 9a6132a0..8f578608 100644 --- a/cmd/orchestrator/main.go +++ b/cmd/orchestrator/main.go @@ -38,6 +38,10 @@ func main() { if err != nil { log.Fatalf("load TQ_HOME config: %v", err) } + tasqCommand, err := tqconfig.ValidateTasqCommand() + if err != nil { + log.Fatalf("validate Tasq CLI command: %v", err) + } resolvedDBPath := *dbPath if resolvedDBPath == "" { resolvedDBPath = tqconfig.OrchestratorDBPath(home) @@ -80,6 +84,7 @@ func main() { Store: store, Runner: runner.CodexRunner{}, WorkflowResolver: workflowResolver, + TasqCommand: tasqCommand, MaxConcurrentRuns: effectiveMaxConcurrentRuns, }) if err != nil { diff --git a/docs/design/system-prompts.ja.md b/docs/design/system-prompts.ja.md index 1d2b85f8..bede4afe 100644 --- a/docs/design/system-prompts.ja.md +++ b/docs/design/system-prompts.ja.md @@ -49,6 +49,15 @@ Lifecycle: `{{ issue.id }}` などのテンプレート変数は、このプロンプトを先頭へ追加した後に展開されます。`tasq.task_work_prompt: false` を設定すると、Issue Tracker と Artifact に関する指示を含む開始プロンプト全体が無効になりますが、継続時の動作は変わりません。 +本番ビルドでは `{{ tq.command }}` を `tq` として展開します。開発ビルドでは `tqdev` として展開し、既定のタスク作業プロンプトより前に次の 2 行を追加します。 + +```text +Use the `tqdev` command instead of `tq`. +When using the `tasq-cli` skill, interpret every `tq` command as `tqdev`. +``` + +この開発用ガイダンスも既定のタスク作業プロンプト注入の一部であるため、初回ターンで `tasq.task_work_prompt: false` を設定すると無効になります。 + 承認理由に関する契約を追加することで、承認待ちでブロックされたときのコメントから、運用者が必要な対応を判断できるようにします。runner は Codex app-server から受け取った request payload をすでに保持しているため、エージェントに対して、操作内容、対象範囲、必要性、想定される影響を `reason` に含めるよう指示します。この変更では、実行時の検証や Tasq の承認拒否方針は変更しません。 Pull Request Artifact は、現在レビューを依頼している主要 PR を表します。同じ Artifact を再設定すると以前の URL が置き換わり、補助 PR はハンドオフコメントの補足情報として残します。PR を作成または更新した場合にだけ Artifact 登録が必要です。登録に失敗した場合は合理的な回数だけ再試行し、解決できなければブロッカーコメントを残して `review` へ移動してはいけません。 @@ -63,6 +72,6 @@ Pull Request Artifact は、現在レビューを依頼している主要 PR を 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. Before requesting approval for a command execution or file change, provide a non-empty, specific reason. The reason must identify what needs approval, the target scope (the command and working directory or the file paths), why approval is required, and the expected effect. Do not send a null, empty, or vague reason such as only saying that approval is required. 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 reasonably, then leave a blocker comment and do not move to `review` if it remains unresolved. Otherwise, artifact registration is not required. ``` -`` は managed run では `"$TQ_EXECUTABLE"`、それ以外では `tq` になります。`` はターン開始前に現在の課題 ID で埋め込まれます。同じ継続プロンプトは、有効な複数ターン実行の後続ターンでも使われます。既存の再開条件または後続ターン条件によって継続ターンが選ばれた場合だけ送信され、継続が無効な場合に余分なターンを追加することはありません。担当する変更要求の指示がある場合は、従来どおりこの注意事項の後ろに追加されます。 +`` は本番ビルドでは `tq`、開発ビルドでは `tqdev` になります。開発ビルドの継続プロンプトには、上記と同じ 2 行のコマンドおよび `tasq-cli` の読み替えガイダンスも先頭へ追加します。`` はターン開始前に現在の課題 ID で埋め込まれます。同じ継続プロンプトは、有効な複数ターン実行の後続ターンでも使われます。既存の再開条件または後続ターン条件によって継続ターンが選ばれた場合だけ送信され、継続が無効な場合に余分なターンを追加することはありません。担当する変更要求の指示がある場合は、従来どおりこの注意事項の後ろに追加されます。 再開した thread と後続 turn には完全な開始プロンプトが再送されないため、承認理由に関する同じ契約を継続用プロンプトにも記載します。 diff --git a/docs/design/system-prompts.md b/docs/design/system-prompts.md index fde05a01..3479706c 100644 --- a/docs/design/system-prompts.md +++ b/docs/design/system-prompts.md @@ -49,6 +49,15 @@ Lifecycle: 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. +Production builds render `{{ tq.command }}` as `tq`. Development builds render it as `tqdev` and prepend these two lines before the default task-work prompt: + +```text +Use the `tqdev` command instead of `tq`. +When using the `tasq-cli` skill, interpret every `tq` command as `tqdev`. +``` + +The development guidance is part of the default task-work prompt injection, so `tasq.task_work_prompt: false` disables it on the first turn as well. + The approval-reason contract makes blocked approval comments actionable because the runner already preserves the app-server request payload. It instructs the agent to populate the request reason with the operation, target scope, necessity, and expected effect; it does not add runtime validation or change Tasq's approval-denial policy. The pull-request artifact represents the primary PR currently submitted for review. Setting it again replaces the prior URL, while supporting PRs remain handoff-comment context. Artifact registration is conditional on creating or updating a PR. A registration failure must be retried reasonably and, if unresolved, reported as a blocker without a transition to `review`. @@ -63,6 +72,6 @@ The injected resume prompt is: 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. Before requesting approval for a command execution or file change, provide a non-empty, specific reason. The reason must identify what needs approval, the target scope (the command and working directory or the file paths), why approval is required, and the expected effect. Do not send a null, empty, or vague reason such as only saying that approval is required. 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 reasonably, then leave a blocker comment and do not move to `review` if it remains unresolved. Otherwise, artifact registration is not required. ``` -`` 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. +`` uses `tq` in production builds and `tqdev` in development builds. Development continuations prepend the same two-line command and `tasq-cli` reinterpretation guidance shown above. `` 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. The same approval-reason contract is repeated here because resumed threads and later turns do not receive the full task-start prompt again. diff --git a/docs/references/makefile.ja.md b/docs/references/makefile.ja.md index 33a7f347..bd447136 100644 --- a/docs/references/makefile.ja.md +++ b/docs/references/makefile.ja.md @@ -115,7 +115,7 @@ make run-logs | `make install-tq version=v0.1.0` | 特定のリリースタグから `tq` と管理対象サービスの実行ファイルをインストールします。 | | `make install-tq-prerelease` | 最新の prerelease から `tq` と管理対象サービスの実行ファイルをインストールします。`gh` が必要です。 | | `make install-tq-prerelease version=v0.1.0-pre.1` | 特定の prerelease タグから `tq` と管理対象サービスの実行ファイルをインストールします。 | -| `make build-tq-dev` | `dev` profile を埋め込んだ `tqdev` バイナリをカレントディレクトリに build します。 | +| `make build-tq-dev` | `dev` プロファイルを埋め込んだ `tqdev` とサービスバイナリをビルドします。サービス起動前に `tqdev` を `PATH` へ配置する必要があります。 | | `make deploy-tq-dev` | `tqdev` のサービスを停止し、`build-tq-dev` を実行して、生成された `tqdev` バイナリを `$HOME/.local/bin` に移動した後、サービスを再起動します。 | | `make deploy-tq-dev ARGS=/path/to/bin` | 生成された `tqdev` バイナリを `$HOME/.local/bin` の代わりに指定したディレクトリへ配置します。 | diff --git a/docs/references/makefile.md b/docs/references/makefile.md index 8108937f..7320eee4 100644 --- a/docs/references/makefile.md +++ b/docs/references/makefile.md @@ -115,7 +115,7 @@ make run-logs | `make install-tq version=v0.1.0` | Install `tq` and its managed service executables from a specific release tag. | | `make install-tq-prerelease` | Install `tq` and its managed service executables from the latest prerelease. Requires `gh`. | | `make install-tq-prerelease version=v0.1.0-pre.1` | Install `tq` and its managed service executables from a specific prerelease tag. | -| `make build-tq-dev` | Build a `tqdev` binary with the `dev` profile in the current directory. | +| `make build-tq-dev` | Build `tqdev` and service binaries with the `dev` profile. `tqdev` must be on `PATH` before starting services. | | `make deploy-tq-dev` | Stop the `tqdev` services, run `build-tq-dev`, move the resulting `tqdev` binary to `$HOME/.local/bin`, and restart the services. | | `make deploy-tq-dev ARGS=/path/to/bin` | Deploy the resulting `tqdev` binary to the specified directory instead of `$HOME/.local/bin`. | diff --git a/docs/symphony/WORKFLOW_CONTRACT.ja.md b/docs/symphony/WORKFLOW_CONTRACT.ja.md index 22853f7a..5a1e8add 100644 --- a/docs/symphony/WORKFLOW_CONTRACT.ja.md +++ b/docs/symphony/WORKFLOW_CONTRACT.ja.md @@ -98,10 +98,10 @@ front matter の閉じ `---` 以降がすべてプロンプトテンプレート #### Issue ステータス更新 デフォルトでは、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 します。 +課題の状態を更新するよう指示を注入します。本番の Orchestrator ビルドではこのコマンドを `tq`、開発ビルドでは +`tqdev` として展開し、`tasq-cli` スキル内の `tq` の例を `tqdev` へ読み替えるようエージェントへ指示します。 +選択されたコマンドは `PATH` から解決できる必要があります。見つからない場合、`tq service start` と Orchestrator の +起動はどちらも課題を割り当てる前に失敗します。未対応のビルドプロファイルも、実行ファイル名を推測せずエラーになります。 managed agent run は `TQ_MANAGED_RUN=1` も継承します。この文脈では、run を所有する orchestrator を 終了させる可能性があるため、`tq update` と `tq service stop` は service state を変更する前に失敗します。 diff --git a/docs/symphony/WORKFLOW_CONTRACT.md b/docs/symphony/WORKFLOW_CONTRACT.md index db085099..480451c6 100644 --- a/docs/symphony/WORKFLOW_CONTRACT.md +++ b/docs/symphony/WORKFLOW_CONTRACT.md @@ -100,12 +100,11 @@ interacting with the issue-tracker. #### Issue Status Updates 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. +`{{ tq.command }}` for progress comments and issue status updates. Production orchestrator builds +render this command as `tq`, while development builds render it as `tqdev` and tell the agent to +reinterpret `tq` examples in the `tasq-cli` skill as `tqdev`. The selected command must resolve on +`PATH`; both `tq service start` and orchestrator startup fail before dispatch when it does not. +Unsupported build profiles also fail instead of deriving an executable name. 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 diff --git a/internal/cli/tq/command_test.go b/internal/cli/tq/command_test.go index 6a870807..025fd504 100644 --- a/internal/cli/tq/command_test.go +++ b/internal/cli/tq/command_test.go @@ -819,6 +819,11 @@ func TestServiceStatusStopped(t *testing.T) { func TestServiceStartFailsBeforeStartingServicesWhenMigrationsPending(t *testing.T) { home := t.TempDir() t.Setenv(tqconfig.EnvHome, home) + commandDir := t.TempDir() + if err := os.WriteFile(filepath.Join(commandDir, "tq"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write tq executable: %v", err) + } + t.Setenv("PATH", commandDir) for _, name := range []serviceName{serviceIssueTracker, serviceOrchestrator, serviceWeb} { path := serviceExecutablePath(home, name) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { diff --git a/internal/cli/tq/service.go b/internal/cli/tq/service.go index 51a96661..279d15e4 100644 --- a/internal/cli/tq/service.go +++ b/internal/cli/tq/service.go @@ -118,13 +118,12 @@ func (a app) serviceStart(ctx context.Context, args []string, cfg config) error if err := validateServiceExecutables(home); err != nil { return err } + if _, err := tqconfig.ValidateTasqCommand(); err != nil { + return err + } 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 { @@ -158,7 +157,7 @@ func (a app) serviceStart(ctx context.Context, args []string, cfg config) error "-db", issueDB, }, } - if _, err := startManagedService(ctx, home, cliExecutable, issueService); err != nil { + if _, err := startManagedService(ctx, home, issueService); err != nil { return err } if err := waitIssueTrackerHealthy(ctx, "http://"+issueAddr); err != nil { @@ -175,7 +174,7 @@ func (a app) serviceStart(ctx context.Context, args []string, cfg config) error "-port", strconv.Itoa(servicePort(orchestratorAddr)), }, } - if _, err := startManagedService(ctx, home, cliExecutable, orchestratorService); err != nil { + if _, err := startManagedService(ctx, home, orchestratorService); err != nil { _ = stopServiceByName(context.Background(), serviceIssueTracker) return err } @@ -194,7 +193,7 @@ func (a app) serviceStart(ctx context.Context, args []string, cfg config) error "-orchestrator-url", "http://" + orchestratorAddr, }, } - if _, err := startManagedService(ctx, home, cliExecutable, webService); err != nil { + if _, err := startManagedService(ctx, home, webService); err != nil { _ = stopServiceByName(context.Background(), serviceOrchestrator) _ = stopServiceByName(context.Background(), serviceIssueTracker) return err @@ -385,8 +384,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, cliExecutable string, service managedService) (int, error) { - cmd, err := commandForService(ctx, home, cliExecutable, service) +func startManagedService(ctx context.Context, home string, service managedService) (int, error) { + cmd, err := commandForService(ctx, home, service) if err != nil { return 0, err } @@ -404,13 +403,13 @@ func startManagedService(ctx context.Context, home string, cliExecutable string, return cmd.Process.Pid, nil } -func commandForService(ctx context.Context, home string, cliExecutable string, service managedService) (*exec.Cmd, error) { +func commandForService(ctx context.Context, home 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, cliExecutable, os.Environ()) + command.Env = serviceCommandEnv(home, os.Environ()) return command, nil } @@ -449,18 +448,17 @@ func serviceExecutablePath(home string, name serviceName) string { return filepath.Join(tqconfig.SystemDir(home), "bin", string(name)) } -func serviceCommandEnv(home string, cliExecutable string, environment []string) []string { - filtered := make([]string, 0, len(environment)+3) +func serviceCommandEnv(home string, environment []string) []string { + filtered := make([]string, 0, len(environment)+2) for _, entry := range environment { if !environmentEntryMatches(entry, tqconfig.EnvHome) && - !environmentEntryMatches(entry, tqconfig.EnvExecutable) && + !environmentEntryMatches(entry, "TQ_EXECUTABLE") && !environmentEntryMatches(entry, tqconfig.EnvManagedRun) { filtered = append(filtered, entry) } } return append(filtered, tqconfig.EnvHome+"="+home, - tqconfig.EnvExecutable+"="+cliExecutable, tqconfig.EnvManagedRun+"=1", ) } @@ -469,40 +467,6 @@ 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 diff --git a/internal/cli/tq/service_test.go b/internal/cli/tq/service_test.go index d72f31f5..29efeb73 100644 --- a/internal/cli/tq/service_test.go +++ b/internal/cli/tq/service_test.go @@ -59,8 +59,7 @@ func TestServiceStartAddressesUsesFallbackWhenDefaultPortIsUnavailable(t *testin func TestServiceCommandEnvSetsManagedExecutionContract(t *testing.T) { home := t.TempDir() - cliExecutable := filepath.Join(t.TempDir(), "tqdev") - environment := serviceCommandEnv(home, cliExecutable, []string{ + environment := serviceCommandEnv(home, []string{ "PATH=/usr/bin", "TQ_HOME=/other/home", "TQ_HOME_SUFFIX=preserved", @@ -82,13 +81,15 @@ func TestServiceCommandEnvSetsManagedExecutionContract(t *testing.T) { 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) } } + if containsEnvironmentEntry(environment, "TQ_EXECUTABLE") { + t.Fatalf("environment retained legacy TQ_EXECUTABLE: %v", environment) + } } func TestValidateServiceExecutablesReportsAllUnavailableTargets(t *testing.T) { @@ -120,8 +121,7 @@ func TestCommandForServiceUsesHomeSystemBinExecutable(t *testing.T) { if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatalf("write service executable: %v", err) } - cliExecutable := filepath.Join(t.TempDir(), "tqdev") - command, err := commandForService(context.Background(), home, cliExecutable, managedService{name: serviceIssueTracker}) + command, err := commandForService(context.Background(), home, managedService{name: serviceIssueTracker}) if err != nil { t.Fatalf("command for service: %v", err) } @@ -131,35 +131,12 @@ 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") { + if !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) { +func TestServiceStartLeavesLegacyManagedCLIWhenServiceIsRunning(t *testing.T) { home := t.TempDir() t.Setenv(tqconfig.EnvHome, home) if _, err := tqconfig.EnsureHome(); err != nil { @@ -228,6 +205,15 @@ func containsString(values []string, want string) bool { return false } +func containsEnvironmentEntry(values []string, name string) bool { + for _, value := range values { + if strings.HasPrefix(value, name+"=") { + return true + } + } + return false +} + func TestConfirmServicePortsAvailableFailsWhenCandidateIsTaken(t *testing.T) { addresses, err := allocateServiceAddresses() if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 61efe7f6..e2ba882e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -54,6 +54,51 @@ func TestDefaultHomeProfileRejectsInvalidValue(t *testing.T) { } } +func TestTasqCommandUsesBuildProfile(t *testing.T) { + tests := []struct { + name string + profile string + want string + wantErr bool + }{ + {name: "production", profile: "", want: "tq"}, + {name: "development", profile: "dev", want: "tqdev"}, + {name: "unsupported", profile: "preview", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalProfile := defaultHomeProfile + defaultHomeProfile = tt.profile + t.Cleanup(func() { defaultHomeProfile = originalProfile }) + + got, err := TasqCommand() + if tt.wantErr { + if err == nil { + t.Fatal("TasqCommand() error = nil") + } + return + } + if err != nil { + t.Fatalf("TasqCommand: %v", err) + } + if got != tt.want { + t.Fatalf("TasqCommand() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestValidateTasqCommandRequiresCommandOnPath(t *testing.T) { + originalProfile := defaultHomeProfile + defaultHomeProfile = "dev" + t.Cleanup(func() { defaultHomeProfile = originalProfile }) + t.Setenv("PATH", t.TempDir()) + + if _, err := ValidateTasqCommand(); err == nil || !strings.Contains(err.Error(), `"tqdev"`) { + t.Fatalf("ValidateTasqCommand() error = %v", err) + } +} + func TestEnsureHomeCreatesLayout(t *testing.T) { home := filepath.Join(t.TempDir(), ".tasq") t.Setenv(EnvHome, home) diff --git a/internal/config/home.go b/internal/config/home.go index a3993c5b..20d4a708 100644 --- a/internal/config/home.go +++ b/internal/config/home.go @@ -4,13 +4,13 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "strings" ) const ( EnvHome = "TQ_HOME" - EnvExecutable = "TQ_EXECUTABLE" EnvManagedRun = "TQ_MANAGED_RUN" ) @@ -29,6 +29,32 @@ func DefaultHomeProfile() (string, error) { return profile, nil } +func TasqCommand() (string, error) { + profile, err := DefaultHomeProfile() + if err != nil { + return "", err + } + switch profile { + case "": + return "tq", nil + case "dev": + return "tqdev", nil + default: + return "", fmt.Errorf("unsupported build profile %q for Tasq CLI command", profile) + } +} + +func ValidateTasqCommand() (string, error) { + command, err := TasqCommand() + if err != nil { + return "", err + } + if _, err := exec.LookPath(command); err != nil { + return "", fmt.Errorf("resolve Tasq CLI command %q from PATH: %w", command, err) + } + return command, nil +} + func Home() (string, error) { if value := strings.TrimSpace(os.Getenv(EnvHome)); value != "" { return filepath.Abs(value) diff --git a/internal/orchestrator/coordinator/dispatcher.go b/internal/orchestrator/coordinator/dispatcher.go index 52e6d458..cdf7ae3f 100644 --- a/internal/orchestrator/coordinator/dispatcher.go +++ b/internal/orchestrator/coordinator/dispatcher.go @@ -56,6 +56,7 @@ type Dispatcher struct { store DispatchStore runner runner.Runner workflowResolver WorkflowResolver + tasqCommand string maxConcurrentRuns int mu sync.Mutex @@ -70,6 +71,7 @@ type DispatcherConfig struct { Store DispatchStore Runner runner.Runner WorkflowResolver WorkflowResolver + TasqCommand string MaxConcurrentRuns int } @@ -89,12 +91,16 @@ func NewDispatcher(config DispatcherConfig) (*Dispatcher, error) { if config.MaxConcurrentRuns <= 0 { return nil, errors.New("max concurrent runs must be positive") } + if config.TasqCommand == "" { + config.TasqCommand = "tq" + } runCtx, cancel := context.WithCancel(context.Background()) return &Dispatcher{ tracker: config.Tracker, store: config.Store, runner: config.Runner, workflowResolver: config.WorkflowResolver, + tasqCommand: config.TasqCommand, maxConcurrentRuns: config.MaxConcurrentRuns, claimed: make(map[string]struct{}), runCtx: runCtx, @@ -243,6 +249,7 @@ func (d *Dispatcher) taskForRun(ctx context.Context, storedRun run.Run, issue en Workspace: workspace.Workspace{Path: storedRun.Workspace, WorkspaceKey: issueIdentifier(storedRun.IssueID)}, PromptTemplate: definition.PromptTemplate, TaskWorkPrompt: definition.Config.Tasq.TaskWorkPrompt, + TasqCommand: d.tasqCommand, ResumeThreadID: resumeThreadID, ChangeRequests: changeRequests, MaxTurns: definition.Config.MaxTurns, diff --git a/internal/orchestrator/coordinator/dispatcher_test.go b/internal/orchestrator/coordinator/dispatcher_test.go index a8b4bdb8..cb91b638 100644 --- a/internal/orchestrator/coordinator/dispatcher_test.go +++ b/internal/orchestrator/coordinator/dispatcher_test.go @@ -241,6 +241,7 @@ func TestDispatcherResolvesWorkflowForEachRunProject(t *testing.T) { Store: store, Runner: testRunner, WorkflowResolver: resolver, + TasqCommand: "tqdev", MaxConcurrentRuns: 2, }) if err != nil { @@ -260,10 +261,16 @@ func TestDispatcherResolvesWorkflowForEachRunProject(t *testing.T) { if firstTask.TaskWorkPrompt == nil || *firstTask.TaskWorkPrompt { t.Fatalf("first task task_work_prompt = %v", firstTask.TaskWorkPrompt) } + if firstTask.TasqCommand != "tqdev" { + t.Fatalf("first task Tasq command = %q", firstTask.TasqCommand) + } secondTask := tasks[43] if secondTask.PromptTemplate != "Project B prompt" || secondTask.MaxTurns != 5 || secondTask.ContinueTurns || secondTask.Command != "codex app-server --project-b" { t.Fatalf("second task workflow = %+v", secondTask) } + if secondTask.TasqCommand != "tqdev" { + t.Fatalf("second task Tasq command = %q", secondTask.TasqCommand) + } } func TestDispatcherRecordsFailedRun(t *testing.T) { diff --git a/internal/orchestrator/runner/prompt.go b/internal/orchestrator/runner/prompt.go index 3e92b5c9..9a8b2605 100644 --- a/internal/orchestrator/runner/prompt.go +++ b/internal/orchestrator/runner/prompt.go @@ -3,12 +3,9 @@ package runner import ( "errors" "fmt" - "os" "strconv" "strings" "time" - - tqconfig "github.com/version-1/tasq/internal/config" ) const approvalRequestGuidance = "Before requesting approval for a command execution or file change, provide a non-empty, specific reason. The reason must identify what needs approval, the target scope (the command and working directory or the file paths), why approval is required, and the expected effect. Do not send a null, empty, or vague reason such as only saying that approval is required." @@ -50,7 +47,10 @@ const defaultTaskWorkPrompt = "Use `{{ tq.command }}` to keep the issue tracker 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. " + approvalRequestGuidance + " 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 reasonably, then leave a blocker comment and do not move to `review` if it remains unresolved. Otherwise, artifact registration is not required." func continuationPrompt(task Task) string { - prompt := fmt.Sprintf(continuationGuidance, tasqCommand(), task.Issue.ID) + prompt := fmt.Sprintf(continuationGuidance, tasqCommand(task), task.Issue.ID) + if guidance := alternateTasqCommandGuidance(task); guidance != "" { + prompt = guidance + "\n\n" + prompt + } changeRequestGuidance := changeRequestGuidanceForTask(task) if changeRequestGuidance == "" { return prompt @@ -90,6 +90,9 @@ func renderPrompt(task Task) (string, error) { } if shouldInjectTaskWorkPrompt(task.TaskWorkPrompt) { prompt = defaultTaskWorkPrompt + "\n\n" + prompt + if guidance := alternateTasqCommandGuidance(task); guidance != "" { + prompt = guidance + "\n\n" + prompt + } } if strings.Count(prompt, "{{") != strings.Count(prompt, "}}") { return "", errors.New("template_parse_error: unbalanced template delimiters") @@ -142,7 +145,7 @@ func templateVariables(task Task) map[string]string { attempt = task.Attempt } return map[string]string{ - "tq.command": tasqCommand(), + "tq.command": tasqCommand(task), "issue.id": strconv.FormatInt(task.Issue.ID, 10), "issue.title": task.Issue.Title, "issue.description": task.Issue.Description, @@ -155,9 +158,17 @@ func templateVariables(task Task) map[string]string { } } -func tasqCommand() string { - if os.Getenv(tqconfig.EnvManagedRun) == "1" && strings.TrimSpace(os.Getenv(tqconfig.EnvExecutable)) != "" { - return `"$TQ_EXECUTABLE"` +func tasqCommand(task Task) string { + if task.TasqCommand != "" { + return task.TasqCommand } return "tq" } + +func alternateTasqCommandGuidance(task Task) string { + command := tasqCommand(task) + if command == "tq" { + return "" + } + return fmt.Sprintf("Use the `%[1]s` command instead of `tq`.\nWhen using the `tasq-cli` skill, interpret every `tq` command as `%[1]s`.", command) +} diff --git a/internal/orchestrator/runner/runner.go b/internal/orchestrator/runner/runner.go index eb673ffc..7ae75a13 100644 --- a/internal/orchestrator/runner/runner.go +++ b/internal/orchestrator/runner/runner.go @@ -22,6 +22,7 @@ type Task struct { Workspace workspace.Workspace PromptTemplate string TaskWorkPrompt *bool + TasqCommand string ResumeThreadID string ChangeRequests []entity.ChangeRequest MaxTurns int diff --git a/internal/orchestrator/runner/runner_test.go b/internal/orchestrator/runner/runner_test.go index 8aeca5c1..9b5a055a 100644 --- a/internal/orchestrator/runner/runner_test.go +++ b/internal/orchestrator/runner/runner_test.go @@ -627,21 +627,21 @@ func TestRenderPromptInjectsTaskWorkPromptByDefault(t *testing.T) { } } -func TestRenderPromptUsesManagedServiceExecutable(t *testing.T) { - t.Setenv("TQ_EXECUTABLE", "/tmp/tqdev") - t.Setenv("TQ_MANAGED_RUN", "1") - +func TestRenderPromptUsesDevelopmentCommand(t *testing.T) { prompt, err := renderPrompt(Task{ PromptTemplate: "Work on {{ issue.id }}.", + TasqCommand: "tqdev", 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 `, + "Use the `tqdev` command instead of `tq`.", + "When using the `tasq-cli` skill, interpret every `tq` command as `tqdev`.", + "Use `tqdev` to keep the issue tracker synchronized", + `tqdev issue update 7 --status in_progress`, + `tqdev artifact set 7 --type pull_request `, } { if !strings.Contains(prompt, want) { t.Fatalf("prompt missing %q: %q", want, prompt) @@ -649,16 +649,13 @@ func TestRenderPromptUsesManagedServiceExecutable(t *testing.T) { } } -func TestRenderPromptIgnoresExecutableOutsideManagedRun(t *testing.T) { - t.Setenv("TQ_EXECUTABLE", "/tmp/stale-tqdev") - t.Setenv("TQ_MANAGED_RUN", "") - +func TestRenderPromptDefaultsToProductionCommand(t *testing.T) { 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) + if strings.Contains(prompt, "tqdev") || !strings.Contains(prompt, "tq issue update 7 --status in_progress") { + t.Fatalf("prompt did not use tq: %q", prompt) } } @@ -668,6 +665,7 @@ func TestRenderPromptSkipsTaskWorkPromptWhenDisabled(t *testing.T) { prompt, err := renderPrompt(Task{ PromptTemplate: "Work on {{ issue.id }}.", TaskWorkPrompt: boolPtr(false), + TasqCommand: "tqdev", Issue: entity.Issue{ID: 7}, }) if err != nil { @@ -676,7 +674,7 @@ func TestRenderPromptSkipsTaskWorkPromptWhenDisabled(t *testing.T) { if prompt != "Work on 7." { t.Fatalf("prompt = %q", prompt) } - for _, unexpected := range []string{"Prefer typed `tq` commands", "tq api", "tq artifact set", "pull_request"} { + for _, unexpected := range []string{"tqdev", "Prefer typed `tq` commands", "tq api", "tq artifact set", "pull_request"} { if strings.Contains(prompt, unexpected) { t.Fatalf("disabled prompt contains %q: %q", unexpected, prompt) } @@ -745,15 +743,14 @@ 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}}) +func TestContinuationGuidanceUsesDevelopmentCommand(t *testing.T) { + prompt := continuationPrompt(Task{TasqCommand: "tqdev", 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 `, + "Use the `tqdev` command instead of `tq`.", + "When using the `tasq-cli` skill, interpret every `tq` command as `tqdev`.", + `tqdev issue update 7 --status in_progress`, + `tqdev artifact set 7 --type pull_request `, } { if !strings.Contains(prompt, want) { t.Fatalf("prompt missing %q: %q", want, prompt)