@skyf0xx/hedgehog 6.2.3 → 6.2.4
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/package.json +1 -1
- package/src/db/rebuild.mjs +123 -51
- package/src/hosts/gemini/gemini-extension.json +1 -1
package/package.json
CHANGED
package/src/db/rebuild.mjs
CHANGED
|
@@ -218,26 +218,32 @@ async function replayIntents(db, intentsDir) {
|
|
|
218
218
|
return count;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
// Every commit subject in history mapped to
|
|
222
|
-
// one `git log` call rather than one per task. Membership
|
|
223
|
-
// "did this task ever run"; position also answers "did it
|
|
224
|
-
// the thing it depends on", which is what a `once: true`
|
|
225
|
-
// since its commit subject is a constant that one historical
|
|
226
|
-
// would otherwise satisfy forever.
|
|
221
|
+
// Every commit subject in history mapped to EVERY position it occurs at,
|
|
222
|
+
// newest first — one `git log` call rather than one per task. Membership
|
|
223
|
+
// alone answers "did this task ever run"; position also answers "did it
|
|
224
|
+
// run *after* the thing it depends on", which is what a `once: true`
|
|
225
|
+
// task needs, since its commit subject is a constant that one historical
|
|
226
|
+
// occurrence would otherwise satisfy forever. Keeping every occurrence
|
|
227
|
+
// (not just the newest) is what lets markCompletedTasks tell two tasks
|
|
228
|
+
// that share a constant commit_message apart on a linear-chain core: one
|
|
229
|
+
// real commit per intent that actually ran the layer, each position
|
|
230
|
+
// claimable by at most one task. `--topo-order` so position is a
|
|
227
231
|
// property of the history's shape rather than of commit timestamps.
|
|
228
232
|
function loadCommitSubjects() {
|
|
229
233
|
const output = execSync('git log --topo-order --format=%H%x00%s', { encoding: 'utf8' });
|
|
230
|
-
const
|
|
234
|
+
const positions = new Map();
|
|
231
235
|
let position = 0;
|
|
232
236
|
for (const line of output.split('\n')) {
|
|
233
237
|
if (!line) continue;
|
|
234
238
|
const [, subject] = line.split('\0');
|
|
235
239
|
if (subject === undefined) continue;
|
|
236
|
-
// Newest first, so
|
|
237
|
-
|
|
240
|
+
// Newest first, so pushing in read order keeps each subject's array
|
|
241
|
+
// newest-to-oldest.
|
|
242
|
+
if (!positions.has(subject)) positions.set(subject, []);
|
|
243
|
+
positions.get(subject).push(position);
|
|
238
244
|
position++;
|
|
239
245
|
}
|
|
240
|
-
return
|
|
246
|
+
return positions;
|
|
241
247
|
}
|
|
242
248
|
|
|
243
249
|
// A task is complete iff some commit's subject exactly matches its
|
|
@@ -246,25 +252,65 @@ function loadCommitSubjects() {
|
|
|
246
252
|
// there's no verify_command to re-run and no working tree diff to check,
|
|
247
253
|
// only the historical fact that the commit already happened.
|
|
248
254
|
//
|
|
249
|
-
// A
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
255
|
+
// A task's `commit_message` is unique to it exactly when the layer's
|
|
256
|
+
// `commit` template interpolates `{module}` — the module-axis case,
|
|
257
|
+
// where each intent's copy of the layer produces a distinct string. A
|
|
258
|
+
// `once: true` layer has no module to substitute (core.mjs's
|
|
259
|
+
// validateCore rejects one that names {module}), so its single task's
|
|
260
|
+
// commit subject is a constant by construction — and, since a core
|
|
261
|
+
// compiles at most one once-task per once-layer, that task is the only
|
|
262
|
+
// one carrying its subject. A linear-chain core (authored, adopted) has
|
|
263
|
+
// no module axis at all: every layer's `commit` is a fixed string in
|
|
264
|
+
// core.yaml with no `{module}` token, so every intent that walks the
|
|
265
|
+
// chain compiles a per-layer task carrying that same constant — one task
|
|
266
|
+
// per intent, all sharing one subject.
|
|
261
267
|
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
+
// A commit_message shared by more than one task (grouped below into
|
|
269
|
+
// `ambiguousTasks`) means membership in commitSubjects can't tell those
|
|
270
|
+
// tasks apart: every commit ever made with that subject is a candidate
|
|
271
|
+
// match for every one of them. Two conditions resolve it:
|
|
272
|
+
//
|
|
273
|
+
// 1. Ordering — a task's own matching commit must sit *above* (be
|
|
274
|
+
// newer than) every one of its own prerequisites' matching
|
|
275
|
+
// commits, the same way a once-task has always required its commit
|
|
276
|
+
// to postdate the module it deploys. Without this, the first
|
|
277
|
+
// intent's commit for a layer would satisfy every later intent's
|
|
278
|
+
// task of that same layer forever, re-closing the layer on a fresh
|
|
279
|
+
// clone regardless of what that later intent's own chain has
|
|
280
|
+
// actually done.
|
|
281
|
+
// 2. Consumption — a group of N tasks sharing a subject can credit at
|
|
282
|
+
// most as many of them complete as there are actual commits with
|
|
283
|
+
// that subject in history, each commit backing at most one task.
|
|
284
|
+
// Ordering alone doesn't catch a head-of-chain task: with no
|
|
285
|
+
// prerequisite of its own, "ran after its prerequisites" is
|
|
286
|
+
// vacuously true regardless of which commit it points at, which is
|
|
287
|
+
// exactly the shape of the bug — a fresh intent's first layer,
|
|
288
|
+
// sharing a constant commit_message with an already-built intent's
|
|
289
|
+
// completed first layer, has nothing to check position against.
|
|
290
|
+
// Consumption is what a head-of-chain task actually needs: once
|
|
291
|
+
// every real commit for that subject is claimed by other tasks,
|
|
292
|
+
// none is left for it to point at.
|
|
293
|
+
//
|
|
294
|
+
// Both are walked together, per group, in a single deterministic pass
|
|
295
|
+
// ordered by task id: earlier-sorted tasks get first claim on the
|
|
296
|
+
// oldest still-unclaimed matching commit that satisfies the ordering
|
|
297
|
+
// condition against whatever their own prerequisites already claimed.
|
|
298
|
+
// A task that finds no claimable commit is left incomplete — the safe
|
|
299
|
+
// direction, matching every other cross-cutting-layer default in this
|
|
300
|
+
// engine (re-running an idempotent step beats silently skipping one).
|
|
301
|
+
// The whole thing is walked to a fixpoint, since one ambiguous task's
|
|
302
|
+
// claim can be the prerequisite another ambiguous task needs before it
|
|
303
|
+
// can claim its own (a once-layer behind another once-layer, or one
|
|
304
|
+
// linear-chain layer behind the one before it in the same intent).
|
|
305
|
+
//
|
|
306
|
+
// A task with a commit_message unique to it (the ordinary module-axis
|
|
307
|
+
// case) skips all of this: it is marked complete directly from
|
|
308
|
+
// commitSubjects membership, with no ordering or consumption check.
|
|
309
|
+
// That's necessary, not just cheaper — a task that completed without
|
|
310
|
+
// touching any file leaves no commit at all (verifyTask writes none),
|
|
311
|
+
// and applying either check there would cascade that gap through the
|
|
312
|
+
// whole chain and reset already-built modules that have no ambiguity to
|
|
313
|
+
// resolve in the first place.
|
|
268
314
|
function markCompletedTasks(db, commitSubjects) {
|
|
269
315
|
const tasks = db.prepare('SELECT id, module, commit_message FROM tasks').all();
|
|
270
316
|
const prerequisites = new Map(tasks.map((t) => [t.id, []]));
|
|
@@ -272,39 +318,64 @@ function markCompletedTasks(db, commitSubjects) {
|
|
|
272
318
|
prerequisites.get(d.task_id)?.push(d.depends_on_task_id);
|
|
273
319
|
}
|
|
274
320
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
);
|
|
321
|
+
const messageCounts = new Map();
|
|
322
|
+
for (const task of tasks) {
|
|
323
|
+
messageCounts.set(task.commit_message, (messageCounts.get(task.commit_message) ?? 0) + 1);
|
|
324
|
+
}
|
|
325
|
+
const isAmbiguous = (task) => messageCounts.get(task.commit_message) > 1;
|
|
280
326
|
|
|
327
|
+
// Position of each unambiguous task's one matching commit; undefined
|
|
328
|
+
// means the task never committed. An ambiguous task's position is
|
|
329
|
+
// resolved separately below, since "the" matching commit for its
|
|
330
|
+
// subject isn't decided until a claim succeeds.
|
|
331
|
+
const positionOf = new Map();
|
|
281
332
|
const complete = new Set();
|
|
282
|
-
const
|
|
333
|
+
const ambiguousTasks = [];
|
|
283
334
|
for (const task of tasks) {
|
|
284
|
-
if (task.module === CORE_MODULE) {
|
|
285
|
-
|
|
335
|
+
if (task.module === CORE_MODULE || isAmbiguous(task)) {
|
|
336
|
+
ambiguousTasks.push(task);
|
|
286
337
|
continue;
|
|
287
338
|
}
|
|
288
|
-
|
|
339
|
+
const position = (commitSubjects.get(task.commit_message) ?? [])[0];
|
|
340
|
+
positionOf.set(task.id, position);
|
|
341
|
+
if (position !== undefined) complete.add(task.id);
|
|
289
342
|
}
|
|
343
|
+
// Deterministic claim order within a shared subject: sorted by task id.
|
|
344
|
+
ambiguousTasks.sort((a, b) => a.id.localeCompare(b.id));
|
|
345
|
+
|
|
346
|
+
// Every commit position still unclaimed, per subject — shrinks as
|
|
347
|
+
// ambiguous tasks below claim one each.
|
|
348
|
+
const available = new Map(
|
|
349
|
+
[...commitSubjects].map(([subject, positions]) => [subject, [...positions]]),
|
|
350
|
+
);
|
|
290
351
|
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
const ranAfterPrerequisites = (task) =>
|
|
294
|
-
|
|
295
|
-
return prerequisites.get(task.id).every((id) => {
|
|
352
|
+
// A task ran after a prerequisite when its own claimed position is
|
|
353
|
+
// strictly smaller (newer) than the prerequisite's.
|
|
354
|
+
const ranAfterPrerequisites = (task, position) =>
|
|
355
|
+
prerequisites.get(task.id).every((id) => {
|
|
296
356
|
const prereq = positionOf.get(id);
|
|
297
|
-
return prereq === undefined ||
|
|
357
|
+
return prereq === undefined || position < prereq;
|
|
298
358
|
});
|
|
299
|
-
};
|
|
300
359
|
|
|
301
360
|
let changed = true;
|
|
302
361
|
while (changed) {
|
|
303
362
|
changed = false;
|
|
304
|
-
for (const task of
|
|
305
|
-
if (complete.has(task.id) || positionOf.
|
|
363
|
+
for (const task of ambiguousTasks) {
|
|
364
|
+
if (complete.has(task.id) || positionOf.has(task.id)) continue;
|
|
306
365
|
if (!prerequisites.get(task.id).every((id) => complete.has(id))) continue;
|
|
307
|
-
|
|
366
|
+
|
|
367
|
+
const slots = available.get(task.commit_message) ?? [];
|
|
368
|
+
// Oldest-first, so a task claims the least-recent commit that
|
|
369
|
+
// still satisfies its ordering condition — leaving newer slots
|
|
370
|
+
// free for whichever task in the group depends on this one.
|
|
371
|
+
const slotIndex = [...slots]
|
|
372
|
+
.map((position, i) => [position, i])
|
|
373
|
+
.sort((a, b) => b[0] - a[0])
|
|
374
|
+
.find(([position]) => ranAfterPrerequisites(task, position))?.[1];
|
|
375
|
+
if (slotIndex === undefined) continue;
|
|
376
|
+
|
|
377
|
+
const [position] = slots.splice(slotIndex, 1);
|
|
378
|
+
positionOf.set(task.id, position);
|
|
308
379
|
complete.add(task.id);
|
|
309
380
|
changed = true;
|
|
310
381
|
}
|
|
@@ -313,13 +384,14 @@ function markCompletedTasks(db, commitSubjects) {
|
|
|
313
384
|
const setComplete = db.prepare("UPDATE tasks SET status = 'complete' WHERE id = ?");
|
|
314
385
|
for (const id of complete) setComplete.run(id);
|
|
315
386
|
|
|
316
|
-
// A
|
|
317
|
-
//
|
|
318
|
-
// Reconcile it back rather than leaving the stale status
|
|
387
|
+
// A task on the ambiguous path can be marked complete by the loop
|
|
388
|
+
// above and then fail to claim a slot on a later pass of the fixpoint
|
|
389
|
+
// walk. Reconcile it back rather than leaving the stale status
|
|
390
|
+
// untouched.
|
|
319
391
|
const reopen = db.prepare(
|
|
320
392
|
"UPDATE tasks SET status = 'planned' WHERE id = ? AND status = 'complete'",
|
|
321
393
|
);
|
|
322
|
-
for (const task of
|
|
394
|
+
for (const task of ambiguousTasks) {
|
|
323
395
|
if (!complete.has(task.id)) reopen.run(task.id);
|
|
324
396
|
}
|
|
325
397
|
|