@enderfga/claw-orchestrator 6.5.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/mcp-server.js +5 -3
- package/dist/bin/mcp-server.js.map +1 -1
- package/dist/src/autoloop/dispatcher.d.ts +37 -3
- package/dist/src/autoloop/dispatcher.js +171 -30
- package/dist/src/autoloop/dispatcher.js.map +1 -1
- package/dist/src/autoloop/messages.d.ts +19 -1
- package/dist/src/autoloop/messages.js +4 -0
- package/dist/src/autoloop/messages.js.map +1 -1
- package/dist/src/autoloop/runner.d.ts +57 -2
- package/dist/src/autoloop/runner.js +260 -26
- package/dist/src/autoloop/runner.js.map +1 -1
- package/dist/src/autoloop/types.d.ts +62 -2
- package/dist/src/autoloop/types.js +63 -0
- package/dist/src/autoloop/types.js.map +1 -1
- package/dist/src/embedded-server.d.ts +8 -0
- package/dist/src/embedded-server.js +65 -4
- package/dist/src/embedded-server.js.map +1 -1
- package/dist/src/index.d.ts +9 -7
- package/dist/src/index.js +120 -80
- package/dist/src/index.js.map +1 -1
- package/dist/src/kernel/engine.js +19 -14
- package/dist/src/kernel/engine.js.map +1 -1
- package/dist/src/session-manager.d.ts +6 -0
- package/dist/src/session-manager.js +246 -13
- package/dist/src/session-manager.js.map +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/SKILL.md +1 -1
- package/skills/references/autoloop.md +34 -3
- package/skills/references/multi-engine.md +1 -1
- package/skills/references/tools.md +23 -2
|
@@ -134,12 +134,122 @@ import { isAgyConversationId } from './agy-conversation.js';
|
|
|
134
134
|
import { Council } from './council.js';
|
|
135
135
|
import { AutoloopRunner } from './autoloop/runner.js';
|
|
136
136
|
import { ClaudeAgentDispatcher } from './autoloop/dispatcher.js';
|
|
137
|
-
import { DEFAULT_PUSH_POLICY } from './autoloop/types.js';
|
|
137
|
+
import { DEFAULT_PUSH_POLICY, DEFAULT_SEND_TIMEOUT_MS, validateAutoloopTimeoutConfig } from './autoloop/types.js';
|
|
138
138
|
import { Msg as AutoloopMsg } from './autoloop/messages.js';
|
|
139
139
|
import { appendPushLog, notifyUserFallbackChain } from './autoloop/notify.js';
|
|
140
140
|
import { UltraappManager } from './ultraapp/manager.js';
|
|
141
141
|
import { UltraappStore, defaultStoreRoot } from './ultraapp/store.js';
|
|
142
142
|
import { PERSIST_DISK_TTL_MS, DEBOUNCED_SAVE_MS, CLEANUP_INTERVAL_MS, TURN_TIMEOUT_MS, GREP_HISTORY_FETCH, ULTRAPLAN_TIMEOUT_MS, STOP_SIGKILL_DELAY_MS, SESSION_EVENT, DEFAULT_HISTORY_LIMIT, } from './constants.js';
|
|
143
|
+
function isSendTimeoutPayload(value) {
|
|
144
|
+
if (typeof value !== 'object' || value === null)
|
|
145
|
+
return false;
|
|
146
|
+
const pending = value;
|
|
147
|
+
return (pending.status === 'awaiting_resume' &&
|
|
148
|
+
typeof pending.dispatch_id === 'string' &&
|
|
149
|
+
pending.dispatch_id.length > 0 &&
|
|
150
|
+
(pending.agent === 'planner' || pending.agent === 'coder' || pending.agent === 'reviewer') &&
|
|
151
|
+
typeof pending.message_id === 'string' &&
|
|
152
|
+
typeof pending.message_type === 'string' &&
|
|
153
|
+
typeof pending.iter === 'number' &&
|
|
154
|
+
typeof pending.timeout_ms === 'number' &&
|
|
155
|
+
typeof pending.error === 'string');
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Replay only timeout-resume audit rows. The original run spec remains the
|
|
159
|
+
* immutable starting point; each coherent append advances the effective value.
|
|
160
|
+
* Failing closed on a malformed migration prevents a corrupt audit tail from
|
|
161
|
+
* accidentally authorizing a timeout decrease after process reconstruction.
|
|
162
|
+
*/
|
|
163
|
+
function readStoredAutoloopResumeContext(workspace, runId, originalSendTimeoutMs) {
|
|
164
|
+
validateAutoloopTimeoutConfig({ sendTimeoutMs: originalSendTimeoutMs });
|
|
165
|
+
let effectiveSendTimeoutMs = originalSendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS;
|
|
166
|
+
let pendingDispatch = null;
|
|
167
|
+
const auditPath = path.join(workspace, 'tasks', runId, 'decisions.jsonl');
|
|
168
|
+
if (!fs.existsSync(auditPath))
|
|
169
|
+
return { effectiveSendTimeoutMs, pendingDispatch };
|
|
170
|
+
const lines = fs.readFileSync(auditPath, 'utf8').split('\n');
|
|
171
|
+
for (const line of lines) {
|
|
172
|
+
if (!line.trim())
|
|
173
|
+
continue;
|
|
174
|
+
let row;
|
|
175
|
+
try {
|
|
176
|
+
row = JSON.parse(line);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
throw new Error(`Cannot safely resume Autoloop '${runId}': decisions.jsonl contains malformed JSON`);
|
|
180
|
+
}
|
|
181
|
+
if (row.kind === 'send_timeout' && isSendTimeoutPayload(row.payload)) {
|
|
182
|
+
pendingDispatch = row.payload;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (row.kind === 'terminate') {
|
|
186
|
+
pendingDispatch = null;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (row.kind !== 'timeout_migration' || row.runId !== runId || row.field !== 'sendTimeoutMs')
|
|
190
|
+
continue;
|
|
191
|
+
const oldValue = row.oldValue;
|
|
192
|
+
const newValue = row.newValue;
|
|
193
|
+
try {
|
|
194
|
+
validateAutoloopTimeoutConfig({ sendTimeoutMs: oldValue });
|
|
195
|
+
validateAutoloopTimeoutConfig({ sendTimeoutMs: newValue });
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
throw new Error(`Cannot safely resume Autoloop '${runId}': timeout migration audit is invalid`);
|
|
199
|
+
}
|
|
200
|
+
if (oldValue !== effectiveSendTimeoutMs || newValue <= oldValue) {
|
|
201
|
+
throw new Error(`Cannot safely resume Autoloop '${runId}': timeout migration audit chain is inconsistent`);
|
|
202
|
+
}
|
|
203
|
+
effectiveSendTimeoutMs = newValue;
|
|
204
|
+
if (row.pendingDispatchId === pendingDispatch?.dispatch_id)
|
|
205
|
+
pendingDispatch = null;
|
|
206
|
+
}
|
|
207
|
+
return { effectiveSendTimeoutMs, pendingDispatch };
|
|
208
|
+
}
|
|
209
|
+
function validateSendTimeoutIncrease(value, current) {
|
|
210
|
+
validateAutoloopTimeoutConfig({ sendTimeoutMs: value });
|
|
211
|
+
if (value <= current) {
|
|
212
|
+
throw new Error(`sendTimeoutMs must be strictly greater than the current effective value ${current}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function encodeSendTimeoutMigration(migration) {
|
|
216
|
+
const timestamp = new Date().toISOString();
|
|
217
|
+
const record = {
|
|
218
|
+
ts: timestamp,
|
|
219
|
+
kind: 'timeout_migration',
|
|
220
|
+
actor: 'operator',
|
|
221
|
+
timestamp,
|
|
222
|
+
...migration,
|
|
223
|
+
};
|
|
224
|
+
return `${JSON.stringify(record)}\n`;
|
|
225
|
+
}
|
|
226
|
+
function appendSendTimeoutMigration(workspace, migration) {
|
|
227
|
+
const ledgerDir = path.join(workspace, 'tasks', migration.runId);
|
|
228
|
+
fs.mkdirSync(ledgerDir, { recursive: true });
|
|
229
|
+
fs.appendFileSync(path.join(ledgerDir, 'decisions.jsonl'), encodeSendTimeoutMigration(migration));
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Hold an append-capable descriptor before a stored run is restarted. Opening
|
|
233
|
+
* it is the fallible permission/path part of the append; doing that first keeps
|
|
234
|
+
* an unavailable audit ledger from starting agents or changing kernel state.
|
|
235
|
+
* The descriptor stays open across startup so the eventual commit cannot be
|
|
236
|
+
* redirected by a path replacement.
|
|
237
|
+
*/
|
|
238
|
+
function prepareSendTimeoutMigrationAppend(workspace, migration) {
|
|
239
|
+
const ledgerDir = path.join(workspace, 'tasks', migration.runId);
|
|
240
|
+
fs.mkdirSync(ledgerDir, { recursive: true });
|
|
241
|
+
return {
|
|
242
|
+
fd: fs.openSync(path.join(ledgerDir, 'decisions.jsonl'), 'a'),
|
|
243
|
+
line: encodeSendTimeoutMigration(migration),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function commitPreparedSendTimeoutMigration(prepared) {
|
|
247
|
+
const expectedBytes = Buffer.byteLength(prepared.line);
|
|
248
|
+
const writtenBytes = fs.writeSync(prepared.fd, prepared.line, null, 'utf8');
|
|
249
|
+
if (writtenBytes !== expectedBytes) {
|
|
250
|
+
throw new Error(`Could not append the complete sendTimeoutMs migration audit record`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
143
253
|
function isStringRecord(value) {
|
|
144
254
|
return (typeof value === 'object' &&
|
|
145
255
|
value !== null &&
|
|
@@ -2460,6 +2570,7 @@ export class SessionManager {
|
|
|
2460
2570
|
reviewerModel: opts.reviewerModel,
|
|
2461
2571
|
reviewerCustomEngine: opts.reviewerCustomEngine,
|
|
2462
2572
|
sendTimeoutMs: opts.sendTimeoutMs,
|
|
2573
|
+
suppressFailedStartAudit: opts._resumeTimeoutMigration,
|
|
2463
2574
|
logger: this.logger,
|
|
2464
2575
|
pushPolicyRef: pushPolicy,
|
|
2465
2576
|
onSpawnSubagents: async (args) => {
|
|
@@ -2501,14 +2612,24 @@ export class SessionManager {
|
|
|
2501
2612
|
this.logger.info?.(`[autoloop/${runId}] push level=${level} channel=${channel}→${result.channel_used} summary="${summary.slice(0, 80)}"`);
|
|
2502
2613
|
},
|
|
2503
2614
|
dispatcher,
|
|
2615
|
+
sendTimeoutMs: opts.sendTimeoutMs,
|
|
2616
|
+
activityLeaseMs: opts.activityLeaseMs,
|
|
2617
|
+
autoloopHardTimeoutMs: opts.autoloopHardTimeoutMs,
|
|
2504
2618
|
});
|
|
2505
2619
|
runnerRef = runner;
|
|
2506
2620
|
try {
|
|
2507
2621
|
await runner.start();
|
|
2622
|
+
// A reconstructed migration becomes externally visible only after its
|
|
2623
|
+
// Planner is ready and its prepared audit append has committed. Keeping
|
|
2624
|
+
// this inside the startup try makes an append failure follow the same
|
|
2625
|
+
// cleanup path as any other failed resume.
|
|
2626
|
+
opts._commitTimeoutMigration?.();
|
|
2508
2627
|
}
|
|
2509
2628
|
catch (err) {
|
|
2510
2629
|
try {
|
|
2511
|
-
await dispatcher.shutdown('start-failed', {
|
|
2630
|
+
await dispatcher.shutdown('start-failed', {
|
|
2631
|
+
purge: !opts._resumeTimeoutMigration,
|
|
2632
|
+
});
|
|
2512
2633
|
}
|
|
2513
2634
|
catch (cleanupErr) {
|
|
2514
2635
|
this.logger.warn?.(`[autoloop/${runId}] cleanup after failed start failed: ${cleanupErr.message}`);
|
|
@@ -2532,6 +2653,11 @@ export class SessionManager {
|
|
|
2532
2653
|
*/
|
|
2533
2654
|
async autoloopStart(opts) {
|
|
2534
2655
|
// Fail before the run directory exists, so a rejected start leaves nothing.
|
|
2656
|
+
validateAutoloopTimeoutConfig({
|
|
2657
|
+
sendTimeoutMs: opts.sendTimeoutMs,
|
|
2658
|
+
activityLeaseMs: opts.activityLeaseMs,
|
|
2659
|
+
autoloopHardTimeoutMs: opts.autoloopHardTimeoutMs,
|
|
2660
|
+
});
|
|
2535
2661
|
validateAutoloopRole('planner', opts.plannerEngine, opts.plannerCustomEngine);
|
|
2536
2662
|
validateAutoloopRole('coder', opts.coderEngine, opts.coderCustomEngine);
|
|
2537
2663
|
validateAutoloopRole('reviewer', opts.reviewerEngine, opts.reviewerCustomEngine);
|
|
@@ -2701,9 +2827,48 @@ export class SessionManager {
|
|
|
2701
2827
|
return { runId, rolesNeedingCustomEngine: roles };
|
|
2702
2828
|
}
|
|
2703
2829
|
async autoloopResume(runId, opts = {}) {
|
|
2830
|
+
const hasTimeoutIncrease = opts.sendTimeoutMs !== undefined;
|
|
2831
|
+
if (!hasTimeoutIncrease && opts.pendingDispatchId !== undefined) {
|
|
2832
|
+
throw new Error('pendingDispatchId requires a sendTimeoutMs increase');
|
|
2833
|
+
}
|
|
2834
|
+
if (opts.pendingDispatchId !== undefined &&
|
|
2835
|
+
(typeof opts.pendingDispatchId !== 'string' || opts.pendingDispatchId.length === 0)) {
|
|
2836
|
+
throw new Error('pendingDispatchId must be a non-empty string');
|
|
2837
|
+
}
|
|
2704
2838
|
const live = this.kernel.handle(runId, LEGACY_NODE);
|
|
2705
|
-
if (live)
|
|
2839
|
+
if (live) {
|
|
2840
|
+
if (!hasTimeoutIncrease)
|
|
2841
|
+
return live.runner.state;
|
|
2842
|
+
const pending = live.runner.state.pending_dispatch;
|
|
2843
|
+
if (live.runner.state.status !== 'paused' || !pending) {
|
|
2844
|
+
throw new Error(`Autoloop run '${runId}' is not awaiting a recoverable send timeout`);
|
|
2845
|
+
}
|
|
2846
|
+
if (opts.pendingDispatchId === undefined) {
|
|
2847
|
+
throw new Error(`pendingDispatchId is required to resume timed-out dispatch '${pending.dispatch_id}'`);
|
|
2848
|
+
}
|
|
2849
|
+
const current = live.dispatcher.effectiveSendTimeoutMs;
|
|
2850
|
+
validateSendTimeoutIncrease(opts.sendTimeoutMs, current);
|
|
2851
|
+
if (opts.pendingDispatchId !== undefined && opts.pendingDispatchId !== pending.dispatch_id) {
|
|
2852
|
+
throw new Error(`pending dispatch '${opts.pendingDispatchId}' does not match current dispatch '${pending.dispatch_id}'`);
|
|
2853
|
+
}
|
|
2854
|
+
// The checks above and the three operations below are synchronous. Audit
|
|
2855
|
+
// first, so an append failure leaves both the dispatcher and runner
|
|
2856
|
+
// untouched; after that no asynchronous work can swap the pending id.
|
|
2857
|
+
appendSendTimeoutMigration(live.runner.state.workspace, {
|
|
2858
|
+
runId,
|
|
2859
|
+
field: 'sendTimeoutMs',
|
|
2860
|
+
oldValue: current,
|
|
2861
|
+
newValue: opts.sendTimeoutMs,
|
|
2862
|
+
reason: 'recoverable_send_timeout_resume',
|
|
2863
|
+
pendingDispatchId: pending.dispatch_id,
|
|
2864
|
+
});
|
|
2865
|
+
live.dispatcher.increaseSendTimeoutMs(opts.sendTimeoutMs);
|
|
2866
|
+
if (!live.runner.resumeTimedOutDispatch(pending.dispatch_id)) {
|
|
2867
|
+
throw new Error(`Autoloop run '${runId}' pending dispatch changed during resume`);
|
|
2868
|
+
}
|
|
2869
|
+
this._autoloopPublishers.get(runId)?.();
|
|
2706
2870
|
return live.runner.state;
|
|
2871
|
+
}
|
|
2707
2872
|
const record = loadRun(runId);
|
|
2708
2873
|
if (!record || record.workflow !== 'autoloop')
|
|
2709
2874
|
throw new Error(`Autoloop run '${runId}' not found`);
|
|
@@ -2718,18 +2883,81 @@ export class SessionManager {
|
|
|
2718
2883
|
validateAutoloopRole('planner', config.plannerEngine, opts.plannerCustomEngine);
|
|
2719
2884
|
validateAutoloopRole('coder', config.coderEngine, opts.coderCustomEngine);
|
|
2720
2885
|
validateAutoloopRole('reviewer', config.reviewerEngine, opts.reviewerCustomEngine);
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
const
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2886
|
+
const workspace = typeof config.workspace === 'string' ? config.workspace : record.cwd;
|
|
2887
|
+
const storedContext = readStoredAutoloopResumeContext(workspace, runId, config.sendTimeoutMs);
|
|
2888
|
+
const nodeState = record.nodes[LEGACY_NODE]?.data?.state;
|
|
2889
|
+
const recordCarriesPending = nodeState
|
|
2890
|
+
? Object.prototype.hasOwnProperty.call(nodeState, 'pending_dispatch')
|
|
2891
|
+
: false;
|
|
2892
|
+
const pending = recordCarriesPending
|
|
2893
|
+
? isSendTimeoutPayload(nodeState?.pending_dispatch)
|
|
2894
|
+
? nodeState.pending_dispatch
|
|
2895
|
+
: null
|
|
2896
|
+
: storedContext.pendingDispatch;
|
|
2897
|
+
if (hasTimeoutIncrease && pending && opts.pendingDispatchId === undefined) {
|
|
2898
|
+
throw new Error(`pendingDispatchId is required to resume timed-out dispatch '${pending.dispatch_id}'`);
|
|
2899
|
+
}
|
|
2900
|
+
if (opts.pendingDispatchId !== undefined) {
|
|
2901
|
+
if (!pending) {
|
|
2902
|
+
throw new Error(`Autoloop run '${runId}' has no pending dispatch to match '${opts.pendingDispatchId}'`);
|
|
2903
|
+
}
|
|
2904
|
+
if (opts.pendingDispatchId !== pending.dispatch_id) {
|
|
2905
|
+
throw new Error(`pending dispatch '${opts.pendingDispatchId}' does not match stored dispatch '${pending.dispatch_id}'`);
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
const nextSendTimeoutMs = opts.sendTimeoutMs ?? storedContext.effectiveSendTimeoutMs;
|
|
2909
|
+
if (hasTimeoutIncrease)
|
|
2910
|
+
validateSendTimeoutIncrease(nextSendTimeoutMs, storedContext.effectiveSendTimeoutMs);
|
|
2911
|
+
const migration = hasTimeoutIncrease
|
|
2912
|
+
? {
|
|
2913
|
+
runId,
|
|
2914
|
+
field: 'sendTimeoutMs',
|
|
2915
|
+
oldValue: storedContext.effectiveSendTimeoutMs,
|
|
2916
|
+
newValue: nextSendTimeoutMs,
|
|
2917
|
+
reason: pending ? 'recoverable_send_timeout_resume' : 'stored_run_resume',
|
|
2918
|
+
...(pending ? { pendingDispatchId: pending.dispatch_id } : {}),
|
|
2919
|
+
}
|
|
2920
|
+
: undefined;
|
|
2921
|
+
const preparedMigration = migration ? prepareSendTimeoutMigrationAppend(workspace, migration) : undefined;
|
|
2922
|
+
let migrationCommitted = false;
|
|
2923
|
+
try {
|
|
2924
|
+
// Custom-engine configs are never persisted (they can carry secrets), so
|
|
2925
|
+
// a resume must be given them again by the caller.
|
|
2926
|
+
return await this._resumeAutoloopRun(runId, {
|
|
2927
|
+
...config,
|
|
2928
|
+
// Effective migrations are replayed from append-only audit rather
|
|
2929
|
+
// than written back into the immutable original spec.
|
|
2930
|
+
sendTimeoutMs: nextSendTimeoutMs,
|
|
2931
|
+
plannerCustomEngine: opts.plannerCustomEngine,
|
|
2932
|
+
coderCustomEngine: opts.coderCustomEngine,
|
|
2933
|
+
reviewerCustomEngine: opts.reviewerCustomEngine,
|
|
2934
|
+
}, {
|
|
2935
|
+
timeoutMigration: hasTimeoutIncrease,
|
|
2936
|
+
commitTimeoutMigration: preparedMigration
|
|
2937
|
+
? () => {
|
|
2938
|
+
if (migrationCommitted)
|
|
2939
|
+
return;
|
|
2940
|
+
commitPreparedSendTimeoutMigration(preparedMigration);
|
|
2941
|
+
migrationCommitted = true;
|
|
2942
|
+
}
|
|
2943
|
+
: undefined,
|
|
2944
|
+
});
|
|
2945
|
+
}
|
|
2946
|
+
finally {
|
|
2947
|
+
if (preparedMigration) {
|
|
2948
|
+
try {
|
|
2949
|
+
fs.closeSync(preparedMigration.fd);
|
|
2950
|
+
}
|
|
2951
|
+
catch (err) {
|
|
2952
|
+
// Descriptor cleanup cannot retroactively turn a committed append
|
|
2953
|
+
// and successful startup into a failed migration.
|
|
2954
|
+
this.logger.warn?.(`[autoloop/${runId}] failed to close migration audit: ${err.message}`);
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2730
2958
|
}
|
|
2731
2959
|
/** Re-attach a stored autoloop run: same run id, same spec, fresh engine. */
|
|
2732
|
-
async _resumeAutoloopRun(runId, config) {
|
|
2960
|
+
async _resumeAutoloopRun(runId, config, opts = {}) {
|
|
2733
2961
|
const tag = `${runId}:${randomUUID()}`;
|
|
2734
2962
|
const ready = new Promise((resolve, reject) => {
|
|
2735
2963
|
this._autoloopReady.set(tag, { resolve, reject });
|
|
@@ -2743,6 +2971,11 @@ export class SessionManager {
|
|
|
2743
2971
|
plannerCustomEngine: config.plannerCustomEngine,
|
|
2744
2972
|
coderCustomEngine: config.coderCustomEngine,
|
|
2745
2973
|
reviewerCustomEngine: config.reviewerCustomEngine,
|
|
2974
|
+
// Resume-only effective value. This travels in the in-memory bag so the
|
|
2975
|
+
// immutable spec continues to describe the run's original configuration.
|
|
2976
|
+
sendTimeoutMs: config.sendTimeoutMs,
|
|
2977
|
+
_resumeTimeoutMigration: opts.timeoutMigration || undefined,
|
|
2978
|
+
_commitTimeoutMigration: opts.commitTimeoutMigration,
|
|
2746
2979
|
};
|
|
2747
2980
|
try {
|
|
2748
2981
|
// `restart: true` because an autoloop resume means "bring the loop back
|