fix(create_project): fast raw create + honest partial_failure (#42, infra#179)
CD / Lint / Test / Vet (push) Successful in 8s
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Successful in 6s

gitea's template-generate is slow-async on this instance (repo not writable for
>40s; infra#179) — no synchronous MCP tool can wait that long, and the 60s retry
made the call hang until the client timed out. Bound the write-readiness retry to
5s (a healthy gitea commits in ~1s and this still catches it), and when the branch
isn't writable in time, return a clear partial_failure: repo created, substitution
deferred, finalize locally with `hyperguild new-project`. Substitution logic is
intact and completes automatically once generate is fast (infra#179). Tool
description updated to describe substitution as best-effort. Refs #42, infra#179.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 23:38:13 +02:00
co-authored by Claude Opus 4.8
parent d45ba712ce
commit 039598855c
+28 -9
View File
@@ -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
}