Skip to content

Commit 701a94c

Browse files
feat(worktree): require managed task trackers (#942)
* feat(worktree): require managed task trackers * fix(worktree): enforce agent tracker metadata --------- Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent 58f7147 commit 701a94c

11 files changed

Lines changed: 166 additions & 21 deletions

inc/Abilities/WorkspaceAbilities.php

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use DataMachineCode\Workspace\WorkspaceSafeCleanupOrchestrator;
2626
use DataMachineCode\Workspace\WorkspaceReader;
2727
use DataMachineCode\Workspace\WorkspaceWriter;
28+
use DataMachineCode\Workspace\WorktreeContextInjector;
2829
use DataMachineCode\Support\GitRunner;
2930
use DataMachineCode\Support\RuntimeCapabilities;
3031

@@ -1344,6 +1345,10 @@ private function registerAbilities(): void {
13441345
'type' => 'string',
13451346
'description' => 'Optional short task/issue reference (e.g. `org/repo#123`) recorded alongside task_url. Falls back to DATAMACHINE_TASK_REF env when omitted.',
13461347
),
1348+
'require_task_tracker' => array(
1349+
'type' => 'boolean',
1350+
'description' => 'Require a valid task URL or task reference before creating the worktree. Defaults true; trusted operator-local callers may explicitly set false.',
1351+
),
13471352
),
13481353
'required' => array( 'repo', 'branch' ),
13491354
),
@@ -3886,7 +3891,8 @@ public static function worktreeAdd( array $input ): array|\WP_Error {
38863891
$allow_unverified_freshness = array_key_exists('allow_unverified_freshness', $input) ? (bool) $input['allow_unverified_freshness'] : false;
38873892
// Default rebase_base=false; only true when explicitly requested.
38883893
$rebase_base = array_key_exists('rebase_base', $input) ? (bool) $input['rebase_base'] : false;
3889-
$force = ! empty($input['force']);
3894+
$force = ! empty($input['force']);
3895+
$require_task_tracker = array_key_exists('require_task_tracker', $input) ? (bool) $input['require_task_tracker'] : true;
38903896
$task = array();
38913897
if ( isset($input['task_url']) && '' !== trim( (string) $input['task_url']) ) {
38923898
$task['task_url'] = (string) $input['task_url'];
@@ -3896,6 +3902,10 @@ public static function worktreeAdd( array $input ): array|\WP_Error {
38963902
}
38973903

38983904
$workspace = new Workspace();
3905+
$task = WorktreeContextInjector::resolve_task_metadata($task) ?? array();
3906+
if ( $require_task_tracker && empty($task) && RemoteWorkspaceBackend::should_handle() && ! self::hasLocalPrimaryCheckout($workspace, (string) ( $input['repo'] ?? '' )) ) {
3907+
return new \WP_Error('worktree_task_tracker_required', 'Refusing to create a managed worktree without a valid task URL or task reference.', array( 'status' => 400 ));
3908+
}
38993909
if ( RemoteWorkspaceBackend::should_handle() && self::hasLocalPrimaryCheckout($workspace, (string) ( $input['repo'] ?? '' )) ) {
39003910
return $workspace->worktree_add(
39013911
$input['repo'] ?? '',
@@ -3907,15 +3917,17 @@ public static function worktreeAdd( array $input ): array|\WP_Error {
39073917
$rebase_base,
39083918
$force,
39093919
$task,
3910-
$allow_unverified_freshness
3920+
$allow_unverified_freshness,
3921+
$require_task_tracker
39113922
);
39123923
}
39133924

39143925
if ( RemoteWorkspaceBackend::should_handle() ) {
39153926
$result = ( new RemoteWorkspaceBackend() )->worktree_add(
39163927
$input['repo'] ?? '',
39173928
$input['branch'] ?? '',
3918-
$input['from'] ?? null
3929+
$input['from'] ?? null,
3930+
$task
39193931
);
39203932
if ( ! self::shouldFallbackToLocalWorkspace($result) ) {
39213933
return self::decorate_remote_workspace_result('worktree_add', $result);
@@ -3932,7 +3944,8 @@ public static function worktreeAdd( array $input ): array|\WP_Error {
39323944
$rebase_base,
39333945
$force,
39343946
$task,
3935-
$allow_unverified_freshness
3947+
$allow_unverified_freshness,
3948+
$require_task_tracker
39363949
);
39373950
}
39383951

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3889,8 +3889,8 @@ private function renderGitOperationResult( string $operation, array $result, arr
38893889
* wp datamachine-code workspace worktree finalize data-machine@fix-foo --pr=https://github.com/org/repo/pull/123
38903890
* wp datamachine-code workspace worktree mark-cleanup-eligible data-machine@fix-foo
38913891
*
3892-
* # Record a task/issue URL on a worktree for ownership tracking
3893-
* wp datamachine-code workspace worktree add data-machine fix/foo --task-url=https://github.com/org/repo/issues/42
3892+
* # Require a task/issue URL before creating an agent-managed worktree
3893+
* wp datamachine-code workspace worktree add data-machine fix/foo --task-url=https://github.com/org/repo/issues/42 --require-task-tracker
38943894
*
38953895
* @subcommand worktree
38963896
*/
@@ -3990,7 +3990,7 @@ public function worktree( array $args, array $assoc_args ): void {
39903990
switch ( $operation ) {
39913991
case 'add':
39923992
if ( empty($args[1]) || empty($args[2]) ) {
3993-
WP_CLI::error('Usage: worktree add <repo> <branch> [--from=<ref>|--base=<ref>|--base-ref=<ref>|--base-branch=<branch>] [--skip-context-injection] [--skip-bootstrap] [--allow-stale] [--allow-unverified-freshness] [--rebase-base] [--force]');
3993+
WP_CLI::error('Usage: worktree add <repo> <branch> [--from=<ref>|--base=<ref>|--base-ref=<ref>|--base-branch=<branch>] [--skip-context-injection] [--skip-bootstrap] [--allow-stale] [--allow-unverified-freshness] [--rebase-base] [--force] [--task-url=<url>|--task-ref=<ref>] [--require-task-tracker]');
39943994
return;
39953995
}
39963996
$input['repo'] = $args[1];
@@ -4028,6 +4028,8 @@ public function worktree( array $args, array $assoc_args ): void {
40284028
$input['rebase_base'] = ! empty($assoc_args['rebase-base']);
40294029
// --force is an explicit disk-budget override for add.
40304030
$input['force'] = ! empty($assoc_args['force']);
4031+
// CLI is the explicit operator-local path; strict tracking is opt-in.
4032+
$input['require_task_tracker'] = ! empty($assoc_args['require-task-tracker']);
40314033
if ( isset($assoc_args['task-url']) && '' !== trim( (string) $assoc_args['task-url']) ) {
40324034
$input['task_url'] = (string) $assoc_args['task-url'];
40334035
}

inc/CodeTask/CodeTaskCreator.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ public function create( EvidencePacket $packet, array $args = array() ): array|\
7070
true,
7171
! empty($args['allow_stale']),
7272
false,
73-
! empty($args['force'])
73+
! empty($args['force']),
74+
array( 'task_url' => $packet->source_url() ),
75+
true
7476
);
7577

7678
if ( $worktree instanceof \WP_Error ) {

inc/CodeTask/CodeTaskWorkspaceInterface.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public function clone_repo( string $url, string $name ): array|\WP_Error;
2626
/**
2727
* @return array<string,mixed>|\WP_Error
2828
*/
29-
public function worktree_add( string $repo, string $branch, ?string $from, bool $inject_context, bool $bootstrap, bool $allow_stale, bool $rebase_base, bool $force ): array|\WP_Error;
29+
public function worktree_add( string $repo, string $branch, ?string $from, bool $inject_context, bool $bootstrap, bool $allow_stale, bool $rebase_base, bool $force, array $task, bool $require_task_tracker ): array|\WP_Error;
3030

3131
public function get_repo_path( string $handle ): string;
3232
}

inc/CodeTask/WorkspaceCodeTaskWorkspace.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ public function clone_repo( string $url, string $name ): array|\WP_Error {
2929
return $this->workspace->clone_repo($url, $name);
3030
}
3131

32-
public function worktree_add( string $repo, string $branch, ?string $from, bool $inject_context, bool $bootstrap, bool $allow_stale, bool $rebase_base, bool $force ): array|\WP_Error {
33-
return $this->workspace->worktree_add($repo, $branch, $from, $inject_context, $bootstrap, $allow_stale, $rebase_base, $force);
32+
public function worktree_add( string $repo, string $branch, ?string $from, bool $inject_context, bool $bootstrap, bool $allow_stale, bool $rebase_base, bool $force, array $task, bool $require_task_tracker ): array|\WP_Error {
33+
return $this->workspace->worktree_add($repo, $branch, $from, $inject_context, $bootstrap, $allow_stale, $rebase_base, $force, $task, false, $require_task_tracker);
3434
}
3535

3636
public function get_repo_path( string $handle ): string {

inc/Tools/WorkspaceTools.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -550,8 +550,9 @@ public function handleGitPull( array $parameters ): array
550550
public function handleWorktreeAdd( array $parameters ): array
551551
{
552552
$input = array(
553-
'repo' => $parameters['repo'] ?? '',
554-
'branch' => $parameters['branch'] ?? '',
553+
'repo' => $parameters['repo'] ?? '',
554+
'branch' => $parameters['branch'] ?? '',
555+
'require_task_tracker' => true,
555556
);
556557

557558
foreach ( array( 'from', 'task_url', 'task_ref' ) as $key ) {

inc/Workspace/RemoteWorkspaceBackend.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ public function clone_repo( string $url, ?string $name = null ): array|\WP_Error
107107
*
108108
* @return array<string,mixed>|\WP_Error
109109
*/
110-
public function worktree_add( string $repo_name, string $branch, ?string $from = null ): array|\WP_Error {
110+
public function worktree_add( string $repo_name, string $branch, ?string $from = null, array $task = array() ): array|\WP_Error {
111111
$repo = $this->resolve_repo($repo_name);
112112
if ( is_wp_error($repo) ) {
113113
return $repo;
@@ -126,6 +126,7 @@ public function worktree_add( string $repo_name, string $branch, ?string $from =
126126
'repo' => $repo,
127127
'branch' => $branch,
128128
'base_ref' => null !== $from && '' !== $from ? $from : '',
129+
'task' => $task,
129130
'pending_files' => array(),
130131
'changed_files' => array(),
131132
'last_commit_sha' => '',

inc/Workspace/WorkspaceWorktreeLifecycle.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,10 @@ trait WorkspaceWorktreeLifecycle {
7070
* @param bool $force Bypass the disk-budget refusal threshold (default false).
7171
* @param array $task Optional task metadata recorded on the worktree.
7272
* @param bool $allow_unverified_freshness Bypass fetch-failure freshness verification (default false).
73+
* @param bool $require_task_tracker Reject creation without task metadata (default false).
7374
* @return array{success: bool, handle: string, path: string, branch: string, slug: string, created_branch: bool, message: string, disk_budget?: array, context_injected?: bool, context_files?: string[], context_skip_reason?: string, bootstrap?: array, fetch_failed?: bool, fetch_error?: string, stale_commits_behind?: int, upstream?: string, base_stale_commits_behind?: int, base_upstream?: string, default_branch_commits_behind?: int, default_branch_ref?: string, gate_threshold?: int, rebase_attempted?: bool, rebase_succeeded?: bool, rebase_error?: string, rebase_target?: string}|\WP_Error
7475
*/
75-
public function worktree_add( string $repo, string $branch, ?string $from = null, bool $inject_context = true, bool $bootstrap = true, bool $allow_stale = false, bool $rebase_base = false, bool $force = false, array $task = array(), bool $allow_unverified_freshness = false ): array|\WP_Error {
76+
public function worktree_add( string $repo, string $branch, ?string $from = null, bool $inject_context = true, bool $bootstrap = true, bool $allow_stale = false, bool $rebase_base = false, bool $force = false, array $task = array(), bool $allow_unverified_freshness = false, bool $require_task_tracker = false ): array|\WP_Error {
7677
$visible = $this->require_workspace_visible();
7778
if ( null !== $visible ) {
7879
return $visible;
@@ -92,6 +93,15 @@ public function worktree_add( string $repo, string $branch, ?string $from = null
9293
return new \WP_Error('invalid_branch', 'Branch name is required.', array( 'status' => 400 ));
9394
}
9495

96+
$task = WorktreeContextInjector::resolve_task_metadata($task) ?? array();
97+
if ( $require_task_tracker && empty($task) ) {
98+
return new \WP_Error(
99+
'worktree_task_tracker_required',
100+
'Refusing to create a managed worktree without a valid task URL or task reference. Supply task_url or task_ref, or use an operator-local creation path that does not require a tracker.',
101+
array( 'status' => 400 )
102+
);
103+
}
104+
95105
$slug = $this->slugify_branch($branch);
96106
if ( '' === $slug ) {
97107
return new \WP_Error('invalid_branch', sprintf('Branch "%s" produced an empty slug.', $branch), array( 'status' => 400 ));

inc/Workspace/WorktreeContextInjector.php

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ public static function build_lifecycle_metadata( array $args = array() ): array
161161
$user = self::resolve_origin_user();
162162
$agent = self::resolve_origin_agent();
163163
$session = self::resolve_origin_session();
164-
$task = self::resolve_origin_task($args);
164+
$task = self::resolve_task_metadata($args);
165165

166166
$created_at = gmdate('c');
167167

@@ -1474,7 +1474,7 @@ private static function is_url_subkey( string $subkey ): bool {
14741474
}
14751475

14761476
/**
1477-
* Resolve a task/issue reference for the worktree, when available.
1477+
* Resolve valid task metadata for the worktree, when available.
14781478
*
14791479
* Sources, in order:
14801480
* 1. Caller-supplied `task_url` / `task_ref` in `$args` (explicit wins).
@@ -1486,7 +1486,7 @@ private static function is_url_subkey( string $subkey ): bool {
14861486
* @param array<string,mixed> $args Worktree creation context.
14871487
* @return array<string,mixed>|null
14881488
*/
1489-
private static function resolve_origin_task( array $args = array() ): ?array {
1489+
public static function resolve_task_metadata( array $args = array() ): ?array {
14901490
$task = array();
14911491

14921492
$task_url = isset($args['task_url']) && '' !== trim( (string) $args['task_url']) ? trim( (string) $args['task_url']) : '';
@@ -1496,7 +1496,8 @@ private static function resolve_origin_task( array $args = array() ): ?array {
14961496
$task_url = trim($env_task_url);
14971497
}
14981498
}
1499-
if ( '' !== $task_url && preg_match('#^https?://#', $task_url) ) {
1499+
$task_url_parts = '' !== $task_url ? parse_url($task_url) : false;
1500+
if ( is_array($task_url_parts) && isset($task_url_parts['host'], $task_url_parts['scheme']) && in_array(strtolower((string) $task_url_parts['scheme']), array( 'http', 'https' ), true) ) {
15001501
$task['task_url'] = $task_url;
15011502
}
15021503

@@ -1507,7 +1508,8 @@ private static function resolve_origin_task( array $args = array() ): ?array {
15071508
$task_ref = trim($env_task_ref);
15081509
}
15091510
}
1510-
if ( '' !== $task_ref ) {
1511+
$normalized_task_ref = strtolower($task_ref);
1512+
if ( '' !== $normalized_task_ref && ! preg_match('/\s/', $normalized_task_ref) ) {
15111513
$task['task_ref'] = $task_ref;
15121514
}
15131515

tests/worktree-add-lifecycle.php

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,11 +289,51 @@ function create_primary_checkout( string $workspace_root ): void {
289289
assert_true(str_contains($refused->get_error_message(), 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=25'), 'disk pressure refusal must include the next cleanup command');
290290
assert_true(! is_dir($workspace_root . '/homeboy@audit-primitives-disk-refused'), 'disk pressure refusal left a worktree directory behind');
291291

292-
$result = $workspace->worktree_add('homeboy', 'audit-primitives-20260616', 'origin/main', false, false, false, false, true);
292+
$ability_default = WorkspaceAbilities::worktreeAdd(
293+
array(
294+
'repo' => 'homeboy',
295+
'branch' => 'ability-default-tracker-required',
296+
'from' => 'origin/main',
297+
'inject_context' => false,
298+
'bootstrap' => false,
299+
'force' => true,
300+
)
301+
);
302+
assert_true(is_wp_error($ability_default), 'agent-facing worktree ability accepted missing tracker metadata by default');
303+
assert_true('worktree_task_tracker_required' === $ability_default->get_error_code(), 'agent-facing worktree ability returned an unexpected error code');
304+
assert_true(! is_dir($workspace_root . '/homeboy@ability-default-tracker-required'), 'agent-facing tracker refusal left a worktree directory behind');
305+
306+
$ability_operator_local = WorkspaceAbilities::worktreeAdd(
307+
array(
308+
'repo' => 'homeboy',
309+
'branch' => 'ability-operator-local',
310+
'from' => 'origin/main',
311+
'inject_context' => false,
312+
'bootstrap' => false,
313+
'force' => true,
314+
'require_task_tracker' => false,
315+
)
316+
);
317+
assert_true(! is_wp_error($ability_operator_local), is_wp_error($ability_operator_local) ? $ability_operator_local->get_error_message() : 'explicit operator-local ability creation failed');
318+
assert_true(is_dir($workspace_root . '/homeboy@ability-operator-local'), 'explicit operator-local ability creation did not materialize a worktree');
319+
320+
$strict_missing = $workspace->worktree_add('homeboy', 'audit-primitives-tracker-required', 'origin/main', false, false, false, false, true, array(), false, true);
321+
assert_true(is_wp_error($strict_missing), 'strict worktree creation accepted missing tracker metadata');
322+
assert_true('worktree_task_tracker_required' === $strict_missing->get_error_code(), 'strict worktree creation returned an unexpected error code');
323+
assert_true(! is_dir($workspace_root . '/homeboy@audit-primitives-tracker-required'), 'strict tracker refusal left a worktree directory behind');
324+
325+
putenv('DATAMACHINE_TASK_URL=https://example.test/issues/environment');
326+
$result = $workspace->worktree_add('homeboy', 'audit-primitives-20260616', 'origin/main', false, false, false, false, true, array( 'task_url' => 'https://example.test/issues/explicit' ), false, true);
293327
assert_true(! is_wp_error($result), is_wp_error($result) ? $result->get_error_message() : 'worktree_add failed');
294328
assert_true(is_dir($result['path']), 'successful worktree_add path is not accessible');
295329
assert_true(isset($wpdb->rows['homeboy@audit-primitives-20260616']), 'successful worktree_add was not persisted');
296330
assert_true('refused' !== ( $result['disk_budget']['status'] ?? '' ), 'normal worktree_add should pass the disk budget gate without hard refusal');
331+
assert_true('https://example.test/issues/explicit' === ( $wpdb->rows['homeboy@audit-primitives-20260616']['task_url'] ?? '' ), 'explicit tracker metadata did not override the environment fallback');
332+
333+
$environment_tracker = $workspace->worktree_add('homeboy', 'audit-primitives-environment-tracker', 'origin/main', false, false, false, false, true, array(), false, true);
334+
assert_true(! is_wp_error($environment_tracker), is_wp_error($environment_tracker) ? $environment_tracker->get_error_message() : 'environment tracker fallback failed');
335+
assert_true('https://example.test/issues/environment' === ( $wpdb->rows['homeboy@audit-primitives-environment-tracker']['task_url'] ?? '' ), 'environment tracker metadata was not persisted');
336+
putenv('DATAMACHINE_TASK_URL');
297337

298338
$show = $workspace->show_repo('homeboy@audit-primitives-20260616');
299339
assert_true(! is_wp_error($show), 'persisted worktree is not visible to show_repo');

0 commit comments

Comments
 (0)