Skip to content

feat(tutorial): implement the Course-author-facing framework - #3442

Merged
nighca merged 36 commits into
goplus:issue-3403-tutorial-v2from
Ethanlita:issue-3417-framework
Sep 17, 2026
Merged

nighca merged 36 commits into
goplus:issue-3403-tutorial-v2from
Ethanlita:issue-3417-framework

Conversation

@Ethanlita

@Ethanlita Ethanlita commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Refs #3417 (part of #3403). Implements the contract documented in #3439, which should land first.

Summary

Grows the placeholder Tutorial class framework from #3424 into the API a Playground Course is written against: Course presentation and completion (showPrelude / showMessage / showVideo / complete / completeWith), the Editor namespaces (Project, Runtime, CodeEditor, Ruler), Copilot text and structured generation, and Spotlight reveal.

Behavioral decisions worth reviewing:

  • Course callbacks run in order on the Course program's own goroutine. The placeholder started a goroutine per event; Course code is written as ordinary sequential code, and runtime logs are a judging channel whose order carries meaning, so events are queued and delivered one at a time.
  • Every host event is registered up front, so dispatching an event the Course did not subscribe to is accepted and dropped rather than failing as an unknown event.
  • The pending queue is bounded. A Course blocked in a slow capability — an LLM round takes seconds — tells the host it is not keeping up instead of silently losing events.
  • complete / completeWith end the program from within: the current callback runs to its end, queued events are abandoned, Start returns and the executor reports the run as completed. Repeated completions are ignored.
  • A failed capability panics. The Course asked for something that did not happen, and continuing would judge the learner on a false premise.
  • generateJSON derives its schema by reflection and decodes the response back into the Course's struct. A struct with no exported fields is rejected: encoding/json could never fill it, and the Course would silently read zero values. (An XGo author lowercasing field names out of habit is the likely case.)

