fix(create_project): fast raw create + honest partial_failure (#42, infra#179)
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user