眠る前に流す音を、決めた時間で止めるだけの小さな画面を Rork で試作していた夜のことです。ヒーリング音源のアプリを長く運用しているせいか、こういう「時間が来たら止まる」仕組みには私自身の思い込みが多いのかもしれません。だからこそ、AI にゼロから組ませてみたくなったのです。
最初の生成はきれいに動きました。15 分と入れて、画面を見つめていると、15 分後に音は止まります。ところが一度ホームへ戻って、また画面を開き直すと、いつまでも止まらないことがある——そこから三度の修正依頼が始まりました。
一度目は「止まらないことがあります」と伝え、二度目は「まだ止まりません」と重ね、三度目の文面を打ちかけたところで手が止まりました。この三度目をそのまま送るのか、それとも動いていた版へ戻して、頼み方を変えるのか。両方をやってみたので、その顛末と、私が引いた線を書き残します。
最初にお伝えしたいのは、三度目からは戻る、という線です
先に結論を置きます。同じ不具合への修正依頼が三度目に入ったら、私は続きで頼まず、いったん動いていた版へ戻して、別の言い方で頼み直すことにしています。
Rork の FAQ には、Restore 機能で過去のどの版にも戻せること、そして戻す前に各版をプレビューして確かめられることが書かれています。同じ FAQ には、エラーループに入ったときの選択肢として、Web 検索を頼む、モデルを切り替える、動いていた版へ戻して別の進め方でもう一度組む、の三つが並んでいます。
ここで一つ、私が最初に読み違えていたことがあります。FAQ には「AI のエラーにはクレジットを課金しない」とも書かれていますが、私の読み方では、これは生成そのものが失敗した場合の話です。今回のように「動くけれど意図と違う」修正は、ふつうに一往復ぶんのクレジットが減ります。無料プランは 1 日 5 クレジット、Pro は月 100 クレジットという枯れ方をしますから、「直っていない修正」の往復は、思っているより早く残高を削るのです。
無料枠の数え方そのものは、Rork の無料枠は月35クレジットですが、実際にぶつかるのは1日5という上限のほうですで別に書いておりますので、ここでは「往復の数」だけを追います。
続きで直す道で、コードに何が残ったか
まず、三度目もそのまま続きで頼んでみました。「画面を開き直しても、設定した時間で必ず止まるようにしてください」と伝えたのです。
返ってきた修正は動きました。ただし、Dev mode で中身を開くと、こういう形になっていました。要点だけ抜き出しています。
// Before: 三度の「続きで直して」のあとに残っていた形(要点のみ)
final class SleepTimer: ObservableObject {
@Published var isPlaying = false
private var timer: Timer?
private var backupTimer: Timer? // 二度目の修正で追加された
private var shouldStop = false // 三度目の修正で追加された
func start(minutes: Int) {
isPlaying = true
let seconds = Double(minutes) * 60
timer = Timer.scheduledTimer(withTimeInterval: seconds, repeats: false) { _ in
self.isPlaying = false
}
backupTimer = Timer.scheduledTimer(withTimeInterval: seconds + 1, repeats: false) { _ in
self.shouldStop = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) {
if self.shouldStop { self.isPlaying = false }
}
}
}止めるための仕組みが三つ、別々に生きています。一度目のタイマー、二度目の予備タイマー、三度目のフラグと遅延実行。そして本当の原因——画面を開き直すたびに start が呼ばれ、前のタイマーを捨てずに新しい予定を足していたこと——には、どの修正も触れていません。
なぜこうなるのかを、私はこう理解しています。続きで頼むと、AI は直前までの会話を「正しい前提」として読みます。前回の修正が間違っていたとは考えにくく、その上に一枚足すほうが会話としては自然なのです。人間の同僚に「まだ直らない」と三度言われたときの反応と、たぶん同じです。
往復は三度で、コードは動き、けれど私には読めない形になりました。次に別の不具合が出たとき、この三つのどれが犯人かを切り分ける自信が、私にはありませんでした。
復元して言い換える道で、何が変わったか
そこで Restore を開き、一度目の修正依頼を送る前の版をプレビューで確かめてから、そこへ戻しました。開き直したときの不具合はそのまま残っている版です。
戻したあと、同じ不具合を、前とは違う言い方で一度だけ頼みました。三度の失敗を眺めて分かったのは、私の頼み方が「症状」を伝えていて、「あるべき状態」を伝えていなかったことです。言い換えの型は三つに絞りました。
# 言い換えの型(実際に送った文面をこの三つに沿って組み直しました)
1. 症状ではなく、結果で言う
「止まらないことがある」→「設定した時刻を過ぎたら、どの画面遷移を経ても再生が止まっている状態にしてください」
2. 決める場所を一箇所に指定する
「停止する時刻は一つの値として持ち、止めるかどうかの判定はその値との比較だけにしてください」
3. やらないことを添える
「タイマーを二つ以上作らないでください。フラグの追加も不要です。
画面を開き直しても、既に予定がある場合は延長しないでください」返ってきたのは、次のような形でした。
// After: 停止時刻を一つの値で持ち、判定は一箇所だけ
import Foundation
import Combine
@MainActor
final class SleepTimer: ObservableObject {
@Published private(set) var isPlaying = false
private var stopAt: Date?
private var ticker: AnyCancellable?
func start(minutes: Int) {
// 既に予定があれば延長しない(開き直しの二重起動を防ぐ)
guard stopAt == nil else { return }
stopAt = Date().addingTimeInterval(TimeInterval(minutes * 60))
isPlaying = true
ticker = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.sink { [weak self] now in self?.check(now) }
}
func stop() {
isPlaying = false
stopAt = nil
ticker?.cancel()
ticker = nil
}
private func check(_ now: Date) {
guard let stopAt, now >= stopAt else { return }
stop()
}
}私がこの形を好む理由は、stopAt という一つの日付だけを見れば、いま何が起きるかが分かることです。止まらなければ stopAt が空か、check が呼ばれていないかの二つしか疑う場所がありません。前の三層構造と比べて、次に壊れたときの自分の負担が、はるかに軽いのです。
往復は、復元が一度、言い換えが一度。復元そのものはクレジットを使いませんでしたから、消えたのは一往復ぶんだけでした。
二つの道を並べてみます
同じ不具合を、同じ夜に、両方の道で直した結果を並べます。数字は私の手元の記録で、プロジェクトや不具合の種類によって当然変わります。
| 見る場所 | 続きで直す | 復元して言い換える |
|---|---|---|
| 修正依頼の往復 | 3 回(うち直ったのは 3 回目) | 1 回(復元は別・クレジット消費なし) |
| 止める仕組みの数 | 3 つが並走 | 1 つ |
| 本当の原因への言及 | なし(症状を塞ぐ修正が重なる) | あり(二重起動を guard で防ぐ) |
| 次に壊れたときの切り分け | 3 経路のどれかを追う | stopAt と check の 2 箇所だけ |
| 失う作業 | なし | 戻した版より後の変更(今回はゼロ) |
| 自分の理解 | 何が直ったのか説明できない | 頼み方の失敗が言葉になった |
表にすると復元の道が一方的に勝っているように見えますが、そうとも言い切れません。続きで直す道が向いている場面も、はっきりあります。
一つは、エラーメッセージが具体的で、一往復ごとに確実に前へ進んでいるときです。ビルドエラーの行番号が変わり、数が減っていくような修正は、続きで頼むほうが速いのです。もう一つは、不具合が出た版より後に、別の機能をたくさん積んでしまっているときです。復元はその作業も一緒に巻き戻しますから、先に積んだ機能を書き出しておくか、復元をあきらめて症状を狭く言い直すかを選ぶことになります。
私が引いた線
対比だけで終わらせたくないので、両方を残したまま、どう使い分けているかを書きます。
二度目までは続きで、三度目は戻って言い換えます。 理由は単純で、二度の失敗があれば、頼み方のどこがまずかったかを言葉にできるだけの材料が揃うからです。一度目で戻るのは早すぎ、材料がありません。四度目まで待つと、コードに積もった層が厚くなり、戻す先を選ぶのが難しくなります。
そして戻ったあとの一回は、症状を繰り返さないことにしています。三度の失敗から「あるべき状態」「決める場所」「やらないこと」を一つずつ拾って、一つの文面にまとめ直すのです。個人開発では隣に相談できる人がいませんから、この言い換えの作業が、そのまま自分の理解の棚卸しになります。
この線を引いてから、修正依頼の残高を見て胃が重くなることは、ほとんどなくなりました。同じ夜に二つの道を歩いてみたことが、いまも判断の手触りとして残っております。
次の一歩
もし手元に「三度頼んでも直らない」不具合があるなら、まず Restore を開いて、動いていた版のプレビューを一度だけ眺めていただければと思います。戻さなくて構いません。動いていた画面を見ると、いまの症状のどこが「あるべき状態」から外れているかが、言葉になりやすいのです。
戻すか戻さないかを決めるのは、そのあとで十分です。
なお、修正を AI に任せる範囲と、判断を自分の側に残す範囲の線引きは、Rork が iPhone と Android の二つのネイティブアプリを出すようになってから、もう一段ややこしくなりました。そちらはRork が二つのネイティブアプリに分かれたあと、AI の判断を残す側と寄せる側の線引きで、私の実際の切り分けを書いております。