@adhdev/daemon-core 0.9.82-rc.385 → 0.9.82-rc.387
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/cli-adapter-types.d.ts +12 -0
- package/dist/cli-adapters/provider-cli-shared.d.ts +11 -0
- package/dist/git/git-worktree.d.ts +22 -0
- package/dist/index.js +193 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +193 -8
- package/dist/index.mjs.map +1 -1
- package/dist/providers/cli-provider-instance.d.ts +8 -0
- package/dist/providers/spec/fsm-driver.d.ts +10 -0
- package/package.json +2 -2
- package/src/cli-adapter-types.ts +12 -0
- package/src/cli-adapters/provider-cli-shared.ts +11 -0
- package/src/commands/med-family/mesh-crud.ts +11 -0
- package/src/commands/router.ts +49 -2
- package/src/git/git-worktree.ts +68 -0
- package/src/mesh/mesh-work-queue.ts +110 -2
- package/src/providers/cli-provider-instance.ts +43 -1
- package/src/providers/spec/cli-adapter.ts +6 -1
- package/src/providers/spec/fsm-driver.ts +12 -0
|
@@ -66,6 +66,7 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
66
66
|
private context;
|
|
67
67
|
private events;
|
|
68
68
|
private lastStatus;
|
|
69
|
+
private agentReadyEmitted;
|
|
69
70
|
private generatingStartedAt;
|
|
70
71
|
private settings;
|
|
71
72
|
private monitor;
|
|
@@ -220,6 +221,13 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
220
221
|
* the approval decision; the next real PTY frame refreshes visible status.
|
|
221
222
|
*/
|
|
222
223
|
private recheckAutoApproveSettled;
|
|
224
|
+
/**
|
|
225
|
+
* Emit the queue-claim agent:ready event at most once per session. Both the
|
|
226
|
+
* boot-time starting→idle one-shot and the fsmReadySeen re-arm call this; the
|
|
227
|
+
* agentReadyEmitted guard ensures the second caller is a no-op so a worker is
|
|
228
|
+
* never claimed twice and a queued task is never double-dispatched.
|
|
229
|
+
*/
|
|
230
|
+
private emitAgentReadyOnce;
|
|
223
231
|
private detectStatusTransition;
|
|
224
232
|
private pushEvent;
|
|
225
233
|
private flushEvents;
|
|
@@ -134,6 +134,7 @@ export interface ISpecDriver {
|
|
|
134
134
|
}> | null;
|
|
135
135
|
getLastBusyAt(): number;
|
|
136
136
|
hasIdleHoldPending(): boolean;
|
|
137
|
+
hasSeenReady(): boolean;
|
|
137
138
|
getCompletionIdleDebounceState(): {
|
|
138
139
|
active: boolean;
|
|
139
140
|
ageMs: number;
|
|
@@ -264,6 +265,15 @@ export declare class FsmDriver implements ISpecDriver {
|
|
|
264
265
|
* working without branching on driver type. */
|
|
265
266
|
getLastBusyAt(): number;
|
|
266
267
|
hasIdleHoldPending(): boolean;
|
|
268
|
+
/**
|
|
269
|
+
* True once the machine has reached its first non-initial idle state (the
|
|
270
|
+
* prompt is genuinely drawn — see maybeMarkReady). The cli-adapter surfaces
|
|
271
|
+
* this on its idle status so CliProviderInstance can re-arm the queue-claim
|
|
272
|
+
* agent:ready on the first genuine ready, independent of the boot-time
|
|
273
|
+
* starting→idle one-shot (which is consumed too early for specs whose
|
|
274
|
+
* initial state already reports idle).
|
|
275
|
+
*/
|
|
276
|
+
hasSeenReady(): boolean;
|
|
267
277
|
getCompletionIdleDebounceState(): {
|
|
268
278
|
active: boolean;
|
|
269
279
|
ageMs: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.387",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.387",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
package/src/cli-adapter-types.ts
CHANGED
|
@@ -28,6 +28,18 @@ export interface CliAdapterStatus {
|
|
|
28
28
|
providerSessionId?: string;
|
|
29
29
|
errorMessage?: string;
|
|
30
30
|
errorReason?: string;
|
|
31
|
+
/**
|
|
32
|
+
* FSM-spec adapters only: true once the driver has observed its first
|
|
33
|
+
* non-initial idle state (the prompt is genuinely drawn — see
|
|
34
|
+
* FsmDriver.maybeMarkReady / readySeenOnce). Used by CliProviderInstance to
|
|
35
|
+
* re-arm the queue-claim `agent:ready` event on the first genuine ready,
|
|
36
|
+
* independent of the boot-time starting→idle one-shot. That one-shot is
|
|
37
|
+
* consumed too early for providers whose INITIAL FSM state already reports
|
|
38
|
+
* status 'idle' (e.g. antigravity-cli), so without this re-arm the worker
|
|
39
|
+
* never claims its queued task and the coordinator relaunch-loops. Absent
|
|
40
|
+
* (undefined) for non-FSM adapters — they keep the boot one-shot behavior.
|
|
41
|
+
*/
|
|
42
|
+
fsmReadySeen?: boolean;
|
|
31
43
|
}
|
|
32
44
|
|
|
33
45
|
export interface AcpAdapterHandle {
|
|
@@ -60,6 +60,17 @@ export interface CliSessionStatus {
|
|
|
60
60
|
errorMessage?: string;
|
|
61
61
|
errorReason?: string;
|
|
62
62
|
providerSessionId?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Spec/FSM adapters only (SpecCliAdapter): true once the driver has observed
|
|
65
|
+
* its first non-initial idle state — the prompt is genuinely drawn. The
|
|
66
|
+
* legacy ProviderCliAdapter never sets it (undefined). CliProviderInstance
|
|
67
|
+
* uses it to re-arm the queue-claim agent:ready on the first genuine ready,
|
|
68
|
+
* independent of the boot-time starting→idle one-shot (which is consumed too
|
|
69
|
+
* early for specs whose initial state already reports status 'idle', e.g.
|
|
70
|
+
* antigravity-cli — without the re-arm the worker never claims its queued
|
|
71
|
+
* task and the coordinator relaunch-loops).
|
|
72
|
+
*/
|
|
73
|
+
fsmReadySeen?: boolean;
|
|
63
74
|
/**
|
|
64
75
|
* Timestamp (ms) of the most recent raw PTY output chunk. Advances on every
|
|
65
76
|
* byte the process emits, including tool/build output that produces no
|
|
@@ -422,6 +422,8 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
|
|
|
422
422
|
worktreeCleanupFallback: typeof worktreeCleanup?.fallback === 'string' ? worktreeCleanup.fallback : undefined,
|
|
423
423
|
forced: worktreeCleanup?.forced === true ? true : undefined,
|
|
424
424
|
forceFallbackReason: typeof worktreeCleanup?.reason === 'string' ? worktreeCleanup.reason : undefined,
|
|
425
|
+
branchRefDeleted: typeof worktreeCleanup?.branchRefDeleted === 'boolean' ? worktreeCleanup.branchRefDeleted : undefined,
|
|
426
|
+
branchRefReason: typeof worktreeCleanup?.branchRefReason === 'string' ? worktreeCleanup.branchRefReason : undefined,
|
|
425
427
|
},
|
|
426
428
|
});
|
|
427
429
|
} catch { /* ledger append is best-effort */ }
|
|
@@ -434,6 +436,14 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
|
|
|
434
436
|
? worktreeCleanup.residueWarning
|
|
435
437
|
: undefined;
|
|
436
438
|
|
|
439
|
+
// Surface a preserved-branch warning at the top level so callers see
|
|
440
|
+
// that the branch ref was intentionally NOT deleted (unmerged work is
|
|
441
|
+
// never silently dropped). When the branch ref WAS deleted, the nested
|
|
442
|
+
// worktreeCleanup.branchRefDeleted flag already records it.
|
|
443
|
+
const branchRefWarning = typeof worktreeCleanup?.branchRefWarning === 'string'
|
|
444
|
+
? worktreeCleanup.branchRefWarning
|
|
445
|
+
: undefined;
|
|
446
|
+
|
|
437
447
|
// Orphan guard: if the session cleanup still left any LIVE session skipped
|
|
438
448
|
// (e.g. a future skip reason, or a workspace-only session on a base node),
|
|
439
449
|
// surface it at the top level so the caller knows a manual mesh_cleanup_sessions
|
|
@@ -451,6 +461,7 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
|
|
|
451
461
|
success: true,
|
|
452
462
|
removed,
|
|
453
463
|
...(residueWarning ? { residueWarning } : {}),
|
|
464
|
+
...(branchRefWarning ? { branchRefWarning } : {}),
|
|
454
465
|
...(sessionCleanup ? { sessionCleanup } : {}),
|
|
455
466
|
...(worktreeCleanup ? { worktreeCleanup } : {}),
|
|
456
467
|
...(orphanedSessionsRemaining
|
package/src/commands/router.ts
CHANGED
|
@@ -829,7 +829,7 @@ export class DaemonCommandRouter {
|
|
|
829
829
|
node: any;
|
|
830
830
|
nodeId: string;
|
|
831
831
|
force?: boolean;
|
|
832
|
-
}): Promise<{ success: true; skipped?: boolean; removedPath?: string; repoRoot?: string; reason?: string; fallback?: string; forced?: boolean; convergence?: Record<string, unknown>; recovered?: boolean; residue?: boolean; residueWarning?: string; residueError?: string } | { success: false; code: string; error: string; recoveryHint: string; convergence?: Record<string, unknown> }> {
|
|
832
|
+
}): Promise<{ success: true; skipped?: boolean; removedPath?: string; repoRoot?: string; reason?: string; fallback?: string; forced?: boolean; convergence?: Record<string, unknown>; recovered?: boolean; residue?: boolean; residueWarning?: string; residueError?: string; branchRefDeleted?: boolean; branchRefReason?: string; branchRefForced?: boolean; branchRefWarning?: string } | { success: false; code: string; error: string; recoveryHint: string; convergence?: Record<string, unknown> }> {
|
|
833
833
|
const workspace = typeof args.node?.workspace === 'string' ? args.node.workspace.trim() : '';
|
|
834
834
|
if (!workspace) {
|
|
835
835
|
return {
|
|
@@ -929,19 +929,62 @@ export class DaemonCommandRouter {
|
|
|
929
929
|
};
|
|
930
930
|
}
|
|
931
931
|
|
|
932
|
+
// Always evaluate real merge convergence so we can decide whether the
|
|
933
|
+
// branch ref is safe to delete after removal — even on the force path,
|
|
934
|
+
// where `force_override` only authorizes the *worktree* removal and must
|
|
935
|
+
// NOT be taken as proof the branch is merged (that would risk work loss).
|
|
936
|
+
const mergeConvergence = await this.getWorktreeForceCleanupConvergence({ repoRoot, workspace, node: args.node });
|
|
932
937
|
const forceFallbackConvergence = args.force
|
|
933
938
|
? { allow: true, status: 'force_override', source: 'caller_force_flag' }
|
|
934
|
-
:
|
|
939
|
+
: mergeConvergence;
|
|
940
|
+
|
|
941
|
+
// After the worktree is removed, delete the branch ref iff the branch is
|
|
942
|
+
// fully merged into the default ref (no work loss). Otherwise preserve it
|
|
943
|
+
// and surface a warning. `mergeConvergence` (NOT the force override) is the
|
|
944
|
+
// authority on merged-ness.
|
|
945
|
+
const deleteBranchIfMerged = async () => {
|
|
946
|
+
const branch = String(args.node.worktreeBranch).trim();
|
|
947
|
+
const status = mergeConvergence.allow ? (mergeConvergence.status || '') : '';
|
|
948
|
+
const MERGED_STATUSES = new Set([
|
|
949
|
+
'merged_to_main', 'merged_pushed', 'merged_to_default_ref', 'cleanup_candidate',
|
|
950
|
+
]);
|
|
951
|
+
const PATCH_EQUIV_STATUS = 'patch_equivalent_to_default_ref';
|
|
952
|
+
if (!branch) {
|
|
953
|
+
return { branchRefDeleted: false, branchRefReason: 'empty_branch_name' };
|
|
954
|
+
}
|
|
955
|
+
if (!mergeConvergence.allow || (!MERGED_STATUSES.has(status) && status !== PATCH_EQUIV_STATUS)) {
|
|
956
|
+
return {
|
|
957
|
+
branchRefDeleted: false,
|
|
958
|
+
branchRefReason: `branch_not_merged_preserved: ${mergeConvergence.error || mergeConvergence.status || 'convergence_unverified'}`,
|
|
959
|
+
branchRefWarning: `Branch ref '${branch}' was preserved (not deleted) because it is not confirmed merged into the default ref — no work was lost. Merge it (or pass a verified branchConvergence final state) and re-run cleanup, or delete it manually after confirming.`,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
const { deleteBranchRef } = await import('../git/git-worktree.js');
|
|
963
|
+
// `-d` can detect a true fast-forward/merge; patch-equivalent landings
|
|
964
|
+
// (squash/cherry-pick) are invisible to `-d`, so allow the verified `-D`
|
|
965
|
+
// fallback only for the patch-equivalence status.
|
|
966
|
+
const res = await deleteBranchRef(repoRoot, branch, { safeDeleteOnly: status !== PATCH_EQUIV_STATUS });
|
|
967
|
+
return {
|
|
968
|
+
branchRefDeleted: res.deleted,
|
|
969
|
+
branchRefReason: res.reason,
|
|
970
|
+
...(res.forced ? { branchRefForced: true } : {}),
|
|
971
|
+
...(res.deleted ? {} : {
|
|
972
|
+
branchRefWarning: `Branch ref '${branch}' could not be deleted (${res.reason}); it was preserved so no work is lost.`,
|
|
973
|
+
}),
|
|
974
|
+
};
|
|
975
|
+
};
|
|
935
976
|
|
|
936
977
|
try {
|
|
937
978
|
const result = await removeWorktree(repoRoot, workspace, {
|
|
938
979
|
requireClean: !args.force,
|
|
939
980
|
allowSubmoduleForceFallback: forceFallbackConvergence.allow,
|
|
940
981
|
});
|
|
982
|
+
const branchOutcome = await deleteBranchIfMerged();
|
|
941
983
|
return {
|
|
942
984
|
success: true,
|
|
943
985
|
removedPath: result.removedPath,
|
|
944
986
|
repoRoot,
|
|
987
|
+
...branchOutcome,
|
|
945
988
|
...(result.fallback ? {
|
|
946
989
|
fallback: result.fallback,
|
|
947
990
|
forced: result.forced,
|
|
@@ -969,10 +1012,12 @@ export class DaemonCommandRouter {
|
|
|
969
1012
|
await execFileAsync('git', ['worktree', 'remove', '--force', workspace], {
|
|
970
1013
|
cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_CLEANUP, maxBuffer: GIT_MAX_BUFFER_CLEANUP, windowsHide: true,
|
|
971
1014
|
});
|
|
1015
|
+
const branchOutcome = await deleteBranchIfMerged();
|
|
972
1016
|
return {
|
|
973
1017
|
success: true,
|
|
974
1018
|
removedPath: workspace,
|
|
975
1019
|
repoRoot,
|
|
1020
|
+
...branchOutcome,
|
|
976
1021
|
fallback: 'git_worktree_remove_submodule_deinit' as const,
|
|
977
1022
|
forced: true,
|
|
978
1023
|
reason: 'working_trees_containing_submodules' as const,
|
|
@@ -990,10 +1035,12 @@ export class DaemonCommandRouter {
|
|
|
990
1035
|
cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_CLEANUP, maxBuffer: GIT_MAX_BUFFER_CLEANUP, windowsHide: true,
|
|
991
1036
|
});
|
|
992
1037
|
} catch { /* prune is best-effort */ }
|
|
1038
|
+
const branchOutcome = await deleteBranchIfMerged();
|
|
993
1039
|
return {
|
|
994
1040
|
success: true,
|
|
995
1041
|
removedPath: workspace,
|
|
996
1042
|
repoRoot,
|
|
1043
|
+
...branchOutcome,
|
|
997
1044
|
fallback: 'fs_rm_worktree_prune' as const,
|
|
998
1045
|
forced: true,
|
|
999
1046
|
reason: 'working_trees_containing_submodules' as const,
|
package/src/git/git-worktree.ts
CHANGED
|
@@ -257,6 +257,74 @@ export function parseWorktreeListOutput(output: string): WorktreeEntry[] {
|
|
|
257
257
|
return entries;
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
// ─── Branch ref deletion ────────────────────────
|
|
261
|
+
|
|
262
|
+
export interface BranchRefDeleteResult {
|
|
263
|
+
/** True if the branch ref no longer exists after this call. */
|
|
264
|
+
deleted: boolean;
|
|
265
|
+
/** Why it was (not) deleted, for surfacing in the cleanup result. */
|
|
266
|
+
reason: string;
|
|
267
|
+
/** True when a forced delete (`-D`) was needed (e.g. squash/patch-equivalent merge). */
|
|
268
|
+
forced?: boolean;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Delete a local branch ref after its worktree was removed.
|
|
273
|
+
*
|
|
274
|
+
* SAFETY: this is only meant to be called once the caller has independently
|
|
275
|
+
* verified that the branch is fully merged / its content is contained in the
|
|
276
|
+
* default ref (no work loss). It first tries the safe `git branch -d`, which
|
|
277
|
+
* refuses to delete a branch git itself does not consider merged. If
|
|
278
|
+
* `safeDeleteOnly` is false (the caller proved containment by patch-equivalence,
|
|
279
|
+
* which `-d` cannot see), it falls back to `git branch -D`. When the branch is
|
|
280
|
+
* already gone, this reports `deleted: true` idempotently.
|
|
281
|
+
*/
|
|
282
|
+
export async function deleteBranchRef(
|
|
283
|
+
repoRoot: string,
|
|
284
|
+
branch: string,
|
|
285
|
+
opts: { safeDeleteOnly?: boolean } = {},
|
|
286
|
+
): Promise<BranchRefDeleteResult> {
|
|
287
|
+
const name = (branch || '').trim();
|
|
288
|
+
if (!name) return { deleted: false, reason: 'empty_branch_name' };
|
|
289
|
+
|
|
290
|
+
// Idempotent: nothing to delete if the ref does not exist.
|
|
291
|
+
try {
|
|
292
|
+
await execFileAsync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], {
|
|
293
|
+
cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, windowsHide: true,
|
|
294
|
+
});
|
|
295
|
+
} catch {
|
|
296
|
+
return { deleted: true, reason: 'branch_ref_absent' };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Try the safe delete first — git refuses if it cannot see the branch as merged.
|
|
300
|
+
try {
|
|
301
|
+
await execFileAsync('git', ['branch', '-d', name], {
|
|
302
|
+
cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, windowsHide: true,
|
|
303
|
+
});
|
|
304
|
+
return { deleted: true, reason: 'safe_deleted_merged_branch' };
|
|
305
|
+
} catch (error: any) {
|
|
306
|
+
const stderr = typeof error?.stderr === 'string' ? error.stderr : '';
|
|
307
|
+
const notMerged = /not fully merged/i.test(stderr) || /not fully merged/i.test(String(error?.message || ''));
|
|
308
|
+
if (!notMerged) {
|
|
309
|
+
return { deleted: false, reason: `branch_delete_failed: ${stderr.trim() || error?.message || 'unknown error'}` };
|
|
310
|
+
}
|
|
311
|
+
// git -d refused. Only force when the caller proved containment another way
|
|
312
|
+
// (e.g. squash/cherry-pick/patch-equivalent merge that -d cannot detect).
|
|
313
|
+
if (opts.safeDeleteOnly) {
|
|
314
|
+
return { deleted: false, reason: 'branch_not_merged_per_git_safe_delete_only' };
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
await execFileAsync('git', ['branch', '-D', name], {
|
|
318
|
+
cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, windowsHide: true,
|
|
319
|
+
});
|
|
320
|
+
return { deleted: true, reason: 'force_deleted_patch_equivalent_branch', forced: true };
|
|
321
|
+
} catch (forceError: any) {
|
|
322
|
+
const fErr = typeof forceError?.stderr === 'string' ? forceError.stderr : forceError?.message;
|
|
323
|
+
return { deleted: false, reason: `branch_force_delete_failed: ${String(fErr || 'unknown error').trim()}` };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
260
328
|
// ─── Prune ──────────────────────────────────────
|
|
261
329
|
|
|
262
330
|
async function pruneWorktrees(repoRoot: string): Promise<void> {
|
|
@@ -130,6 +130,18 @@ function isMutationKeywordInCommandContext(text: string, matchStart: number, mat
|
|
|
130
130
|
// Line start (only whitespace before the keyword on this line).
|
|
131
131
|
if (/^\s*$/.test(linePrefix)) return true;
|
|
132
132
|
|
|
133
|
+
// Executed script at command position: the keyword sits inside a run-prefix
|
|
134
|
+
// path token (`./scripts/version-bump.sh`, `~/bin/deploy.sh`) that is the
|
|
135
|
+
// leading token of the line or follows a shell connective — the script is
|
|
136
|
+
// being invoked, so the keyword is a real command. (Path *arguments* like
|
|
137
|
+
// `list build/Release` are excluded earlier by isInsidePathSegment.)
|
|
138
|
+
let tokStart = matchStart;
|
|
139
|
+
while (tokStart > lineStart && !/\s/.test(text[tokStart - 1])) tokStart--;
|
|
140
|
+
const beforeToken = text.slice(lineStart, tokStart);
|
|
141
|
+
const tokenLead = text.slice(tokStart, matchStart);
|
|
142
|
+
const atCmdPos = /^\s*$/.test(beforeToken) || /(?:&&|\|\||\||;)\s*$/.test(beforeToken);
|
|
143
|
+
if (atCmdPos && /^(?:\.\/|\.\.\/|~\/|\/)/.test(tokenLead)) return true;
|
|
144
|
+
|
|
133
145
|
// After a shell connective or imperative connective introducing a command.
|
|
134
146
|
// We look at what immediately precedes the keyword on the same line.
|
|
135
147
|
if (/(?:&&|\|\||\||;)\s*$/.test(linePrefix)) return true;
|
|
@@ -171,15 +183,111 @@ function isInsideBackticksOrFence(text: string, matchStart: number, matchEnd: nu
|
|
|
171
183
|
return inlineCount % 2 === 1;
|
|
172
184
|
}
|
|
173
185
|
|
|
186
|
+
/**
|
|
187
|
+
* True if the matched keyword is part of a filesystem-path-like token rather than
|
|
188
|
+
* a standalone word/command. A "release" inside `build/Release`, `dist/release/`,
|
|
189
|
+
* or `packages\release` is a directory/file name, not a deploy instruction. We
|
|
190
|
+
* look at the characters immediately adjacent to the match: if either side is a
|
|
191
|
+
* path separator (`/` or `\`) joining it to another path segment, the keyword is
|
|
192
|
+
* a path component.
|
|
193
|
+
*
|
|
194
|
+
* IMPORTANT: a path token that is itself being *executed* — i.e. the leading
|
|
195
|
+
* token of a command line such as `./scripts/version-bump.sh patch` — is NOT a
|
|
196
|
+
* suppressible path; that is a real command. We therefore exclude the case where
|
|
197
|
+
* the path token sits at command-invocation position (line start, optionally with
|
|
198
|
+
* a leading `./` / `/` / `~/`, or right after a shell connective), which means it
|
|
199
|
+
* is the program being run rather than an argument being inspected.
|
|
200
|
+
*/
|
|
201
|
+
function isInsidePathSegment(text: string, matchStart: number, matchEnd: number): boolean {
|
|
202
|
+
const prev = matchStart > 0 ? text[matchStart - 1] : '';
|
|
203
|
+
const next = matchEnd < text.length ? text[matchEnd] : '';
|
|
204
|
+
const isSep = (c: string) => c === '/' || c === '\\';
|
|
205
|
+
const segChar = (c: string) => /[A-Za-z0-9._~-]/.test(c);
|
|
206
|
+
const inPath =
|
|
207
|
+
(isSep(prev) && (next === '' || isSep(next) || segChar(next) || /\s/.test(next))) ||
|
|
208
|
+
(isSep(next) && (prev === '' || isSep(prev) || segChar(prev) || /\s/.test(prev)));
|
|
209
|
+
if (!inPath) return false;
|
|
210
|
+
|
|
211
|
+
// Find the whole whitespace-delimited path token containing the match and the
|
|
212
|
+
// text on its line before it. If the token is the first thing on the line
|
|
213
|
+
// (after an optional `./`, `/`, `~/`, or `../` prefix) or directly follows a
|
|
214
|
+
// shell connective, it is being executed → not a suppressible path.
|
|
215
|
+
const lineStart = text.lastIndexOf('\n', matchStart - 1) + 1;
|
|
216
|
+
let tokStart = matchStart;
|
|
217
|
+
while (tokStart > lineStart && !/\s/.test(text[tokStart - 1])) tokStart--;
|
|
218
|
+
const linePrefixBeforeToken = text.slice(lineStart, tokStart);
|
|
219
|
+
const tokenPrefix = text.slice(tokStart, matchStart);
|
|
220
|
+
// The path token is in command position when nothing but whitespace (or a
|
|
221
|
+
// shell connective) precedes it on the line, AND the token itself is a
|
|
222
|
+
// run-prefix path (`./`, `/`, `~/`, `../`). A bare `build/Release` argument
|
|
223
|
+
// after a verb ("list build/Release") is NOT command position.
|
|
224
|
+
const atLineStart = /^\s*$/.test(linePrefixBeforeToken);
|
|
225
|
+
const afterConnective = /(?:&&|\|\||\||;)\s*$/.test(linePrefixBeforeToken);
|
|
226
|
+
const isRunPrefixPath = /^(?:\.\/|\.\.\/|~\/|\/)/.test(tokenPrefix);
|
|
227
|
+
if ((atLineStart || afterConnective) && isRunPrefixPath) return false;
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* True if [matchStart, matchEnd) lies inside a quoted span — straight quotes
|
|
233
|
+
* (`"..."`, `'...'`), typographic quotes (`“...”`, `‘...’`), or CJK brackets
|
|
234
|
+
* (`「...」`, `『...』`, `《...》`). Commit-message citations and other quoted prose
|
|
235
|
+
* (e.g. quoting a `chore: ... version-bump ...` log line, or the Korean
|
|
236
|
+
* "포인터 bump" phrase) are descriptive, not command invocations, so a forbidden
|
|
237
|
+
* keyword inside such a quote must not trip the guardrail. Backticks are excluded
|
|
238
|
+
* here on purpose — they denote shell/code snippets and are handled as command
|
|
239
|
+
* context in {@link isInsideBackticksOrFence}.
|
|
240
|
+
*/
|
|
241
|
+
function isInsideQuotedSpan(text: string, matchStart: number, matchEnd: number): boolean {
|
|
242
|
+
// Paired quotes: an opening char before the match without its closer in
|
|
243
|
+
// between, and the matching closer after the match.
|
|
244
|
+
const pairs: Array<[string, string]> = [
|
|
245
|
+
['“', '”'], ['‘', '’'], ['「', '」'], ['『', '』'], ['《', '》'],
|
|
246
|
+
];
|
|
247
|
+
for (const [open, close] of pairs) {
|
|
248
|
+
const openIdx = text.lastIndexOf(open, matchStart - 1);
|
|
249
|
+
if (openIdx < 0) continue;
|
|
250
|
+
// No closer between the opener and the match → still open at the match.
|
|
251
|
+
if (text.indexOf(close, openIdx + open.length) >= matchEnd) return true;
|
|
252
|
+
}
|
|
253
|
+
// Symmetric quotes (" and '): odd count of the quote char before the match,
|
|
254
|
+
// within the same line (a quote does not span newlines), and a closing quote
|
|
255
|
+
// later on the line.
|
|
256
|
+
const lineStart = text.lastIndexOf('\n', matchStart - 1) + 1;
|
|
257
|
+
const lineEnd = (() => { const i = text.indexOf('\n', matchEnd); return i < 0 ? text.length : i; })();
|
|
258
|
+
for (const q of ['"', "'"]) {
|
|
259
|
+
let count = 0;
|
|
260
|
+
for (let i = lineStart; i < matchStart; i++) if (text[i] === q) count++;
|
|
261
|
+
if (count % 2 === 1 && text.indexOf(q, matchEnd) >= 0 && text.indexOf(q, matchEnd) < lineEnd) {
|
|
262
|
+
// For "'" guard against the apostrophe-in-prose case (e.g. "don't"):
|
|
263
|
+
// require the opening quote to be preceded by a boundary (start/space/
|
|
264
|
+
// open-paren) so a contraction apostrophe is not read as a quote opener.
|
|
265
|
+
if (q === '"') return true;
|
|
266
|
+
// find the opening quote (the one making the count odd)
|
|
267
|
+
let openPos = -1, c = 0;
|
|
268
|
+
for (let i = lineStart; i < matchStart; i++) { if (text[i] === q) { c++; if (c % 2 === 1) openPos = i; } }
|
|
269
|
+
const beforeOpen = openPos > lineStart ? text[openPos - 1] : ' ';
|
|
270
|
+
if (/[\s(\[{>]/.test(beforeOpen) || openPos === lineStart) return true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
|
|
174
276
|
/**
|
|
175
277
|
* Decides whether a forbidden keyword match at [matchStart, matchEnd) should
|
|
176
278
|
* count as a real violation. A match counts only when it is in command context
|
|
177
|
-
* and not negated
|
|
178
|
-
*
|
|
279
|
+
* and not negated, and is NOT merely a path segment or quoted (descriptive)
|
|
280
|
+
* citation. Negation always wins (even inside a code block), per the conservative
|
|
281
|
+
* rule: code-block + negation → exclude; other code-block → keep.
|
|
179
282
|
*/
|
|
180
283
|
function isRealMutationMatch(text: string, matchStart: number, matchEnd: number): boolean {
|
|
181
284
|
if (hasNegationBefore(text, matchStart)) return false;
|
|
182
285
|
if (hasTrailingNegation(text, matchEnd)) return false;
|
|
286
|
+
// A keyword that is part of a file path (build/Release, dist/release/) or
|
|
287
|
+
// sits inside quoted prose (a commit-message citation, "포인터 bump") is a
|
|
288
|
+
// description, not a command — suppress it even if it lands at line-start.
|
|
289
|
+
if (isInsidePathSegment(text, matchStart, matchEnd)) return false;
|
|
290
|
+
if (isInsideQuotedSpan(text, matchStart, matchEnd)) return false;
|
|
183
291
|
return isMutationKeywordInCommandContext(text, matchStart, matchEnd);
|
|
184
292
|
}
|
|
185
293
|
|
|
@@ -430,6 +430,14 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
430
430
|
private context: InstanceContext | null = null;
|
|
431
431
|
private events: ProviderEvent[] = [];
|
|
432
432
|
private lastStatus: string = 'starting';
|
|
433
|
+
// Idempotency guard for the queue-claim agent:ready event. agent:ready is the
|
|
434
|
+
// sole signal the mesh coordinator's tryAssignQueueTask waits on to hand a
|
|
435
|
+
// queued task to this worker. It is emitted in two places: the boot-time
|
|
436
|
+
// starting→idle one-shot, and the readySeen re-arm below. This flag makes the
|
|
437
|
+
// event fire AT MOST ONCE per session so a worker is never claimed twice and a
|
|
438
|
+
// queued task is never double-dispatched/double-injected. Whichever path fires
|
|
439
|
+
// first sets it; the other becomes a no-op.
|
|
440
|
+
private agentReadyEmitted = false;
|
|
433
441
|
private generatingStartedAt: number = 0;
|
|
434
442
|
private settings: Record<string, any> = {};
|
|
435
443
|
private monitor: StatusMonitor;
|
|
@@ -1920,6 +1928,18 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1920
1928
|
} catch { /* adapter gone / transient — next frame retries */ }
|
|
1921
1929
|
}
|
|
1922
1930
|
|
|
1931
|
+
/**
|
|
1932
|
+
* Emit the queue-claim agent:ready event at most once per session. Both the
|
|
1933
|
+
* boot-time starting→idle one-shot and the fsmReadySeen re-arm call this; the
|
|
1934
|
+
* agentReadyEmitted guard ensures the second caller is a no-op so a worker is
|
|
1935
|
+
* never claimed twice and a queued task is never double-dispatched.
|
|
1936
|
+
*/
|
|
1937
|
+
private emitAgentReadyOnce(chatTitle: string, now: number): void {
|
|
1938
|
+
if (this.agentReadyEmitted) return;
|
|
1939
|
+
this.agentReadyEmitted = true;
|
|
1940
|
+
this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1923
1943
|
private detectStatusTransition(): void {
|
|
1924
1944
|
const now = Date.now();
|
|
1925
1945
|
// Status-change handling is a hot path: PTY output can fire it many times
|
|
@@ -2142,7 +2162,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2142
2162
|
this.scheduleCompletedDebounceFlush(flushDelay);
|
|
2143
2163
|
}
|
|
2144
2164
|
} else if (newStatus === 'idle' && this.lastStatus === 'starting') {
|
|
2145
|
-
this.
|
|
2165
|
+
this.emitAgentReadyOnce(chatTitle, now);
|
|
2146
2166
|
} else if (newStatus === 'error') {
|
|
2147
2167
|
if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
|
|
2148
2168
|
this.generatingDebouncePending = null;
|
|
@@ -2171,6 +2191,28 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2171
2191
|
this.lastStatus = newStatus;
|
|
2172
2192
|
}
|
|
2173
2193
|
|
|
2194
|
+
// Re-arm the queue-claim agent:ready on the FSM's first GENUINE ready.
|
|
2195
|
+
//
|
|
2196
|
+
// The boot-time starting→idle one-shot above is the historical claim
|
|
2197
|
+
// trigger, but it is consumed too early for FSM-spec providers whose
|
|
2198
|
+
// INITIAL state already reports status 'idle' (e.g. antigravity-cli): the
|
|
2199
|
+
// adapter reports idle before maybeMarkReady has fired, so lastStatus
|
|
2200
|
+
// advances starting→idle while the prompt is not yet drawn and the worker
|
|
2201
|
+
// cannot yet claim. Subsequent state-driven idle frames are idle→idle (no
|
|
2202
|
+
// status change), so the one-shot never re-fires and the worker strands its
|
|
2203
|
+
// queued task — the coordinator then relaunch-loops every ~90s.
|
|
2204
|
+
//
|
|
2205
|
+
// The adapter surfaces fsmReadySeen=true exactly when the FSM reaches its
|
|
2206
|
+
// first non-initial idle (the prompt is genuinely up). On that signal we
|
|
2207
|
+
// emit agent:ready once more. emitAgentReadyOnce is idempotent (guarded by
|
|
2208
|
+
// agentReadyEmitted), so providers whose boot one-shot already landed on a
|
|
2209
|
+
// real ready (claude-cli / codex-cli / hermes-cli, which use a startup
|
|
2210
|
+
// grace and whose initial state is not idle) treat this as a no-op — no
|
|
2211
|
+
// double claim, no double task injection.
|
|
2212
|
+
if (newStatus === 'idle' && adapterStatus.fsmReadySeen === true && !this.agentReadyEmitted) {
|
|
2213
|
+
this.emitAgentReadyOnce(chatTitle, now);
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2174
2216
|
this.applyProviderResponse(parsedStatus, {
|
|
2175
2217
|
phase: (newStatus === 'idle' && (previousStatus === 'generating' || previousStatus === 'waiting_approval'))
|
|
2176
2218
|
? 'turn_completed'
|
|
@@ -206,7 +206,12 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
206
206
|
if (state.status === 'generating') {
|
|
207
207
|
return { status: 'generating', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, ...sessionFields };
|
|
208
208
|
}
|
|
209
|
-
|
|
209
|
+
// fsmReadySeen lets CliProviderInstance re-arm the queue-claim agent:ready
|
|
210
|
+
// on the first genuine ready (prompt drawn), independent of the boot-time
|
|
211
|
+
// starting→idle one-shot that the provider-instance otherwise relies on.
|
|
212
|
+
// Surfaced only on idle so the provider-instance fires agent:ready exactly
|
|
213
|
+
// when the worker is actually ready to claim.
|
|
214
|
+
return { status: 'idle', messages: [], activeModal: null, activeInteractivePrompt: this.activeInteractivePrompt, fsmReadySeen: this.driver.hasSeenReady?.() ?? false, ...sessionFields };
|
|
210
215
|
}
|
|
211
216
|
|
|
212
217
|
private maybeRefreshNativeHistory(): void {
|
|
@@ -125,6 +125,7 @@ export interface ISpecDriver {
|
|
|
125
125
|
getSections(): Array<{ id: string; text: string }> | null;
|
|
126
126
|
getLastBusyAt(): number;
|
|
127
127
|
hasIdleHoldPending(): boolean;
|
|
128
|
+
hasSeenReady(): boolean;
|
|
128
129
|
getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null;
|
|
129
130
|
getFsmDebug?(): unknown;
|
|
130
131
|
getFsmSnapshotHistory?(): ReadonlyArray<FsmSnapshotEntry>;
|
|
@@ -466,6 +467,17 @@ export class FsmDriver implements ISpecDriver {
|
|
|
466
467
|
// Report true while any outgoing transition is hold-blocked.
|
|
467
468
|
return (this.lastFsmEval?.transitions ?? []).some(t => !t.holdSatisfied && t.condResult);
|
|
468
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* True once the machine has reached its first non-initial idle state (the
|
|
472
|
+
* prompt is genuinely drawn — see maybeMarkReady). The cli-adapter surfaces
|
|
473
|
+
* this on its idle status so CliProviderInstance can re-arm the queue-claim
|
|
474
|
+
* agent:ready on the first genuine ready, independent of the boot-time
|
|
475
|
+
* starting→idle one-shot (which is consumed too early for specs whose
|
|
476
|
+
* initial state already reports idle).
|
|
477
|
+
*/
|
|
478
|
+
hasSeenReady(): boolean {
|
|
479
|
+
return this.readySeenOnce;
|
|
480
|
+
}
|
|
469
481
|
getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null {
|
|
470
482
|
// Surface the busy→ready transition's stable countdown, if any, so the
|
|
471
483
|
// existing panel field stays meaningful.
|