diff --git a/internal/tools/create_project_from_template.go b/internal/tools/create_project_from_template.go index 37619a1..0e8554f 100644 --- a/internal/tools/create_project_from_template.go +++ b/internal/tools/create_project_from_template.go @@ -48,7 +48,7 @@ func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplO func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor { return registry.ToolDescriptor{ Name: "create_project_from_template", - Description: "Create a new project repo from a template, substituting placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/) so the result builds. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent).", + Description: "Create a new project repo from a template. Best-effort substitution of placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/): it completes only if the generated branch is promptly writable. If gitea's async generate is slow (infra#179) the repo is still created and partial_failure explains how to finalize locally (`hyperguild new-project`). Check files_substituted and partial_failure. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent).", InputSchema: json.RawMessage(`{ "type":"object", "properties":{ @@ -176,6 +176,21 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag } } + // If substitution stalled because the generated branch wasn't writable in time, + // the repo IS created — say so clearly and point to the local finalize step, + // rather than leaking the raw "branch does not exist" (infra#179: gitea's + // template-generate is slow-async on this instance, so tool-side substitution + // is best-effort). + if strings.Contains(result.PartialFailure, "branch does not exist") || + strings.Contains(result.PartialFailure, "not found") { + result.PartialFailure = fmt.Sprintf( + "repo created, but its branch (%s) was not writable within %ds — gitea's "+ + "template-generate is slow-async on this instance (infra#179), so substitution "+ + "is incomplete (%d file(s) done). Finalize locally with `hyperguild new-project` "+ + "(clone + substitute, no API race). Underlying: %s", + branch, substitutionBudget, len(result.FilesSubstituted), result.PartialFailure) + } + // Fail loud: a scaffold that still holds placeholders does not build. Nothing // substituted (with no explicit failure) means the walk found no placeholders — // suspicious for a real template. Surface it instead of returning silent success. @@ -186,16 +201,20 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag return textOK(result) } -// upsertRetry is the readiness gate for the async-generate branch race: gitea's -// /generate returns (and serves reads) before the branch ref is committed, so the -// first writes 404 "branch does not exist" until the initial commit lands (observed -// up to ~30s under load). BranchExists is not a usable signal — it reports the -// branch present before writes succeed. So the write itself is the probe: retry on -// the transient not-found until it takes. Once the first write lands, the branch is -// writable and the rest succeed on the first try. +// substitutionBudget bounds how long we retry the first write while the freshly +// generated branch becomes writable. gitea's /generate returns (and serves reads) +// before the branch ref is committed, so writes 404 "branch does not exist" for a +// window. We keep the budget SHORT so the MCP call stays responsive: a healthy +// gitea commits in ~1s and this catches it; a slow one (infra#179, observed >40s) +// fails fast and we defer substitution with clear guidance rather than hang. +const substitutionBudget = 5 + +// upsertRetry retries UpsertFile on the transient post-generate "branch does not +// exist" not-found, up to substitutionBudget. The write itself is the readiness +// probe — BranchExists reports the branch present before writes succeed. func (t *CreateProjectFromTemplate) upsertRetry(ctx context.Context, owner, name, path string, args gitea.UpsertFileArgs) error { var err error - for i := 0; i < 60; i++ { + for i := 0; i < substitutionBudget; i++ { if _, err = t.c.UpsertFile(ctx, owner, name, path, args); err == nil { return nil }