Validation

  • go test ./... in tools/tutorial (event ordering, completion and idempotency, post-completion events, unsubscribed events, queue overflow, capability failure, schema derivation and write-back)
  • go test ./... in tools/xgoexec-bundle: builds a Course program that uses the whole API against the real class-framework binding, which also fails if the ixgo exports were not regenerated
  • GOOS=js GOARCH=wasm go build ./... in tools/xgoexec-bundle
  • go generate ./... in tools/xgoexec-bundle (regenerated exports are included)
  • Test on XGo Executor test page passed (/debug/xgoexec
  • Test on Tutorial test page with example course passed (/debug/tutorial-courses
截屏2026-08-24 17 16 39 截屏2026-08-24 17 14 55

🤖 Generated with Claude Code

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Tutorial ("Course") class framework

This is a solid, well-documented build-out of the Course framework. The concurrency model is deliberately narrow (one program, mutex-guarded state, single consumer goroutine draining a bounded event channel), the doc comments explain why rather than restate code, and the tests cover the important lifecycle edges: idempotent completion, in-order log delivery, post-completion drop, queue overflow, and capability failure. Removing the //go:build js && wasm constraint from tools/xgoexec-bundle/tutorial.go is correct — the host-only tutorial_test.go needs those symbols and the file pulls in no syscall/js.

A few findings below; the schema-recursion one is the only thing I'd want addressed before merge since it is reachable from Course-author input.

Additional notes (no reliable inline anchor)

  • Panic recovery for interpreted Course code. mustCallCapability and GenerateJSON panic on failure. TestCapabilityFailureStopsTheCourse verifies the panic propagates out of Gopt_Course_Main when called directly, but not that it is recovered when the Course runs inside the ixgo interpreter goroutine (tools/xgoexec/runtime.go Run uses a bare go func() with only ctx.SetPanic, no defer/recover). Worth confirming an author-triggered panic degrades to a reported error rather than crashing the host worker.
  • Stale design docs vs. shipped API (out of PR scope). docs/develop/tutorial-v2/tutorial-class-framework.go puts getCode() (no args) on CodeEditor and gives Project no methods, while the implementation ships Project.GetCode(sprite) / Project.ListSprites(). docs/develop/tutorial-v2/example-tutorial-course/main_course.gox calls editor.codeEditor.getCode() and will not compile against the shipped API. Consider a follow-up so the example doesn't drift.
  • Minor: every namespace receiver is named p (e.g. func (p *Spotlight)), which is non-idiomatic Go; and the drain loop in Start takes the global mutex on every iteration (completed()) — an atomic.Bool for the completed flag would remove that per-event contention. Low priority.

Comment thread tools/tutorial/schema.go
Comment thread tools/tutorial/spotlight.go Outdated
Comment thread tools/tutorial/program.go Outdated
Comment thread tools/tutorial/copilot.go Outdated
Ethanlita and others added 3 commits August 21, 2026 15:17
Grow the placeholder Tutorial class framework into the API a Playground
Course is written against: Course presentation and completion, the
Editor namespaces (Project, Runtime, CodeEditor, Ruler), Copilot text
and structured generation, and Spotlight reveal.

Course callbacks now run on the Course program's own goroutine, one at a
time, in the order the host dispatched the events, instead of each event
starting its own goroutine — Course code is written as ordinary
sequential code, and runtime logs are a judging channel whose order
carries meaning. The framework registers every host event up front, so
dispatching an event the Course did not subscribe to is accepted and
dropped rather than reported as an unknown event, and it bounds the
pending queue so a Course blocked in a slow capability tells the host it
is not keeping up instead of silently losing events.

complete and completeWith end the program from within: the current
callback runs to its end, queued events are abandoned and Start returns,
so the executor reports the run as completed. Repeated completions are
ignored.

generateJSON derives its JSON Schema from the struct the Course passed
and decodes the response back into it. A struct with no exported fields
is rejected, since encoding/json could never fill it and the Course
would silently read zero values.

A failed capability panics: the Course asked the editor, Copilot or the
learner's UI for something that did not happen, and continuing would
judge the learner on a false premise.

Validation: unit tests drive the framework through a fake host, and a
bundle-level test builds a Course program that uses the whole API
against the real class-framework binding, which also fails if the ixgo
exports were not regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ivation

- ShowVideo takes a declared video resource name, matching the contract
  merged in goplus#3437: videos are declared under assets/videos/<name>/, and
  the Course writes showVideo "step-to" rather than a file path
- Reject recursive result types in generateJSON: a self-referential or
  mutually referential struct made schema derivation recurse without
  bound, which crashes the interpreter instead of reporting a mistake
- Raise the event queue bound: presentation capabilities block until the
  learner acts, which can take minutes, and the runtime-log contract
  promises one event per appended entry, so the bound has to sit beyond
  what a running project can produce meanwhile
- Say "serializable exported fields" where json:"-" also excludes a field

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The namespaces reach their course's run state through this field, and
"program" sat one letter away from the unrelated Project namespace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Tutorial lifecycle prototype (goplus#3445) reads the completion feedback
from a feedback field, and the name also says what the value is. Align
the wire shape before either side ships.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
createTutorialFramework wraps the host's typed methods into the
capability table the executor consumes, mirroring the Go half's wire
names and request shapes. It lives in tools/tutorial next to the Go
package so the framework stays one self-contained unit, and spx-gui
consumes it through a symlink the same way it consumes the spxls type
declarations. The adapter deliberately imports nothing from spx-gui:
the executor's framework shape is declared structurally, which keeps
the whole directory extractable as its own repository.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The example course used a parenthesis-free command-style call as an
expression (feedback := Copilot.generateText "..."), which XGo only
allows at statement level, so the example never compiled. Fix the call
and add a bundle test that compiles the example through the real
classfile pipeline so the docs example cannot drift from the framework
again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reveal the API References panel right after filterAPIs narrows it to
stepTo, using the panel's real Radar node name. The exact selector
syntax is pending goplus#3441; a TODO marks the line for revisiting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the hand-rolled single-capability framework stub with
createTutorialFramework wired to a full mock TutorialFrameworkHost that
logs every capability call and answers with canned values. The course
source is the docs example imported via ?raw, so the debug page always
exercises the exact example we document.

Verified in the browser against the freshly built wasm: filterAPIs,
showPrelude, showVideo and spotlight_reveal (with Go-side defaults
mask=true/duration=0) run on start; dispatching the reached-target log
drives getCode -> generateText -> completeWith and the executor exits
with reason 'completed'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Extract the mock TutorialFrameworkHost into tutorial-mock-host.ts so
  both debug pages share it; it logs every capability call and answers
  with canned values.
- xgoexec debug page: the course source is now an editable textarea
  (prefilled with the docs example, with a button to load it back), and
  the status line turns green on 'exited: completed' / red on errors.
- New /debug/tutorial-courses page: scripted course cases run one by one
  against the real executor wasm; each case declares a course source and
  the events to dispatch, and passes (green) when the run exits as
  'completed'. Seeded with four cases: the docs example, a full-API
  course adapted from the bundle integration test, completion during
  onStart, and unsubscribed-event tolerance.

Verified in the browser: all four cases pass, including the
reflection-derived generateJSON schema round-tripping the bridge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Reviewed the tutorial Course-author framework across four dimensions (quality, performance, security, docs). This is a carefully-reasoned, well-tested PR: the single-consumer event loop, idempotent completion (markCompleted sets the flag before the capability so a panicking capability can't re-open the gate), snapshot-under-lock handler iteration, and the reflection schema derivation (rejecting recursive types and zero-serializable-field structs) are all sound. The two-language wire contract is guarded on both sides (tutorial-framework.test.ts locks the capability names; program_test.go asserts request shapes), and I verified every request shape matches the Go json tags (content, videoName, feedback, apis, sprite, {content,schema}, {target,tip,options}, and SpotlightOptions{mask,duration}).

The removal of //go:build js && wasm from tools/xgoexec-bundle/tutorial.go was confirmed intentional and correct — it lets tutorial_test.go run under host go test, while main.go (the only code touching syscall/js) still carries the wasm tag and off-wasm CallCapability resolves to the erroring stub. Not a defect.

One inline suggestion below. A few non-blocking notes:

  • Contract param-name drift (docs, not in this diff): docs/develop/tutorial-v2/module_TutorialFramework.ts:94 and tutorial-class-framework.go:33 declare completeWith(message …), but the code deliberately uses feedback (client.ts, Go json:"feedback"). The wire is consistent; only the contract's parameter name lags. Worth aligning the contract to feedback so it stays the source of truth tutorial.go claims it to be.
  • dispatchWithRetry retries on any error (tutorial-courses.vue): the loop retries every thrown error for up to 2s, so a genuine queue-full/decode rejection is masked and then rethrown with the transient cause obscured. Narrowing to the known "not running" registration-race message would surface real failures faster. Debug-only; the same broad-catch assumption exists in the Go test helper dispatch.
  • Reminder: both /debug/xgoexec and the new /debug/tutorial-courses routes run arbitrary XGo through the real executor and carry the "remove before merging to dev" TODO — just make sure that removal/gating is enforced before this reaches a non-dev build.

No correctness-breaking bug or security issue found.

Comment thread spx-gui/src/apps/xbuilder/pages/debug/tutorial-courses.vue Outdated
Ethanlita and others added 2 commits August 24, 2026 18:09
The contract (tutorial-class-framework.go / module_TutorialFramework.ts)
declares completeWith(message); the earlier switch to a {feedback} wire
field (b3784e9) followed the goplus#3445 prototype instead of the contract.
The contract is the source of truth, so revert to the {content} shape
shared by the other presentation capabilities and name the parameter
message; the prototype side should align with the contract instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a case exits before the deadline the 15s setTimeout kept running
until it fired into the already-settled Promise.race; clear it once the
case is over.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Ethanlita

Copy link
Copy Markdown
Collaborator Author

对 review summary 里三条 non-blocking notes 的处理:

  • completeWith 参数名漂移:成立,但修复方向与建议相反——契约是事实源,改的是代码而不是契约(b85584a0)。wire 回退为与其他展示类能力一致的 {content},参数名对齐契约的 message{feedback} 是当时向 Implement Tutorial v2 module #3445 原型对齐引入的偏离,原型侧应向契约对齐。
  • dispatchWithRetry 对所有错误重试:接受现状。超时后抛出的就是最后一次的真实错误,失败只是最多晚 2 秒暴露;而按错误串区分"未注册竞态"需要对 xgoexec 内部消息做字符串匹配,对一个调试页来说过于脆弱。Go 测试里的 dispatch helper 同理。
  • debug 路由不进非 dev 构建:确认。两个页面共用同一条 "Remove these temporary Tutorial v2 development pages before merging to dev" TODO,集成分支合入 dev 前移除。

Add a tools-tutorial-test job following the tools-ai-test precedent: it
verifies the committed qexp export file matches the tutorial package's
public API (build-wasm.sh silently regenerates it, so staleness never
failed a build before — it only broke course execution inside the wasm
interpreter), then runs the tutorial unit tests with -race and the
executor bundle's classfile-binding tests. Also let the spx-gui job's Go
cache cover the executor bundle modules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread tools/tutorial/program.go Outdated
if dead {
return
}
go lane.run(p)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

我们每一个 OnXXX 对应一个 addLane,产生一个goroutine,也就是一个事件对应一个 goroutine,这样其实不好,建议,每种事件一个 goroutine:同类事件顺序处理的规则容易解释。

比如 两段 OnLog:A 会弹出提示并等待关闭,B 判断是否完成课程。改为每种事件一个 goroutine 后,A 等待期间,B 和后续所有日志都会被堵住。等等,这种存在同一类型,存在先后注册顺序的,按注册顺序执行的需求是合理的

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个地方我们之前有一个讨论:
#3442 (comment)

嗯,所以我稍微放宽了一点。目前是每调用一次 onXXX就建一条 lane,即一个队列加一个专属 goroutine。一条日志到达时投给所有订阅 lane,各自独立处理。

嘛,实际上这样子可能也比较符合spx里面那种感觉,就如同现在的https://x.qiniu.com/editor/curator/Coding-Course-39/sprites/Kiko/code这一课,用户注册了两个onStart回调的时候其实有可能在某种程度上是期待这两个函数在“同时运行”的,如果B 和后续所有日志都会被阻塞的话,可能和spx那边的使用习惯会差的有点远。

@go-wyvern go-wyvern Sep 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

其实不会同时运行,wasm 是单线程的,看起来并发,但不并行

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

嗯好吧,我想一下是不是要简化一下这块的设计……?@nighca 寒星会有什么想法吗?

@nighca nighca Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tutorial 通过假设自己一定运行在浏览器的 wasm 环境中,利用这个环境的行为来简化自己的实现,这个我没问题

回到这里,你们说的好像不是一个事情

其实不会同时运行,wasm 是单线程的,看起来并发,但不并行

这边 Lita 说的“同时运行”就是并发而不是真正的并行,因为事件 handler 中可能存在可被调度的阻塞调用,比如 showMessage,它可能会打开一个 message modal 并等到用户“确认”后才 return,在此期间它不会一直占用 CPU 的。

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这种会卡着的命令,调度中是要让出调度权的?runtime.Sched ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

是的,调用会阻塞的Capability的时候是会释放token,这样其他的handler就会执行

@go-wyvern go-wyvern Sep 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

execToken 之前没 review 到,按目前设计,改造成是否 按注册顺序 复杂度确实比较高,按课程需求,这个需求可能并不太重要。可以忽略:

目前的调度方式:

M -> 调度到 G1 : callCapability 内部有一个 <- response ,这个异步操作,会让 golang 调度器调度到 G2,但 G2 的 execToken 拿不到( <- execToken 卡住),继续让出调度权。如果 G1 调用 callCapability 前,判断 式 call 是 kindSlow 或者 kindPresentation 会临时让出 execToken,G2就可以继续走下去。

按目前的设计,我觉得也没问题。我觉得可以先合入。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

有关于不可重入的问题,计划采用“可取消”的事件模型替代,并且改用“提供能力”的方式,允许作者根据其需求自行设计。

func longTask(ctx context.Context) {
	doTask1()
	if (ctx.Done()) {
		return
	}
	doTask2()
	if (ctx.Done()) {
		return
	}
	doTask3()
}

var ctrl tutorial.TaskController

// var currentId = 0

// course.onExit => {
// 	var confirmed = ui.confirm("Are you sure you want to exit?")
// 	if !confirmed {
// 		return
// 	}
// 	var confirmed = ui.confirm("Are you sure you want to exit again?")
// 	if !confirmed {
// 		return
// 	}
// 	complete
// }

onLog log => {
	ctrl.trackRoutine() // 1. cancel previous 2. watch and abort current routine on next cancel
	// ctrl.cancelPrevious()

	doCheck
	doCheck2
	complete
}

onExit code => {
	// ctrl.trackFrame()
	doCheck
	complete
}

// ==== 1 ====

onLog log => {
	if log.level == "error" {
		processError(log)
	} else if log.level == "warning" {
		processWarning(log)
	}
}

// ==== 2 ====

onLog log => {
	if log.level == "error" {
		processError(log)
	}
}

onLog log => {
	if log.level == "warning" {
		processWarning(log)
	}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

该计划已经延后到issue #3509 PR #3510

Comment thread tools/xgoexec-bundle/tutorial.go
Ethanlita and others added 2 commits September 14, 2026 13:47
…lers

Split terminated into a locked wrapper and terminatedLocked, and let
addLane's start reuse the latter inside the region that also does
laneWorkers.Add. The check and the Add stay under one lock on purpose:
after the terminal state, awaitShutdown may already be in Wait, and an
Add landing after that is a WaitGroup misuse. Today both sides run
inside token-holding frames and cannot interleave; keeping them under
one lock means this spot does not depend on that discipline.

Requested by review (go-wyvern).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rker

A course may register a callback from inside another callback, for
example subscribing to runtime logs only after the prelude was
dismissed. That is the lanesStarted branch of addLane, which no test
exercised: disabling it left the whole suite green. The new test
registers onLog inside onStart and expects a later log to complete the
course; with the branch disabled it times out.

Requested by review (go-wyvern).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread tools/tutorial/program.go Outdated
Ethanlita and others added 3 commits September 15, 2026 11:14
Brings in goplus#3445 (Tutorial v2 module). tools/tutorial/tutorial.go is
taken from this branch as agreed in that PR's review: the skeleton
changes there only served its mock session and goplus#3442 is authoritative.
The executor's run() resolves as soon as the interpreter goroutine is
launched, before the course program has registered its event handlers,
and xgoexec now ignores events with no handler instead of erroring. An
event dispatched in that window was silently lost; the debug page and
the Go tests only survived it by retrying on the old error.

Register the four handlers once, in package init: this package is
compiled natively into xgoexec.wasm, so init runs at process start,
before any build or run. The handlers route to a process-level event
registry that knows the attached program and whether it is live.
XGot_Course_Main attaches the program before MainEntry; Start switches
it live only after the course-start delivery, replaying everything held
until then in arrival order, and keeps holding events that arrive during
the replay so nothing overtakes a held event. The pending buffer is
bounded like a lane queue and errors beyond that.

The contract now states the guarantee: dispatching is safe as soon as
run() resolves. The invariants checker pins the new shape: handlers are
registered only in init, attach/goLive only from their entry points,
and registryMu regions may only touch fields (len/append/fmt.Errorf/
json.RawMessage). The debug page and the test dispatch helper drop
their retry loops; the one test that dispatched from outside without a
readiness signal now resets the registry first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Compare the keys of eventDeliverers with the TutorialEvent union in
module_TutorialFramework.ts. With the executor ignoring unregistered
event names, forgetting to register a new event no longer produces any
error at runtime, so the mismatch has to surface here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the per-callback serial unit with runs: every trigger starts a
new run of each registered callback, and runs may overlap. How runs
relate is decided by a run group's RunPolicy (CancelPrevious,
OneAtATime, SkipWhileBusy): a policy given at registration puts the
callback's runs in a private group from their first statement, while
newRunGroup plus enter() lets a run join a group after filtering its
trigger and lets several callbacks, such as an onLog and an onExit that
both judge completion, share one policy. A cancelled run ends at its
next waiting point and the result of a call it was waiting on is
discarded; the host settles calls as usual and needs no cancellation
protocol. Runs start in a fixed order: triggers in arrival order, and
within one trigger the callbacks in registration order, each to its
first wait before the next starts.

Presentation is no longer serialized by the framework: overlapping
course.show* calls are the host capability's policy, as agreed in the
review call. Align CopilotRound's field names with how Course code reads
them (XGo lowercases method calls, not field access).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every trigger now starts a new run of each registered callback on its
own goroutine, so runs of one callback may overlap, as agreed in the
review call: the old per-callback serial unit handled the stale-result
race by queueing, and queueing gave poor results. How runs relate is
now the author's choice through a run group's RunPolicy:

  CancelPrevious  the joining run cancels the run holding the group;
                  a cancelled run ends at its next waiting point and
                  the result it was waiting on is discarded
  OneAtATime      the joining run yields and waits for its turn
  SkipWhileBusy   the joining run ends on the spot

A policy given at registration (OnLog__1 and friends, resolved by XGo's
__N overloading) puts the callback's runs in a private group from their
first statement; newRunGroup plus enter() lets a run join after
filtering its trigger and lets callbacks of different events share one
policy. The author never checks a result: skipping and cancellation end
the run through a sentinel that runFrame absorbs silently.

Runs start in a fixed order. A single dispatcher goroutine (the former
pre-ready buffer became the one dispatch queue) handles triggers in
arrival order, and startRuns starts the callbacks of one trigger in
registration order, each to its first yield before the next, the
JoinYieldedOrDone shape spx uses for onStart. yieldWhile is the only
yield point and the only place cancellation takes effect, so a
cancelled run never touches a pending result.

The framework no longer serializes presentation calls: overlapping
course.show* calls are the host capability's policy per the contract.
The invariants checker pins the new shape (token ops in runFrame and
yieldWhile only, one start path, cancel only in join, leaf-lock call
allowlists); the docs example and the debug seed course use a group;
the bundle build test exercises all three registration forms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Keep the overlapping-run model from the previous commit: every trigger
starts a new run of each registered callback, nothing queues, and the
host owns presentation overlap. Take the run policies out of this PR so
it can merge and unblock the rest of Tutorial v2: RunPolicy, RunGroup,
newRunGroup and enter(), the registration overloads, cancellation and
the group lock all go. The review call left the concept, its naming and
whether to ship it unsettled; goplus#3509 carries that discussion, and the
full implementation stays reachable as 07ed874 for a follow-up PR.

What remains is exactly what the contract now says: runs of one
callback may overlap and how they relate is unspecified (TODO in the
contract and in program.go pointing at goplus#3509). The dispatcher still
starts runs in arrival and registration order, each to its first yield
before the next, because that is what lets course start precede host
events and keeps the queue draining; the contract does not promise it,
and the regression test says so. The invariants checker drops the
policy rules; the docs example, the debug seed course and the bundle
build test use plain registrations again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@nighca nighca left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

另外记得 rebase 下 goplus/issue-3403-tutorial-v2,预期 rebase 后

image

这个会通过

Comment thread tools/tutorial/client.ts Outdated
@@ -0,0 +1,127 @@
/**
* Tutorial Class Framework 的前端半边。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

之前 comment 里说合入前最后一个 commit 把注释统一为英文,别忘了

Requested by review: the project writes comments in English, and this
package was the last one in tools/ still commenting in Chinese. Every
comment in tools/tutorial and in the bundle's build test is translated
rather than trimmed, so the design rationale each one carries survives.

Three comments were stale and are corrected while being translated:
GenerateText pointed at program.go's eventQueueSize, which is now
pendingEventLimit in events.go; the late-registration test still
described addLane and lanesStarted, which registration no longer has;
and GenerateJSON repeated, at length, why reflection works on Course
types, which deriveSchema already explains, so it now points there
instead. No code changes: the only lines that differ outside comment
blocks are trailing comments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nighca
nighca merged commit 2f9ee66 into goplus:issue-3403-tutorial-v2 Sep 17, 2026
6 checks passed
Ethanlita added a commit to Ethanlita/builder that referenced this pull request Sep 17, 2026
… runs

Since goplus#3442, every trigger starts a new run of each registered callback
and runs may overlap while one of them waits on the learner or on
generation. That reproduces the stale-result race the review call
discussed, and the agreed answer was cancelling or aborting stale
processing rather than queueing. This lets the author say how runs of
one callback, or of several callbacks, relate:

  CancelPrevious  the joining run cancels the run holding the group;
                  a cancelled run ends at its next waiting point and
                  the result it was waiting on is discarded
  OneAtATime      the joining run yields and waits for its turn
  SkipWhileBusy   the joining run ends on the spot

newRunGroup plus enter() lets a run join after filtering its trigger and
lets callbacks of different events share one policy; a policy or a group
given at registration joins from the first statement, through the __N
overloads XGo groups. The author never checks a result: skipping and
cancellation end the run through a sentinel that runFrame absorbs
silently.

yieldWhile is the only yield point and the only place cancellation takes
effect, so a cancelled run never touches a pending result. Group state
lives behind a leaf lock. The invariants checker pins the shape, the
docs example and the debug seed course use a group, and the bundle build
test exercises all three registration forms.

The design is still under discussion in goplus#3509; this branch carries the
implementation for that follow-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nighca pushed a commit to nighca/goplus-builder that referenced this pull request Sep 20, 2026
…3442)

* feat(tutorial): implement the Course-author-facing framework

Grow the placeholder Tutorial class framework into the API a Playground
Course is written against: Course presentation and completion, the
Editor namespaces (Project, Runtime, CodeEditor, Ruler), Copilot text
and structured generation, and Spotlight reveal.

Course callbacks now run on the Course program's own goroutine, one at a
time, in the order the host dispatched the events, instead of each event
starting its own goroutine — Course code is written as ordinary
sequential code, and runtime logs are a judging channel whose order
carries meaning. The framework registers every host event up front, so
dispatching an event the Course did not subscribe to is accepted and
dropped rather than reported as an unknown event, and it bounds the
pending queue so a Course blocked in a slow capability tells the host it
is not keeping up instead of silently losing events.

complete and completeWith end the program from within: the current
callback runs to its end, queued events are abandoned and Start returns,
so the executor reports the run as completed. Repeated completions are
ignored.

generateJSON derives its JSON Schema from the struct the Course passed
and decodes the response back into it. A struct with no exported fields
is rejected, since encoding/json could never fill it and the Course
would silently read zero values.

A failed capability panics: the Course asked the editor, Copilot or the
learner's UI for something that did not happen, and continuing would
judge the learner on a false premise.

Validation: unit tests drive the framework through a fake host, and a
bundle-level test builds a Course program that uses the whole API
against the real class-framework binding, which also fails if the ixgo
exports were not regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat:  Course ClassFramework

* fix(tutorial): follow the merged video contract and harden schema derivation

- ShowVideo takes a declared video resource name, matching the contract
  merged in goplus#3437: videos are declared under assets/videos/<name>/, and
  the Course writes showVideo "step-to" rather than a file path
- Reject recursive result types in generateJSON: a self-referential or
  mutually referential struct made schema derivation recurse without
  bound, which crashes the interpreter instead of reporting a mistake
- Raise the event queue bound: presentation capabilities block until the
  learner acts, which can take minutes, and the runtime-log contract
  promises one event per appended entry, so the bound has to sit beyond
  what a running project can produce meanwhile
- Say "serializable exported fields" where json:"-" also excludes a field

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tutorial): name the shared state field courseProgram

The namespaces reach their course's run state through this field, and
"program" sat one letter away from the unrelated Project namespace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tutorial): send completeWith feedback as the feedback field

The Tutorial lifecycle prototype (goplus#3445) reads the completion feedback
from a feedback field, and the name also says what the value is. Align
the wire shape before either side ships.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tutorial): add the frontend framework adapter

createTutorialFramework wraps the host's typed methods into the
capability table the executor consumes, mirroring the Go half's wire
names and request shapes. It lives in tools/tutorial next to the Go
package so the framework stays one self-contained unit, and spx-gui
consumes it through a symlink the same way it consumes the spxls type
declarations. The adapter deliberately imports nothing from spx-gui:
the executor's framework shape is declared structurally, which keeps
the whole directory extractable as its own repository.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tutorial): make the docs example course compile and guard it

The example course used a parenthesis-free command-style call as an
expression (feedback := Copilot.generateText "..."), which XGo only
allows at statement level, so the example never compiled. Fix the call
and add a bundle test that compiles the example through the real
classfile pipeline so the docs example cannot drift from the framework
again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(tutorial): demonstrate spotlight in the example course

Reveal the API References panel right after filterAPIs narrows it to
stepTo, using the panel's real Radar node name. The exact selector
syntax is pending goplus#3441; a TODO marks the line for revisiting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tutorial): run the docs example course in the xgoexec debug page

Replace the hand-rolled single-capability framework stub with
createTutorialFramework wired to a full mock TutorialFrameworkHost that
logs every capability call and answers with canned values. The course
source is the docs example imported via ?raw, so the debug page always
exercises the exact example we document.

Verified in the browser against the freshly built wasm: filterAPIs,
showPrelude, showVideo and spotlight_reveal (with Go-side defaults
mask=true/duration=0) run on start; dispatching the reached-target log
drives getCode -> generateText -> completeWith and the executor exits
with reason 'completed'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tutorial): turn the debug pages into course test infrastructure

- Extract the mock TutorialFrameworkHost into tutorial-mock-host.ts so
  both debug pages share it; it logs every capability call and answers
  with canned values.
- xgoexec debug page: the course source is now an editable textarea
  (prefilled with the docs example, with a button to load it back), and
  the status line turns green on 'exited: completed' / red on errors.
- New /debug/tutorial-courses page: scripted course cases run one by one
  against the real executor wasm; each case declares a course source and
  the events to dispatch, and passes (green) when the run exits as
  'completed'. Seeded with four cases: the docs example, a full-API
  course adapted from the bundle integration test, completion during
  onStart, and unsubscribed-event tolerance.

Verified in the browser: all four cases pass, including the
reflection-derived generateJSON schema round-tripping the bridge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tutorial): align completeWith with the contract's naming

The contract (tutorial-class-framework.go / module_TutorialFramework.ts)
declares completeWith(message); the earlier switch to a {feedback} wire
field (b3784e9) followed the goplus#3445 prototype instead of the contract.
The contract is the source of truth, so revert to the {content} shape
shared by the other presentation capabilities and name the parameter
message; the prototype side should align with the contract instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tutorial): clear the per-case timeout timer in the course runner

When a case exits before the deadline the 15s setTimeout kept running
until it fired into the already-settled Promise.race; clear it once the
case is over.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(tutorial): test the tutorial framework and guard qexp exports

Add a tools-tutorial-test job following the tools-ai-test precedent: it
verifies the committed qexp export file matches the tutorial package's
public API (build-wasm.sh silently regenerates it, so staleness never
failed a build before — it only broke course execution inside the wasm
interpreter), then runs the tutorial unit tests with -race and the
executor bundle's classfile-binding tests. Also let the spx-gui job's Go
cache cover the executor bundle modules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tutorial): group TutorialFrameworkHost by the API tree

Restructure the host interface from flat underscore-joined methods into
course / editor / copilot / spotlight namespaces mirroring the
author-facing API tree, per review feedback on goplus#3442 — friendlier for
the host implementer. The wire stays flat: createTutorialFramework still
maps each capability name onto the nested host method. The contract
(module_TutorialFramework.ts) changes together with the code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tutorial): consolidate the debug pages

Per review feedback on goplus#3442: inline a copy of the docs example course
instead of importing it from outside the frontend directory (the docs
copy stays compile-guarded by tools/xgoexec-bundle), and fold the former
/debug/xgoexec page into /debug/tutorial-courses as a free-form runner
card (editable course source prefilled with the example, plain-XGo run,
runtime-log dispatch), removing the old page and route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(tutorial): replace the adapter vitest with a wire-contract check

Per review feedback on goplus#3442: drop spx-gui's tutorial-framework.test.ts
(the adapter is maintained in tools/tutorial, so spx-gui testing it was
misplaced, and its hand-written wire-name list stayed green when both
sides forgot an update). The guard moves into the framework's own
directory as a Go test that extracts capability names from the Go
sources (mustCallCapability call sites) and from client.ts's
capabilities table, then asserts the two sets match — renaming either
side now fails tools-tutorial-test in CI without any manual list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tutorial): let waiting capabilities yield to other events

Replace the single run-to-completion event loop with a token-scheduled
execution model, following the review discussion on the event model:

- Callbacks run as frames holding a cap-1 execution token, so exactly
  one piece of course code executes at any instant and shared variables
  in course code still never race (the token's release/acquire chain
  provides the happens-before edges a single loop used to).
- Capabilities are classified in capabilityKinds by what they wait on.
  Calls waiting on an external party — the learner (course_show*) or
  the LLM (copilot_generate*) — release the token for the duration, so
  events keep being handled while a dialog is open or a generation is
  pending, and state observed after such a call is fresh. Calls that
  only wait on host computation keep the token; an unregistered name
  degrades to non-yielding, the conservative direction.
- Each event gets its own lane (FIFO queue + single worker): triggers
  of one event stay strictly ordered and its handlers never interleave
  or re-enter, so check-then-act judgment code needs no guards. Only
  different events interleave, at waiting points.
- Presentation calls from different frames are serialized on a
  dedicated lane, entered only after releasing the token (lock order
  matters: waiting for it while holding the token would freeze all
  frames), so the host never sees concurrent dialogs.
- Completion closes intake; running and suspended frames finish their
  remaining statements (the host no-ops presentation after completion)
  and the program then exits. Frame panics are recorded and re-thrown
  on the main goroutine so the executor still reports exit 'error'.

Contract docs gain the author-visible semantics and the host-visible
note that non-presentation calls may now be pending concurrently. New
tests cover yielding during a held dialog (logs processed meanwhile,
fresh state observed after), same-event serialization, presentation
serialization, and two generations pending concurrently; the wire-name
guard now also checks capabilityKinds keys are real capabilities.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tutorial): serialize per registered callback, not per event

Change the serial unit from one lane per event to one lane per
registered callback, per design discussion: when an author registers
several callbacks on one event, expecting them to run independently is
the natural reading. Each callback now owns its queue and worker — its
triggers stay strictly ordered and it never re-enters, while different
callbacks (including several on the same event) may interleave at
waiting points. Callbacks registered after the program started get
their worker immediately. Contract wording updated accordingly; tests
drop cross-callback ordering assumptions and add coverage for two
same-event callbacks running independently while one waits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tutorial): close the review findings on the yielding model

Address the external review of the execution model (4 High, 2 Medium,
1 nit — all confirmed):

- Decode waiting-capability responses under the token: the bridge used
  to write straight into the caller's result (an author-shared struct
  for generateJSON) while the token was released — a race outside the
  serialization guarantee. Responses now land in a private buffer and
  are unmarshalled after the token is reacquired.
- Admission moved to after token acquisition: a dequeued event's
  pre-check could go stale while its frame waited for the token, letting
  a callback start after completion or a fatal error. runFrame now
  re-checks the terminal state once it holds the token.
- awaitEnd re-checks the fatal slot after the completion-path Wait: a
  failing course_complete (or a late frame panic) used to be swallowed
  and reported as completed.
- Event delivery is all-or-none: deliverAll preflights every lane's
  capacity under a delivery lock before sending, so one full queue no
  longer leaves the event partially delivered; delivery after a fatal
  error now returns an error instead of feeding dead queues.
- Worker startup checks the terminal state under the same lock as
  workers.Add, closing an Add-vs-Wait window for callbacks registered
  while the program is winding down.
- The token comment no longer claims FIFO wakeup (Go does not promise
  it), and a classification test forces every capability into either
  capabilityKinds or an explicit fast list — the safe runtime default
  can no longer hide an unclassified capability.

The host contract gains the matching obligation: on completion the host
must promptly settle every still-pending call, since the program exits
only after pending callbacks finish. Four regression tests pin the
fixed behaviors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(tutorial): machine-check the scheduler's locking discipline

The rules that keep the yielding model deadlock-free lived only in
comments; this turns them into checks that fail in CI:

- An AST-based test asserts: the token moves only through
  acquire/release, and only runFrame and mustCallCapability may operate
  it; presentationMu and the capability bridge are touched only inside
  mustCallCapability (whose fixed order releases the token before any
  long wait); mu- and deliverMu-held regions contain no blocking
  operation (channel ops, select, Wait, token ops, bridge calls), with
  deliverMu -> mu as the only permitted nesting and deliverAll's
  preflighted queue send as the one documented exemption.
- release() gains a dynamic assertion: a non-blocking send that panics
  on a release without a matching acquire, so a pairing bug surfaces
  immediately instead of silently minting a second token.
- init fills the token before entering the mu region, so the invariant
  holds without exceptions.

Injecting violations (token op inside a mu region, presentationMu use
outside its home) was verified to turn the check red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tutorial): name the scheduler's primitives by domain

Rename the synchronization primitives so each name carries its domain
and its object, per design discussion:

- token -> execToken, acquire/release -> acquireExec/releaseExec: the
  execution-right token, with the object visible at every call site.
- mu -> schedulerMu: it guards the scheduler's own bookkeeping (handler
  registry, lifecycle flags, lane starters). 'state'/'program' were
  rejected as ambiguous — 'program' collides with the learner project's
  running program; spx's scheduler uses the same name for the same role.
- deliverMu -> eventDeliveryMu: says what is being delivered.
- ending/endOnce/signalEnd/awaitEnd -> shutdown/shutdownOnce/
  beginShutdown/awaitShutdown: the channel signals that wind-down has
  begun, not that everything has finished.

Pure rename, no semantic change; the invariant checker's name-based
rules are updated in lockstep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tutorial): reject untagged embedded fields in generateJSON schemas

encoding/json promotes an untagged anonymous field's exported members
into the outer object, but the derived schema nested them under the
type name — a host result conforming to that schema was then ignored
during unmarshalling, leaving the embedded fields at zero values
(external review finding). Mirroring the full promotion/conflict rules
is not worth it for an LLM-facing schema, so such fields are rejected
with a clear error, consistent with how recursive types are handled;
an anonymous field with an explicit json name is treated by
encoding/json as a regular named field and keeps working, now pinned by
a test that round-trips against encoding/json itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tutorial): rename workers to laneWorkers

'worker' collides with the browser Web Worker that hosts the whole wasm
instance one container level up; the WaitGroup counts lane worker
goroutines inside it. The new name carries its qualifier, and the field
comment now states the distinction explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(tutorial): add AGENTS.md with the scheduler's rules of change

Capture the discipline that keeps the yielding execution model correct
as directory-scoped agent guidance, following the tools/ai precedent:
locking rules (and their machine-checked enforcement), capability
classification and contract-sync obligations, package testing pitfalls
(process-global event registry, token-holding blocks), and the
qexp/symlink build-chain constraints. Rules only — design rationale
stays in program.go's comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tutorial): deliver course start as an event

Course.OnStart used to append to a plain slice that Start ran one after
another on the main goroutine, so a second onStart could not begin until
the first returned — including its waits on the learner. Nothing in the
design asked for that; it was left over from the fully serial model and
was never revisited when event callbacks moved to per-callback lanes.

Course start now goes through the same path as every host event: each
onStart registers a lane, and Start delivers the single course-start
trigger through deliverAll. Several onStart callbacks run independently
and may interleave at waiting points, exactly like several callbacks on
one event; there is no ordering promise between them. Author callbacks
now have exactly two execution paths: a lane worker's frame and the one
MainEntry frame. A rejected delivery can only mean MainEntry already
recorded a fatal error, which recordFatal keeps as the first one.

Contract wording and AGENTS.md updated. TestHandlersAccumulate drops its
start-order assumption and completes only once all four callbacks ran;
TestCourseStartHandlersRunIndependently pins that a second onStart does
not wait for the first one's showPrelude to return.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tutorial)!: name the classfile entry XGot_Course_Main

XGot_ is the current prefix for a template receiver method; Gopt_ is the
legacy spelling that gogen still accepts (isXGoCommon takes both "XGo" and
"Gop"), which is why the old name kept working. spx v3 already uses
XGot_Game_Main, so stop relying on the compatibility path.

The generated qexp export follows. tools/xgoexec-bundle's build test covers
this: with an unrecognized prefix the classfile binding fails with "Course
has no method Main".

Requested by review (go-wyvern).

* refactor(tutorial): share the terminal-state check between locked callers

Split terminated into a locked wrapper and terminatedLocked, and let
addLane's start reuse the latter inside the region that also does
laneWorkers.Add. The check and the Add stay under one lock on purpose:
after the terminal state, awaitShutdown may already be in Wait, and an
Add landing after that is a WaitGroup misuse. Today both sides run
inside token-holding frames and cannot interleave; keeping them under
one lock means this spot does not depend on that discipline.

Requested by review (go-wyvern).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(tutorial): pin that callbacks registered during the run get a worker

A course may register a callback from inside another callback, for
example subscribing to runtime logs only after the prelude was
dismissed. That is the lanesStarted branch of addLane, which no test
exercised: disabling it left the whole suite green. The new test
registers onLog inside onStart and expects a later log to complete the
course; with the branch disabled it times out.

Requested by review (go-wyvern).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tutorial): hold events dispatched before the program is ready

The executor's run() resolves as soon as the interpreter goroutine is
launched, before the course program has registered its event handlers,
and xgoexec now ignores events with no handler instead of erroring. An
event dispatched in that window was silently lost; the debug page and
the Go tests only survived it by retrying on the old error.

Register the four handlers once, in package init: this package is
compiled natively into xgoexec.wasm, so init runs at process start,
before any build or run. The handlers route to a process-level event
registry that knows the attached program and whether it is live.
XGot_Course_Main attaches the program before MainEntry; Start switches
it live only after the course-start delivery, replaying everything held
until then in arrival order, and keeps holding events that arrive during
the replay so nothing overtakes a held event. The pending buffer is
bounded like a lane queue and errors beyond that.

The contract now states the guarantee: dispatching is safe as soon as
run() resolves. The invariants checker pins the new shape: handlers are
registered only in init, attach/goLive only from their entry points,
and registryMu regions may only touch fields (len/append/fmt.Errorf/
json.RawMessage). The debug page and the test dispatch helper drop
their retry loops; the one test that dispatched from outside without a
readiness signal now resets the registry first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(tutorial): guard registered event names against the contract

Compare the keys of eventDeliverers with the TutorialEvent union in
module_TutorialFramework.ts. With the executor ignoring unregistered
event names, forgetting to register a new event no longer produces any
error at runtime, so the mismatch has to surface here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(tutorial): contract for run policies and host-owned presentation

Replace the per-callback serial unit with runs: every trigger starts a
new run of each registered callback, and runs may overlap. How runs
relate is decided by a run group's RunPolicy (CancelPrevious,
OneAtATime, SkipWhileBusy): a policy given at registration puts the
callback's runs in a private group from their first statement, while
newRunGroup plus enter() lets a run join a group after filtering its
trigger and lets several callbacks, such as an onLog and an onExit that
both judge completion, share one policy. A cancelled run ends at its
next waiting point and the result of a call it was waiting on is
discarded; the host settles calls as usual and needs no cancellation
protocol. Runs start in a fixed order: triggers in arrival order, and
within one trigger the callbacks in registration order, each to its
first wait before the next starts.

Presentation is no longer serialized by the framework: overlapping
course.show* calls are the host capability's policy, as agreed in the
review call. Align CopilotRound's field names with how Course code reads
them (XGo lowercases method calls, not field access).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tutorial)!: run policies and run groups replace per-callback lanes

Every trigger now starts a new run of each registered callback on its
own goroutine, so runs of one callback may overlap, as agreed in the
review call: the old per-callback serial unit handled the stale-result
race by queueing, and queueing gave poor results. How runs relate is
now the author's choice through a run group's RunPolicy:

  CancelPrevious  the joining run cancels the run holding the group;
                  a cancelled run ends at its next waiting point and
                  the result it was waiting on is discarded
  OneAtATime      the joining run yields and waits for its turn
  SkipWhileBusy   the joining run ends on the spot

A policy given at registration (OnLog__1 and friends, resolved by XGo's
__N overloading) puts the callback's runs in a private group from their
first statement; newRunGroup plus enter() lets a run join after
filtering its trigger and lets callbacks of different events share one
policy. The author never checks a result: skipping and cancellation end
the run through a sentinel that runFrame absorbs silently.

Runs start in a fixed order. A single dispatcher goroutine (the former
pre-ready buffer became the one dispatch queue) handles triggers in
arrival order, and startRuns starts the callbacks of one trigger in
registration order, each to its first yield before the next, the
JoinYieldedOrDone shape spx uses for onStart. yieldWhile is the only
yield point and the only place cancellation takes effect, so a
cancelled run never touches a pending result.

The framework no longer serializes presentation calls: overlapping
course.show* calls are the host capability's policy per the contract.
The invariants checker pins the new shape (token ops in runFrame and
yieldWhile only, one start path, cancel only in join, leaf-lock call
allowlists); the docs example and the debug seed course use a group;
the bundle build test exercises all three registration forms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tutorial): defer run policies to goplus#3509

Keep the overlapping-run model from the previous commit: every trigger
starts a new run of each registered callback, nothing queues, and the
host owns presentation overlap. Take the run policies out of this PR so
it can merge and unblock the rest of Tutorial v2: RunPolicy, RunGroup,
newRunGroup and enter(), the registration overloads, cancellation and
the group lock all go. The review call left the concept, its naming and
whether to ship it unsettled; goplus#3509 carries that discussion, and the
full implementation stays reachable as 07ed874 for a follow-up PR.

What remains is exactly what the contract now says: runs of one
callback may overlap and how they relate is unspecified (TODO in the
contract and in program.go pointing at goplus#3509). The dispatcher still
starts runs in arrival and registration order, each to its first yield
before the next, because that is what lets course start precede host
events and keeps the queue draining; the contract does not promise it,
and the regression test says so. The invariants checker drops the
policy rules; the docs example, the debug seed course and the bundle
build test use plain registrations again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(tutorial): write the framework's comments in English

Requested by review: the project writes comments in English, and this
package was the last one in tools/ still commenting in Chinese. Every
comment in tools/tutorial and in the bundle's build test is translated
rather than trimmed, so the design rationale each one carries survives.

Three comments were stale and are corrected while being translated:
GenerateText pointed at program.go's eventQueueSize, which is now
pendingEventLimit in events.go; the late-registration test still
described addLane and lanesStarted, which registration no longer has;
and GenerateJSON repeated, at length, why reflection works on Course
types, which deriveSchema already explains, so it now points there
instead. No code changes: the only lines that differ outside comment
blocks are trailing comments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – builder d4ba070a Deployed Sep 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants